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
index 3e67c394a27106dd60b54bd647048e8089edc0bb..c128136b7439845714435a503f89ed131609e0bd 100644
--- a/include/class.import.php
+++ b/include/class.import.php
@@ -16,6 +16,7 @@
 **********************************************************************/
 
 class ImportError extends Exception {}
+class ImportDataError extends ImportError {}
 
 class CsvImporter {
     var $stream;
@@ -42,16 +43,16 @@ class CsvImporter {
         }
     }
 
-    function importCsv($forms=array(), $defaults=array()) {
-        $named_fields = $all_fields = array();
-        foreach ($forms as $F)
-            foreach ($F->getFields() as $field)
-                $all_fields[] = $field;
+    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;
+                $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, ",")))
@@ -91,15 +92,14 @@ class CsvImporter {
         if (!$has_header)
             fseek($this->stream, 0);
 
-        $objects = $fields = $keys = array();
-        foreach ($forms as $F) {
-            foreach ($headers as $h => $label) {
-                if (!($f = $F->getField($h)))
-                    continue;
+        $objects = $fields = array();
+        foreach ($headers as $h => $label) {
+            if (!isset($named_fields[$h]))
+                continue;
 
-                $name = $keys[] = $f->get('name');
-                $fields[$name] = $f->getImpl();
-            }
+            $f = $named_fields[$h];
+            $name = $f->get('name');
+            $fields[$name] = $f;
         }
 
         // Add default fields (org_id, etc).
@@ -107,35 +107,87 @@ class CsvImporter {
             // Don't apply defaults which are also being imported
             if (isset($header[$key]))
                 unset($defaults[$key]);
-            $keys[] = $key;
         }
 
-        while (($data = fgetcsv($this->stream, 4096, ",")) !== false) {
-            if (count($data) == 1 && $data[0] == null)
+        // 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($data) != count($headers))
-                throw new ImportError(sprintf(__('Bad data. Expected: %s'), implode(', ', $headers)));
+            elseif (count($csv) != count($this->headers))
+                throw new ImportDataError(sprintf(__('Bad data. Expected: %s'),
+                    implode(', ', $this->headers)));
+
             // Validate according to field configuration
             $i = 0;
-            foreach ($headers as $h => $label) {
-                $f = $fields[$h];
-                $T = $f->parse($data[$i]);
+            $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 ImportError(sprintf(__(
+                    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
-                $data[$h] = $data[$i++] = $f->to_database($T);
+                $this->current[$h] = $f->to_database($T);
+                $i++;
             }
-            // Add default fields
-            foreach ($defaults as $key => $val)
-                $data[$key] = $data[$i++] = $val;
-
-            $objects[] = $data;
         }
-
-        return $objects;
+        // 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 c1c7255f6c250a81c9fd05d881b4d04b767777dc..16279f76eb834247940fc8f96f2e7807af497f64 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -462,13 +462,13 @@ implements TemplateVariable {
         $imported = 0;
         try {
             db_autocommit(false);
-            $records = $importer->importCsv(array(UserForm::getUserForm()), $defaults);
+            $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($vars, true)));
+                        print_r($data, true)));
                 $imported++;
             }
             db_autocommit(true);
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');