diff --git a/setup/cli/modules/class.module.php b/setup/cli/modules/class.module.php
index 70c2eebebd730d4aa18f901607c6f00c96f28fed..bfe32969b9e891d87e27f99a96aacfe1c64e8bfd 100644
--- a/setup/cli/modules/class.module.php
+++ b/setup/cli/modules/class.module.php
@@ -34,7 +34,7 @@ class Option {
 
     function handleValue(&$destination, $args) {
         $nargs = 0;
-        $value = array_shift($args);
+        $value = ($this->hasArg()) ? array_shift($args) : null;
         if ($value[0] == '-')
             $value = null;
         elseif ($value)
@@ -62,7 +62,7 @@ class Option {
     function toString() {
         $short = explode(':', $this->short);
         $long = explode(':', $this->long);
-        if ($this->nargs == '?')
+        if ($this->nargs === '?')
             $switches = sprintf('    %s [%3$s], %s[=%3$s]', $short[0],
                 $long[0], $this->metavar);
         elseif ($this->hasArg())
@@ -80,6 +80,21 @@ class Option {
     }
 }
 
+class OutputStream {
+    var $stream;
+
+    function OutputStream() {
+        call_user_func_array(array($this, '__construct'), func_get_args());
+    }
+    function __construct($stream) {
+        $this->stream = fopen($stream, 'w');
+    }
+
+    function write($what) {
+        fwrite($this->stream, $what);
+    }
+}
+
 class Module {
 
     var $options = array();
@@ -89,6 +104,9 @@ class Module {
     var $usage = '$script [options] $args [arguments]';
     var $autohelp = true;
 
+    var $stdout;
+    var $stderr;
+
     var $_options;
     var $_args;
 
@@ -102,6 +120,8 @@ class Module {
             'help'=>"Display this help message");
         foreach ($this->options as &$opt)
             $opt = new Option($opt);
+        $this->stdout = new OutputStream('php://output');
+        $this->stderr = new OutputStream('php://stderr');
     }
 
     function showHelp() {
diff --git a/setup/cli/modules/deploy.php b/setup/cli/modules/deploy.php
new file mode 100644
index 0000000000000000000000000000000000000000..585f5314a02f8851f8814bd1bdcae561b48b29ca
--- /dev/null
+++ b/setup/cli/modules/deploy.php
@@ -0,0 +1,59 @@
+<?php
+require_once dirname(__file__) . "/class.module.php";
+require_once dirname(__file__) . "/unpack.php";
+
+class Deployment extends Unpacker {
+    var $prologue = "Deploys osTicket into target install path";
+
+    var $epilog =
+        "Deployment is used from the continuous development model. If you
+        are following the upstream git repo, then you can use the deploy
+        script to deploy changes made by you or upstream development to your
+        installation target";
+
+    function find_root_folder() {
+        # Hop up to the root folder of this repo
+        $start = dirname(__file__);
+        for (;;) {
+            if (is_file($start . '/main.inc.php')) break;
+            $start .= '/..';
+        }
+        return realpath($start);
+    }
+
+    function run($args, $options) {
+        $this->destination = $args['install-path'];
+        if (!is_dir($this->destination))
+            if (!@mkdir($this->destination, 0751, true))
+                die("Destination path does not exist and cannot be created");
+        $this->destination = realpath($this->destination).'/';
+
+        # Determine if this is an upgrade, and if so, where the include/
+        # folder is currently located
+        $upgrade = file_exists("{$this->destination}/main.inc.php");
+
+        # Get the current value of the INCLUDE_DIR before overwriting
+        # main.inc.php
+        $include = ($upgrade) ? $this->get_include_dir()
+            : ($options['include'] ? $options['include']
+                : "{$this->destination}/include");
+        if (substr($include, -1) !== '/')
+            $include .= '/';
+
+        # Locate the upload folder
+        $root = $this->find_root_folder();
+
+        # Unpack everything but the include/ folder
+        $this->unpackage("$root/{,.}*", $this->destination, -1,
+            array("$root/setup", "$root/include", "$root/.git*",
+                "*.sw[a-z]","*.md", "*.txt"));
+        # Unpack the include folder
+        $this->unpackage("$root/include/{,.}*", $include, -1,
+            array("*/include/ost-config.php"));
+        if (!$upgrade && $include != "{$this->destination}/include")
+            $this->change_include_dir($include);
+    }
+}
+
+Module::register('deploy', 'Deployment');
+?>
diff --git a/setup/cli/modules/unpack.php b/setup/cli/modules/unpack.php
index fca8f0ac2405686d72c624786ccefa6a1cac4ebd..3c66703ef90e0f63d23b2e10aa26e84f6948a35a 100644
--- a/setup/cli/modules/unpack.php
+++ b/setup/cli/modules/unpack.php
@@ -21,6 +21,9 @@ class Unpacker extends Module {
              code in that folder. The folder will be automatically created if
              it doesn't already exist."
         ),
+        'verbose' => array('-v','--verbose', 'default'=>false, 'nargs'=>0,
+            'action'=>'store_true', 'help'=>
+            "Move verbose logging to stdout"),
     );
 
     var $arguments = array(
@@ -45,9 +48,9 @@ class Unpacker extends Module {
         # Read the main.inc.php script
         $main_inc_php = $this->destination . '/main.inc.php';
         $lines = explode("\n", file_get_contents($main_inc_php));
-        # Try and use ROOT_PATH
+        # Try and use ROOT_DIR
         if (strpos($include_path, $this->destination) === 0)
-            $include_path = "ROOT_PATH . '" .
+            $include_path = "ROOT_DIR . '" .
                 str_replace($this->destination, '', $include_path) . "'";
         else
             $include_path = "'$include_path'";
@@ -78,23 +81,49 @@ class Unpacker extends Module {
         return false;
     }
 
-    function unpackage($folder, $destination, $recurse=true, $exclude=false) {
+    /**
+     * Copy from source to desination, perhaps recursing up to n folders.
+     * Exclusions are also permitted. If any files match an MD5 sum, they
+     * will be excluded from the copy operation.
+     *
+     * Parameters:
+     * folder - (string) source folder root
+     * destination - (string) destination folder root
+     * recurse - (int) recuse up to this many folders. Use 0 or false to
+     *      disable recursion, and -1 to recurse infinite folders.
+     * exclude - (string | array<string>) patterns that will be matched
+     *      using the PHP `fnmatch` function. If any file or folder matches,
+     *      it will be excluded from the copy procedure. Omit or use false
+     *      to disable exclusions
+     */
+    function unpackage($folder, $destination, $recurse=0, $exclude=false) {
+        $verbose = $this->getOption('verbose');
+        if (substr($destination, -1) !== '/')
+            $destination .= '/';
         foreach (glob($folder, GLOB_BRACE|GLOB_NOSORT) as $file) {
             if ($this->exclude($exclude, $file))
                 continue;
             if (is_file($file)) {
                 if (!is_dir($destination))
                     mkdir($destination, 0751, true);
-                copy($file, $destination . '/' . basename($file));
+                $target = $destination . basename($file);
+                if (is_file($target) && md5_file($target) == md5_file($file))
+                    continue;
+                if ($verbose)
+                    $this->stdout->write($target."\n");
+                copy($file, $target);
             }
         }
         if ($recurse) {
-            foreach (glob(dirname($folder).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
-                if ($this->exclude($exclude, $dir))
+            foreach (glob(dirname($folder).'/'.basename($folder),
+                    GLOB_BRACE|GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
+                if (in_array(basename($dir), array('.','..')))
+                    continue;
+                elseif ($this->exclude($exclude, $dir))
                     continue;
                 $this->unpackage(
                     dirname($folder).'/'.basename($dir).'/'.basename($folder),
-                    $destination.'/'.basename($dir),
+                    $destination.basename($dir),
                     $recurse - 1, $exclude);
             }
         }
@@ -132,7 +161,7 @@ class Unpacker extends Module {
             # Get the current value of the INCLUDE_DIR before overwriting
             # main.inc.php
             $include = $this->get_include_dir();
-        $this->unpackage("$upload/*", $this->destination, -1, "*include");
+        $this->unpackage("$upload/{,.}*", $this->destination, -1, "*include");
 
         if (!$upgrade) {
             if ($this->getOption('include')) {
@@ -140,14 +169,14 @@ class Unpacker extends Module {
                 if (!is_dir("$location/"))
                     if (!mkdir("$location/", 0751, true))
                         die("Unable to create folder for include/ files\n");
-                $this->unpackage("$upload/include/*", $location, -1);
+                $this->unpackage("$upload/include/{,.}*", $location, -1);
                 $this->change_include_dir($location);
             }
             else
-                $this->unpackage("$upload/include/*", "{$this->destination}/include", -1);
+                $this->unpackage("$upload/include/{,.}*", "{$this->destination}/include", -1);
         }
         else {
-            $this->unpackage("$upload/include/*", $include, -1);
+            $this->unpackage("$upload/include/{,.}*", $include, -1);
             # Change the new main.inc.php to reflect the location of the
             # include/ directory
             $this->change_include_dir($include);