diff --git a/include/class.auth.php b/include/class.auth.php
index eb7b740923cccb67ecaf32a4c930bf37259465b9..e320a4f5ec384bfa16618c155d952973b4397c10 100644
--- a/include/class.auth.php
+++ b/include/class.auth.php
@@ -212,7 +212,7 @@ abstract class AuthenticationBackend {
             $bk->audit($result, $credentials);
     }
 
-    static function process($username, $password=null, &$errors) {
+    static function process($username, $password=null, &$errors=array()) {
 
         if (!$username)
             return false;
diff --git a/include/class.import.php b/include/class.import.php
new file mode 100644
index 0000000000000000000000000000000000000000..c128136b7439845714435a503f89ed131609e0bd
--- /dev/null
+++ b/include/class.import.php
@@ -0,0 +1,193 @@
+<?php
+/*********************************************************************
+    class.import.php
+
+    Utilities for importing objects and data (usually via CSV)
+
+    Peter Rotich <peter@osticket.com>
+    Jared Hancock <jared@osticket.com>
+    Copyright (c)  2006-2015 osTicket
+    http://www.osticket.com
+
+    Released under the GNU General Public License WITHOUT ANY WARRANTY.
+    See LICENSE.TXT for details.
+
+    vim: expandtab sw=4 ts=4 sts=4:
+**********************************************************************/
+
+class ImportError extends Exception {}
+class ImportDataError extends ImportError {}
+
+class CsvImporter {
+    var $stream;
+
+    function __construct($stream) {
+        // File upload
+        if (is_array($stream) && !$stream['error']) {
+            // Properly detect Macintosh style line endings
+            ini_set('auto_detect_line_endings', true);
+            $this->stream = fopen($stream['tmp_name'], 'r');
+        }
+        // Open file
+        elseif (is_resource($stream)) {
+            $this->stream = $stream;
+        }
+        // Text from standard-in
+        elseif (is_string($stream)) {
+            $this->stream = fopen('php://temp', 'w+');
+            fwrite($this->stream, $stream);
+            rewind($this->stream);
+        }
+        else {
+            throw new ImportError(__('Unable to parse submitted csv: ').print_r($stream, true));
+        }
+    }
+
+    function __destruct() {
+        fclose($this->stream);
+    }
+
+    function importCsv($all_fields=array(), $defaults=array()) {
+        $named_fields = array();
+        $has_header = true;
+        foreach ($all_fields as $f)
+            if ($f->get('name'))
+                $named_fields[$f->get('name')] = $f;
+
+        // Read the first row and see if it is a header or not
+        if (!($data = fgetcsv($this->stream, 1000, ",")))
+            throw new ImportError(__('Whoops. Perhaps you meant to send some CSV records'));
+
+        $headers = array();
+        foreach ($data as $h) {
+            $h = trim($h);
+            $found = false;
+            foreach ($all_fields as $f) {
+                if (in_array(mb_strtolower($h), array(
+                        mb_strtolower($f->get('name')), mb_strtolower($f->get('label'))))) {
+                    $found = true;
+                    if (!$f->get('name'))
+                        throw new ImportError(sprintf(__(
+                            '%s: Field must have `variable` set to be imported'), $h));
+                    $headers[$f->get('name')] = $f->get('label');
+                    break;
+                }
+            }
+            if (!$found) {
+                $has_header = false;
+                if (count($data) == count($named_fields)) {
+                    // Number of fields in the user form matches the number
+                    // of fields in the data. Assume things line up
+                    $headers = array();
+                    foreach ($named_fields as $f)
+                        $headers[$f->get('name')] = $f->get('label');
+                    break;
+                }
+                else {
+                    throw new ImportError(sprintf(__('%s: Unable to map header to a user field'), $h));
+                }
+            }
+        }
+
+        if (!$has_header)
+            fseek($this->stream, 0);
+
+        $objects = $fields = array();
+        foreach ($headers as $h => $label) {
+            if (!isset($named_fields[$h]))
+                continue;
+
+            $f = $named_fields[$h];
+            $name = $f->get('name');
+            $fields[$name] = $f;
+        }
+
+        // Add default fields (org_id, etc).
+        foreach ($defaults as $key => $val) {
+            // Don't apply defaults which are also being imported
+            if (isset($header[$key]))
+                unset($defaults[$key]);
+        }
+
+        // Avoid reading the entire CSV before yielding the first record.
+        // Use an iterator. This will also allow row-level errors to be
+        // continuable such that the rows with errors can be handled and the
+        // iterator can continue with the next row.
+        return new CsvImportIterator($this->stream, $headers, $fields, $defaults);
+    }
+}
+
+class CsvImportIterator
+implements Iterator {
+    var $stream;
+    var $start = 0;
+    var $headers;
+    var $fields;
+    var $defaults;
+
+    var $current = true;
+    var $row = 0;
+
+    function __construct($stream, $headers, $fields, $defaults) {
+        $this->stream = $stream;
+        $this->start = ftell($stream);
+        $this->headers = $headers;
+        $this->fields = $fields;
+        $this->defaults = $defaults;
+    }
+
+    // Iterator interface -------------------------------------
+    function rewind() {
+        @fseek($this->stream, $this->start);
+        if (ftell($this->stream) != $this->start)
+            throw new RuntimeException('Stream cannot be rewound');
+        $this->row = 0;
+        $this->next();
+    }
+    function valid() {
+        return $this->current;
+    }
+    function current() {
+        return $this->current;
+    }
+    function key() {
+        return $this->row;
+    }
+
+    function next() {
+        do {
+            if (($csv = fgetcsv($this->stream, 4096, ",")) === false) {
+                // Read error
+                $this->current = false;
+                break;
+            }
+
+            if (count($csv) == 1 && $csv[0] == null)
+                // Skip empty rows
+                continue;
+            elseif (count($csv) != count($this->headers))
+                throw new ImportDataError(sprintf(__('Bad data. Expected: %s'),
+                    implode(', ', $this->headers)));
+
+            // Validate according to field configuration
+            $i = 0;
+            $this->current = $this->defaults;
+            foreach ($this->headers as $h => $label) {
+                $f = $this->fields[$h];
+                $f->_errors = array();
+                $T = $f->parse($csv[$i]);
+                if ($f->validateEntry($T) && $f->errors())
+                    throw new ImportDataError(sprintf(__(
+                        /* 1 will be a field label, and 2 will be error messages */
+                        '%1$s: Invalid data: %2$s'),
+                        $label, implode(', ', $f->errors())));
+                // Convert to database format
+                $this->current[$h] = $f->to_database($T);
+                $i++;
+            }
+        }
+        // Use the do-loop only for the empty line skipping
+        while (false);
+        $this->row++;
+    }
+}
diff --git a/include/class.staff.php b/include/class.staff.php
index a40ea046ad140b8ee631e8085ce3e5c0333d3d3a..ed93f6441f7115cda215f6555a6544e39c989c48 100644
--- a/include/class.staff.php
+++ b/include/class.staff.php
@@ -831,6 +831,66 @@ implements AuthenticatedUser, EmailContact, TemplateVariable {
             $msg['body']);
     }
 
+    static function importCsv($stream, $defaults=array(), $callback=false) {
+        require_once INCLUDE_DIR . 'class.import.php';
+
+        $importer = new CsvImporter($stream);
+        $imported = 0;
+        $fields = array(
+            'firstname' => new TextboxField(array(
+                'label' => __('First name'),
+            )),
+            'lastname' => new TextboxField(array(
+                'label' => __('Last name'),
+            )),
+            'email' => new TextboxField(array(
+                'label' => __('Email Address'),
+                'configuration' => array(
+                    'validator' => 'email',
+                ),
+            )),
+            'username' => new TextboxField(array(
+                'label' => __('Username'),
+                'validators' => function($self, $value) {
+                    if (!Validator::is_username($value))
+                        $self->addError('Not a valid username');
+                },
+            )),
+        );
+        $form = new SimpleForm($fields);
+
+        try {
+            db_autocommit(false);
+            $records = $importer->importCsv($form->getFields(), $defaults);
+            foreach ($records as $data) {
+                if (!isset($data['email']) || !isset($data['username']))
+                    throw new ImportError('Both `username` and `email` fields are required');
+
+                if ($agent = self::lookup(array('username' => $data['username']))) {
+                    // TODO: Update the user
+                }
+                elseif ($agent = self::create($data, $errors)) {
+                    if ($callback)
+                        $callback($agent, $data);
+                    $agent->save();
+                }
+                else {
+                    throw new ImportError(sprintf(__('Unable to import (%s): %s'),
+                        $data['username'],
+                        print_r($errors, true)
+                    ));
+                }
+                $imported++;
+            }
+            db_autocommit(true);
+        }
+        catch (Exception $ex) {
+            db_rollback();
+            return $ex->getMessage();
+        }
+        return $imported;
+    }
+
     function save($refetch=false) {
         if ($this->dirty)
             $this->updated = SqlFunction::NOW();
diff --git a/include/class.user.php b/include/class.user.php
index dc599225c6ae05573c7e91f0cccbf7aeeeb6d123..16279f76eb834247940fc8f96f2e7807af497f64 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -456,138 +456,31 @@ implements TemplateVariable {
     }
 
     static function importCsv($stream, $defaults=array()) {
-        //Read the header (if any)
-        $headers = array('name' => __('Full Name'), 'email' => __('Email Address'));
-        $uform = UserForm::getUserForm();
-        $all_fields = $uform->getFields();
-        $named_fields = array();
-        $has_header = true;
-        foreach ($all_fields as $f)
-            if ($f->get('name'))
-                $named_fields[] = $f;
-
-        if (!($data = fgetcsv($stream, 1000, ",")))
-            return __('Whoops. Perhaps you meant to send some CSV records');
-
-        if (Validator::is_email($data[1])) {
-            $has_header = false; // We don't have an header!
-        }
-        else {
-            $headers = array();
-            foreach ($data as $h) {
-                $found = false;
-                foreach ($all_fields as $f) {
-                    if (in_array(mb_strtolower($h), array(
-                            mb_strtolower($f->get('name')), mb_strtolower($f->get('label'))))) {
-                        $found = true;
-                        if (!$f->get('name'))
-                            return sprintf(__(
-                                '%s: Field must have `variable` set to be imported'), $h);
-                        $headers[$f->get('name')] = $f->get('label');
-                        break;
-                    }
-                }
-                if (!$found) {
-                    $has_header = false;
-                    if (count($data) == count($named_fields)) {
-                        // Number of fields in the user form matches the number
-                        // of fields in the data. Assume things line up
-                        $headers = array();
-                        foreach ($named_fields as $f)
-                            $headers[$f->get('name')] = $f->get('label');
-                        break;
-                    }
-                    else {
-                        return sprintf(__('%s: Unable to map header to a user field'), $h);
-                    }
-                }
-            }
-        }
-
-        // 'name' and 'email' MUST be in the headers
-        if (!isset($headers['name']) || !isset($headers['email']))
-            return __('CSV file must include `name` and `email` columns');
-
-        if (!$has_header)
-            fseek($stream, 0);
-
-        $users = $fields = $keys = array();
-        foreach ($headers as $h => $label) {
-            if (!($f = $uform->getField($h)))
-                continue;
-
-            $name = $keys[] = $f->get('name');
-            $fields[$name] = $f->getImpl();
-        }
-
-        // Add default fields (org_id, etc).
-        foreach ($defaults as $key => $val) {
-            // Don't apply defaults which are also being imported
-            if (isset($header[$key]))
-                unset($defaults[$key]);
-            $keys[] = $key;
-        }
-
-        while (($data = fgetcsv($stream, 1000, ",")) !== false) {
-            if (count($data) == 1 && $data[0] == null)
-                // Skip empty rows
-                continue;
-            elseif (count($data) != count($headers))
-                return sprintf(__('Bad data. Expected: %s'), implode(', ', $headers));
-            // Validate according to field configuration
-            $i = 0;
-            foreach ($headers as $h => $label) {
-                $f = $fields[$h];
-                $T = $f->parse($data[$i]);
-                if ($f->validateEntry($T) && $f->errors())
-                    return sprintf(__(
-                        /* 1 will be a field label, and 2 will be error messages */
-                        '%1$s: Invalid data: %2$s'),
-                        $label, implode(', ', $f->errors()));
-                // Convert to database format
-                $data[$i] = $f->to_database($T);
-                $i++;
-            }
-            // Add default fields
-            foreach ($defaults as $key => $val)
-                $data[] = $val;
-
-            $users[] = $data;
-        }
-
-        db_autocommit(false);
-        $error = false;
-        foreach ($users as $u) {
-            $vars = array_combine($keys, $u);
-            if (!static::fromVars($vars, true, true)) {
-                $error = sprintf(__('Unable to import user: %s'),
-                    print_r($vars, true));
-                break;
+        require_once INCLUDE_DIR . 'class.import.php';
+
+        $importer = new CsvImporter($stream);
+        $imported = 0;
+        try {
+            db_autocommit(false);
+            $records = $importer->importCsv(UserForm::getUserForm()->getFields(), $defaults);
+            foreach ($records as $data) {
+                if (!isset($data['email']) || !isset($data['name']))
+                    throw new ImportError('Both `name` and `email` fields are required');
+                if (!($user = static::fromVars($data, true, true)))
+                    throw new ImportError(sprintf(__('Unable to import user: %s'),
+                        print_r($data, true)));
+                $imported++;
             }
+            db_autocommit(true);
         }
-        if ($error)
+        catch (Exception $ex) {
             db_rollback();
-
-        db_autocommit(true);
-
-        return $error ?: count($users);
-    }
-
-    function importFromPost($stuff, $extra=array()) {
-        if (is_array($stuff) && !$stuff['error']) {
-            // Properly detect Macintosh style line endings
-            ini_set('auto_detect_line_endings', true);
-            $stream = fopen($stuff['tmp_name'], 'r');
-        }
-        elseif ($stuff) {
-            $stream = fopen('php://temp', 'w+');
-            fwrite($stream, $stuff);
-            rewind($stream);
-        }
-        else {
-            return __('Unable to parse submitted users');
+            return $ex->getMessage();
         }
+        return $imported;
+    }
 
+    function importFromPost($stream, $extra=array()) {
         return User::importCsv($stream, $extra);
     }
 
diff --git a/include/cli/modules/agent.php b/include/cli/modules/agent.php
new file mode 100644
index 0000000000000000000000000000000000000000..7a55a351d6677a747e57e6f1b05d8f9e4d88978f
--- /dev/null
+++ b/include/cli/modules/agent.php
@@ -0,0 +1,185 @@
+<?php
+
+class AgentManager extends Module {
+    var $prologue = 'CLI agent manager';
+
+    var $arguments = array(
+        'action' => array(
+            'help' => 'Action to be performed',
+            'options' => array(
+                'import' => 'Import agents from CSV file',
+                'export' => 'Export agents from the system to CSV',
+                'list' => 'List agents based on search criteria',
+                'login' => 'Attempt login as an agent',
+                'backends' => 'List agent authentication backends',
+            ),
+        ),
+    );
+
+    var $options = array(
+        'file' => array('-f', '--file', 'metavar'=>'path',
+            'help' => 'File or stream to process'),
+        'verbose' => array('-v', '--verbose', 'default'=>false,
+            'action'=>'store_true', 'help' => 'Be more verbose'),
+
+        'welcome' => array('-w', '--welcome', 'default'=>false,
+            'action'=>'store_true', 'help'=>'Send a welcome email on import'),
+
+        'backend' => array('', '--backend',
+            'help'=>'Specify the authentication backend (used with `login` and `import`)'),
+
+        // -- Search criteria
+        'username' => array('-U', '--username',
+            'help' => 'Search by username'),
+        'email' => array('-E', '--email',
+            'help' => 'Search by email address'),
+        'id' => array('-U', '--id',
+            'help' => 'Search by user id'),
+        'dept' => array('-D', '--dept', 'help' => 'Search by access to department name or id'),
+        'team' => array('-T', '--team', 'help' => 'Search by membership in team name or id'),
+    );
+
+    var $stream;
+
+    function run($args, $options) {
+        global $ost, $cfg;
+
+        Bootstrap::connect();
+
+        if (!($ost=osTicket::start()) || !($cfg = $ost->getConfig()))
+            $this->fail('Unable to load config info!');
+
+        switch ($args['action']) {
+        case 'import':
+            // Properly detect Macintosh style line endings
+            ini_set('auto_detect_line_endings', true);
+
+            if (!$options['file'] || $options['file'] == '-')
+                $options['file'] = 'php://stdin';
+            if (!($this->stream = fopen($options['file'], 'rb')))
+                $this->fail("Unable to open input file [{$options['file']}]");
+
+            // Defaults
+            $extras = array(
+                'isadmin' => 0,
+                'isactive' => 1,
+                'isvisible' => 1,
+                'dept_id' => $cfg->getDefaultDeptId(),
+                'timezone' => $cfg->getDefaultTimezone(),
+                'welcome_email' => $options['welcome'],
+            );
+
+            if ($options['backend'])
+                $extras['backend'] = $options['backend'];
+
+            $stderr = $this->stderr;
+            $status = Staff::importCsv($this->stream, $extras,
+                function ($agent, $data) use ($stderr, $options) {
+                    if (!$options['verbose'])
+                        return;
+                    $stderr->write(
+                        sprintf("\n%s - %s  --- imported!",
+                        $agent->getName(),
+                        $agent->getUsername()));
+                }
+            );
+            if (is_numeric($status))
+                $this->stderr->write("Successfully processed $status agents\n");
+            else
+                $this->fail($status);
+            break;
+
+        case 'export':
+            $stream = $options['file'] ?: 'php://stdout';
+            if (!($this->stream = fopen($stream, 'c')))
+                $this->fail("Unable to open output file [{$options['file']}]");
+
+            fputcsv($this->stream, array('First Name', 'Last Name', 'Email', 'UserName'));
+            foreach ($this->getAgents($options) as $agent)
+                fputcsv($this->stream, array(
+                    $agent->getFirstName(),
+                    $agent->getLastName(),
+                    $agent->getEmail(),
+                    $agent->getUserName(),
+                ));
+            break;
+
+        case 'list':
+            $agents = $this->getAgents($options);
+            foreach ($agents as $A) {
+                $this->stdout->write(sprintf(
+                    "%d \t - %s\t<%s>\n",
+                    $A->staff_id, $A->getName(), $A->getEmail()));
+            }
+            break;
+
+        case 'login':
+            $this->stderr->write('Username: ');
+            $username = trim(fgets(STDIN));
+            $this->stderr->write('Password: ');
+            $password = trim(fgets(STDIN));
+
+            $agent = null;
+            foreach (StaffAuthenticationBackend::allRegistered() as $id=>$bk) {
+                if ((!$options['backend'] || $options['backend'] == $id)
+                    && $bk->supportsInteractiveAuthentication()
+                    && ($agent = $bk->authenticate($username, $password))
+                    && $agent instanceof AuthenticatedUser
+                ) {
+                    break;
+                }
+            }
+
+            if ($agent instanceof Staff) {
+                $this->stdout->write(sprintf("Successfully authenticated as '%s', using '%s'\n",
+                    (string) $agent->getName(),
+                    $bk->getName()
+                ));
+            }
+            else {
+                $this->stdout->write('Authentication failed');
+            }
+            break;
+
+        case 'backends':
+            foreach (StaffAuthenticationBackend::allRegistered() as $name=>$bk) {
+                if (!$bk->supportsInteractiveAuthentication())
+                    continue;
+                $this->stdout->write(sprintf("%s\t%s\n",
+                    $name, $bk->getName()
+                ));
+            }
+            break;
+
+        default:
+            $this->fail($args['action'].': Unknown action!');
+        }
+        @fclose($this->stream);
+    }
+
+    function getAgents($options, $requireOne=false) {
+        $agents = Staff::objects();
+
+        if ($options['email'])
+            $agents->filter(array('email__contains' => $options['email']));
+        if ($options['username'])
+            $agents->filter(array('username__contains' => $options['username']));
+        if ($options['id'])
+            $agents->filter(array('staff_id' => $options['id']));
+        if ($options['dept'])
+            $agents->filter(Q::any(array(
+                'dept_id' => $options['dept'],
+                'dept__name__contains' => $options['dept'],
+                'dept_access__dept_id' => $options['dept'],
+                'dept_access__dept__name__contains' => $options['dept'],
+            )));
+        if ($options['team'])
+            $agents->filter(Q::any(array(
+                'teams__team_id' => $options['team'],
+                'teams__team__name__contains' => $options['team'],
+            )));
+
+        return $agents->distinct('staff_id');
+    }
+}
+Module::register('agent', 'AgentManager');
diff --git a/include/cli/modules/user.php b/include/cli/modules/user.php
index 1504aacd2fcfaba3b423f82147e4a505e3c699eb..00dd6af894463a53fe61e5567c5d84ae3f7c118d 100644
--- a/include/cli/modules/user.php
+++ b/include/cli/modules/user.php
@@ -56,9 +56,9 @@ class UserManager extends Module {
             // Properly detect Macintosh style line endings
             ini_set('auto_detect_line_endings', true);
 
-            if (!$options['file'])
-                $this->fail('CSV file to import users from is required!');
-            elseif (!($this->stream = fopen($options['file'], 'rb')))
+            if (!$options['file'] || $options['file'] == '-')
+                $options['file'] = 'php://stdin';
+            if (!($this->stream = fopen($options['file'], 'rb')))
                 $this->fail("Unable to open input file [{$options['file']}]");
 
             $extras = array();