diff --git a/composer.json b/composer.json
index fc09866766b4c50791f269d042c3afea2316408d..be2debc55591d53ee4d976b7b485e62ec80c2a05 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,6 @@
         "laminas/laminas-captcha": "2.9.0",
         "laminas/laminas-code": "3.4.1",
         "laminas/laminas-config": "3.3.0",
-        "laminas/laminas-console": "2.8.0",
         "laminas/laminas-crypt": "3.3.1",
         "laminas/laminas-db": "2.11.2",
         "laminas/laminas-dependency-plugin": "^1.0",
@@ -41,7 +40,6 @@
         "laminas/laminas-mail": "2.10.0",
         "laminas/laminas-modulemanager": "2.8.4",
         "laminas/laminas-mvc": "3.1.1",
-        "laminas/laminas-mvc-console": "1.2.0",
         "laminas/laminas-mvc-i18n": "1.1.1",
         "laminas/laminas-mvc-plugin-flashmessenger": "1.2.0",
         "laminas/laminas-paginator": "2.8.2",
@@ -65,11 +63,12 @@
         "phing/phing": "2.16.2",
         "ppito/laminas-whoops": "2.0.0",
         "serialssolutions/summon": "1.3.0",
+        "symfony/console": "4.4.4",
         "symfony/yaml": "3.4.36",
         "swagger-api/swagger-ui": "3.25.0",
         "vufind-org/vufindcode": "1.2",
         "vufind-org/vufinddate": "1.0.0",
-        "vufind-org/vufindharvest": "3.0.0",
+        "vufind-org/vufindharvest": "4.0.1",
         "vufind-org/vufindhttp": "3.0.0",
         "wikimedia/composer-merge-plugin": "1.4.1",
         "yajra/laravel-pdo-via-oci8": "2.1.1",
diff --git a/composer.lock b/composer.lock
index c19db06244119db55ddff1f7bbd1407a5330d640..9a6a68a36ad9f028c1116f25526a82d80cfc3981 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "8b23a38fc87e962f97cdf4c957610846",
+    "content-hash": "f1e891ccb58b8552a078f9b99dbbf290",
     "packages": [
         {
             "name": "ahand/mobileesp",
@@ -438,16 +438,16 @@
         },
         {
             "name": "khanamiryan/qrcode-detector-decoder",
-            "version": "1.0.2",
+            "version": "1.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/khanamiryan/php-qrcode-detector-decoder.git",
-                "reference": "a75482d3bc804e3f6702332bfda6cccbb0dfaa76"
+                "reference": "89b57f2d9939dd57394b83f6ccbd3e1a74659e34"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/khanamiryan/php-qrcode-detector-decoder/zipball/a75482d3bc804e3f6702332bfda6cccbb0dfaa76",
-                "reference": "a75482d3bc804e3f6702332bfda6cccbb0dfaa76",
+                "url": "https://api.github.com/repos/khanamiryan/php-qrcode-detector-decoder/zipball/89b57f2d9939dd57394b83f6ccbd3e1a74659e34",
+                "reference": "89b57f2d9939dd57394b83f6ccbd3e1a74659e34",
                 "shasum": ""
             },
             "require": {
@@ -484,7 +484,7 @@
                 "qr",
                 "zxing"
             ],
-            "time": "2018-04-26T11:41:33+00:00"
+            "time": "2020-04-19T16:18:51+00:00"
         },
         {
             "name": "laminas/laminas-cache",
@@ -1916,16 +1916,16 @@
         },
         {
             "name": "laminas/laminas-mime",
-            "version": "2.7.2",
+            "version": "2.7.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laminas/laminas-mime.git",
-                "reference": "2dbace2c69542e5a251af3becb6d7209ac9fb42b"
+                "reference": "e45a7d856bf7b4a7b5bd00d6371f9961dc233add"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-mime/zipball/2dbace2c69542e5a251af3becb6d7209ac9fb42b",
-                "reference": "2dbace2c69542e5a251af3becb6d7209ac9fb42b",
+                "url": "https://api.github.com/repos/laminas/laminas-mime/zipball/e45a7d856bf7b4a7b5bd00d6371f9961dc233add",
+                "reference": "e45a7d856bf7b4a7b5bd00d6371f9961dc233add",
                 "shasum": ""
             },
             "require": {
@@ -1934,12 +1934,12 @@
                 "php": "^5.6 || ^7.0"
             },
             "replace": {
-                "zendframework/zend-mime": "self.version"
+                "zendframework/zend-mime": "^2.7.2"
             },
             "require-dev": {
                 "laminas/laminas-coding-standard": "~1.0.0",
                 "laminas/laminas-mail": "^2.6",
-                "phpunit/phpunit": "^5.7.21 || ^6.3"
+                "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20"
             },
             "suggest": {
                 "laminas/laminas-mail": "Laminas\\Mail component"
@@ -1966,7 +1966,7 @@
                 "laminas",
                 "mime"
             ],
-            "time": "2019-12-31T17:25:27+00:00"
+            "time": "2020-03-29T13:12:07+00:00"
         },
         {
             "name": "laminas/laminas-modulemanager",
@@ -2107,76 +2107,6 @@
             ],
             "time": "2019-12-31T17:33:14+00:00"
         },
-        {
-            "name": "laminas/laminas-mvc-console",
-            "version": "1.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laminas/laminas-mvc-console.git",
-                "reference": "0c16223557fdb9bba853f6de22e1040824c1c966"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-mvc-console/zipball/0c16223557fdb9bba853f6de22e1040824c1c966",
-                "reference": "0c16223557fdb9bba853f6de22e1040824c1c966",
-                "shasum": ""
-            },
-            "require": {
-                "container-interop/container-interop": "^1.1",
-                "laminas/laminas-console": "^2.6",
-                "laminas/laminas-eventmanager": "^2.6.2 || ^3.0",
-                "laminas/laminas-modulemanager": "^2.7.1",
-                "laminas/laminas-mvc": "^3.0.3",
-                "laminas/laminas-router": "^3.0",
-                "laminas/laminas-servicemanager": "^2.7.5 || ^3.0.3",
-                "laminas/laminas-stdlib": "^2.7.5 || ^3.0",
-                "laminas/laminas-text": "^2.6",
-                "laminas/laminas-view": "^2.6.3",
-                "laminas/laminas-zendframework-bridge": "^1.0",
-                "php": "^5.6 || ^7.0"
-            },
-            "conflict": {
-                "laminas/laminas-mvc": "<3.0.0"
-            },
-            "replace": {
-                "zendframework/zend-mvc-console": "self.version"
-            },
-            "require-dev": {
-                "laminas/laminas-coding-standard": "~1.0.0",
-                "laminas/laminas-filter": "^2.6.1",
-                "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.4"
-            },
-            "suggest": {
-                "laminas/laminas-filter": "^2.6.1, to filter rendered results"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.2.x-dev",
-                    "dev-develop": "1.3.x-dev"
-                },
-                "laminas": {
-                    "component": "Laminas\\Mvc\\Console"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laminas\\Mvc\\Console\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "description": "Integration between laminas-mvc and laminas-console",
-            "homepage": "https://laminas.dev",
-            "keywords": [
-                "console",
-                "laminas",
-                "mvc"
-            ],
-            "time": "2019-12-31T17:33:37+00:00"
-        },
         {
             "name": "laminas/laminas-mvc-i18n",
             "version": "1.1.1",
@@ -2433,16 +2363,16 @@
         },
         {
             "name": "laminas/laminas-router",
-            "version": "3.3.1",
+            "version": "3.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laminas/laminas-router.git",
-                "reference": "c94f13f39dfbc4313efdbfcd9772487b4b009026"
+                "reference": "01a6905202ad41a42ba63d60260eba32b89e18c7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-router/zipball/c94f13f39dfbc4313efdbfcd9772487b4b009026",
-                "reference": "c94f13f39dfbc4313efdbfcd9772487b4b009026",
+                "url": "https://api.github.com/repos/laminas/laminas-router/zipball/01a6905202ad41a42ba63d60260eba32b89e18c7",
+                "reference": "01a6905202ad41a42ba63d60260eba32b89e18c7",
                 "shasum": ""
             },
             "require": {
@@ -2457,7 +2387,7 @@
                 "laminas/laminas-mvc": "<3.0.0"
             },
             "replace": {
-                "zendframework/zend-router": "self.version"
+                "zendframework/zend-router": "^3.3.0"
             },
             "require-dev": {
                 "laminas/laminas-coding-standard": "~1.0.0",
@@ -2494,7 +2424,7 @@
                 "mvc",
                 "routing"
             ],
-            "time": "2020-01-03T17:19:34+00:00"
+            "time": "2020-03-29T13:21:03+00:00"
         },
         {
             "name": "laminas/laminas-serializer",
@@ -3183,16 +3113,16 @@
         },
         {
             "name": "laminas/laminas-zendframework-bridge",
-            "version": "1.0.1",
+            "version": "1.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laminas/laminas-zendframework-bridge.git",
-                "reference": "0fb9675b84a1666ab45182b6c5b29956921e818d"
+                "reference": "bfbbdb6c998d50dbf69d2187cb78a5f1fa36e1e9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/0fb9675b84a1666ab45182b6c5b29956921e818d",
-                "reference": "0fb9675b84a1666ab45182b6c5b29956921e818d",
+                "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/bfbbdb6c998d50dbf69d2187cb78a5f1fa36e1e9",
+                "reference": "bfbbdb6c998d50dbf69d2187cb78a5f1fa36e1e9",
                 "shasum": ""
             },
             "require": {
@@ -3231,7 +3161,7 @@
                 "laminas",
                 "zf"
             ],
-            "time": "2020-01-07T22:58:31+00:00"
+            "time": "2020-04-03T16:01:00+00:00"
         },
         {
             "name": "league/commonmark",
@@ -4198,12 +4128,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/pear/Validate_ISPN.git",
-                "reference": "9ea9312a0841b5d745742c737772aeffa6d06e96"
+                "reference": "9b06777cc7b6fea4d6d5c9469f21c4b029d58ab5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/pear/Validate_ISPN/zipball/9ea9312a0841b5d745742c737772aeffa6d06e96",
-                "reference": "9ea9312a0841b5d745742c737772aeffa6d06e96",
+                "url": "https://api.github.com/repos/pear/Validate_ISPN/zipball/9b06777cc7b6fea4d6d5c9469f21c4b029d58ab5",
+                "reference": "9b06777cc7b6fea4d6d5c9469f21c4b029d58ab5",
                 "shasum": ""
             },
             "require": {
@@ -4238,7 +4168,7 @@
                 }
             ],
             "description": "More info available on: http://pear.php.net/package/Validate_ISPN",
-            "time": "2015-04-14T04:17:31+00:00"
+            "time": "2020-04-19T19:25:23+00:00"
         },
         {
             "name": "phing/phing",
@@ -4477,16 +4407,16 @@
         },
         {
             "name": "psr/log",
-            "version": "1.1.2",
+            "version": "1.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
                 "shasum": ""
             },
             "require": {
@@ -4520,7 +4450,7 @@
                 "psr",
                 "psr-3"
             ],
-            "time": "2019-11-01T11:05:21+00:00"
+            "time": "2020-03-23T09:12:05+00:00"
         },
         {
             "name": "psr/simple-cache",
@@ -4660,18 +4590,94 @@
             ],
             "time": "2020-01-17T21:39:28+00:00"
         },
+        {
+            "name": "symfony/console",
+            "version": "v4.4.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/console.git",
+                "reference": "f512001679f37e6a042b51897ed24a2f05eba656"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/console/zipball/f512001679f37e6a042b51897ed24a2f05eba656",
+                "reference": "f512001679f37e6a042b51897ed24a2f05eba656",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php73": "^1.8",
+                "symfony/service-contracts": "^1.1|^2"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<3.4",
+                "symfony/event-dispatcher": "<4.3|>=5",
+                "symfony/lock": "<4.4",
+                "symfony/process": "<3.3"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0"
+            },
+            "require-dev": {
+                "psr/log": "~1.0",
+                "symfony/config": "^3.4|^4.0|^5.0",
+                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
+                "symfony/event-dispatcher": "^4.3",
+                "symfony/lock": "^4.4|^5.0",
+                "symfony/process": "^3.4|^4.0|^5.0",
+                "symfony/var-dumper": "^4.3|^5.0"
+            },
+            "suggest": {
+                "psr/log": "For using the console logger",
+                "symfony/event-dispatcher": "",
+                "symfony/lock": "",
+                "symfony/process": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Console\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Console Component",
+            "homepage": "https://symfony.com",
+            "time": "2020-01-25T12:44:29+00:00"
+        },
         {
             "name": "symfony/inflector",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/inflector.git",
-                "reference": "f419ab2853cc00471ffd7fc18e544b5f5a90adb1"
+                "reference": "53cfa47fe9142f39b5605df67bada3893dd4f46c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/inflector/zipball/f419ab2853cc00471ffd7fc18e544b5f5a90adb1",
-                "reference": "f419ab2853cc00471ffd7fc18e544b5f5a90adb1",
+                "url": "https://api.github.com/repos/symfony/inflector/zipball/53cfa47fe9142f39b5605df67bada3893dd4f46c",
+                "reference": "53cfa47fe9142f39b5605df67bada3893dd4f46c",
                 "shasum": ""
             },
             "require": {
@@ -4716,20 +4722,34 @@
                 "symfony",
                 "words"
             ],
-            "time": "2020-01-04T13:00:46+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "symfony/options-resolver",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/options-resolver.git",
-                "reference": "9a02d6662660fe7bfadad63b5f0b0718d4c8b6b0"
+                "reference": "ade3d89dd3b875b83c8cff2980c9bb0daf6ef297"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/9a02d6662660fe7bfadad63b5f0b0718d4c8b6b0",
-                "reference": "9a02d6662660fe7bfadad63b5f0b0718d4c8b6b0",
+                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/ade3d89dd3b875b83c8cff2980c9bb0daf6ef297",
+                "reference": "ade3d89dd3b875b83c8cff2980c9bb0daf6ef297",
                 "shasum": ""
             },
             "require": {
@@ -4770,20 +4790,34 @@
                 "configuration",
                 "options"
             ],
-            "time": "2020-01-04T13:00:46+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-04-06T10:16:26+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.14.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38"
+                "reference": "4719fa9c18b0464d399f1a63bf624b42b6fa8d14"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/fbdeaec0df06cf3d51c93de80c7eb76e271f5a38",
-                "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/4719fa9c18b0464d399f1a63bf624b42b6fa8d14",
+                "reference": "4719fa9c18b0464d399f1a63bf624b42b6fa8d14",
                 "shasum": ""
             },
             "require": {
@@ -4795,7 +4829,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.14-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
@@ -4828,20 +4862,20 @@
                 "polyfill",
                 "portable"
             ],
-            "time": "2020-01-13T11:15:53+00:00"
+            "time": "2020-02-27T09:26:54+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.14.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2"
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/34094cfa9abe1f0f14f48f490772db7a775559f2",
-                "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
                 "shasum": ""
             },
             "require": {
@@ -4853,7 +4887,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.14-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
@@ -4887,20 +4921,78 @@
                 "portable",
                 "shim"
             ],
-            "time": "2020-01-13T11:15:53+00:00"
+            "time": "2020-03-09T19:04:49+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php73",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php73.git",
+                "reference": "0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7",
+                "reference": "0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php73\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "time": "2020-02-27T09:26:54+00:00"
         },
         {
             "name": "symfony/property-access",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/property-access.git",
-                "reference": "090b4bc92ded1ec512f7e2ed1691210769dffdb3"
+                "reference": "f6a51bd76a3a5c36c57221a4f491b9cf02663672"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/property-access/zipball/090b4bc92ded1ec512f7e2ed1691210769dffdb3",
-                "reference": "090b4bc92ded1ec512f7e2ed1691210769dffdb3",
+                "url": "https://api.github.com/repos/symfony/property-access/zipball/f6a51bd76a3a5c36c57221a4f491b9cf02663672",
+                "reference": "f6a51bd76a3a5c36c57221a4f491b9cf02663672",
                 "shasum": ""
             },
             "require": {
@@ -4954,7 +5046,79 @@
                 "property path",
                 "reflection"
             ],
-            "time": "2020-01-04T13:00:46+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-04-15T15:55:41+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v1.1.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
+                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3",
+                "psr/container": "^1.0"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.1-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "time": "2019-10-14T12:27:06+00:00"
         },
         {
             "name": "symfony/yaml",
@@ -5157,22 +5321,22 @@
         },
         {
             "name": "vufind-org/vufindharvest",
-            "version": "v3.0.0",
+            "version": "v4.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/vufind-org/vufindharvest.git",
-                "reference": "f0cb7188be3f6edd68f89962d7d3d771b2108775"
+                "reference": "8e12f40fd444a033178a836517973643dc04622e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/vufind-org/vufindharvest/zipball/f0cb7188be3f6edd68f89962d7d3d771b2108775",
-                "reference": "f0cb7188be3f6edd68f89962d7d3d771b2108775",
+                "url": "https://api.github.com/repos/vufind-org/vufindharvest/zipball/8e12f40fd444a033178a836517973643dc04622e",
+                "reference": "8e12f40fd444a033178a836517973643dc04622e",
                 "shasum": ""
             },
             "require": {
-                "laminas/laminas-console": ">=2.2",
                 "laminas/laminas-http": ">=2.2",
-                "php": ">=7.0.8"
+                "php": ">=7.0.8",
+                "symfony/console": "^4.0||^5.0"
             },
             "require-dev": {
                 "friendsofphp/php-cs-fixer": "2.16.1",
@@ -5180,8 +5344,8 @@
                 "phing/phing": "2.16.2",
                 "phploc/phploc": "4.0.1",
                 "phpmd/phpmd": "2.8.1",
-                "phpunit/phpunit": "6.5.14",
-                "sebastian/phpcpd": "3.0.1",
+                "phpunit/phpunit": "8.5.2",
+                "sebastian/phpcpd": "4.1.0",
                 "squizlabs/php_codesniffer": "3.5.3"
             },
             "type": "library",
@@ -5203,7 +5367,7 @@
             ],
             "description": "VuFind Harvest Tools",
             "homepage": "https://vufind.org/",
-            "time": "2020-01-27T21:06:16+00:00"
+            "time": "2020-03-23T14:30:57+00:00"
         },
         {
             "name": "vufind-org/vufindhttp",
@@ -5857,20 +6021,21 @@
         },
         {
             "name": "doctrine/annotations",
-            "version": "v1.8.0",
+            "version": "1.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/annotations.git",
-                "reference": "904dca4eb10715b92569fbcd79e201d5c349b6bc"
+                "reference": "b9d758e831c70751155c698c2f7df4665314a1cb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/annotations/zipball/904dca4eb10715b92569fbcd79e201d5c349b6bc",
-                "reference": "904dca4eb10715b92569fbcd79e201d5c349b6bc",
+                "url": "https://api.github.com/repos/doctrine/annotations/zipball/b9d758e831c70751155c698c2f7df4665314a1cb",
+                "reference": "b9d758e831c70751155c698c2f7df4665314a1cb",
                 "shasum": ""
             },
             "require": {
                 "doctrine/lexer": "1.*",
+                "ext-tokenizer": "*",
                 "php": "^7.1"
             },
             "require-dev": {
@@ -5880,7 +6045,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7.x-dev"
+                    "dev-master": "1.9.x-dev"
                 }
             },
             "autoload": {
@@ -5921,7 +6086,7 @@
                 "docblock",
                 "parser"
             ],
-            "time": "2019-10-01T18:55:10+00:00"
+            "time": "2020-04-20T09:18:32+00:00"
         },
         {
             "name": "doctrine/instantiator",
@@ -6439,24 +6604,21 @@
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "2.0.0",
+            "version": "2.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
-            "require-dev": {
-                "phpunit/phpunit": "~6"
-            },
             "type": "library",
             "extra": {
                 "branch-alias": {
@@ -6487,7 +6649,7 @@
                 "reflection",
                 "static analysis"
             ],
-            "time": "2018-08-07T13:53:10+00:00"
+            "time": "2020-04-27T09:25:28+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
@@ -6709,16 +6871,16 @@
         },
         {
             "name": "phpspec/prophecy",
-            "version": "v1.10.2",
+            "version": "v1.10.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "b4400efc9d206e83138e2bb97ed7f5b14b831cd9"
+                "reference": "451c3cd1418cf640de218914901e51b064abb093"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/b4400efc9d206e83138e2bb97ed7f5b14b831cd9",
-                "reference": "b4400efc9d206e83138e2bb97ed7f5b14b831cd9",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
+                "reference": "451c3cd1418cf640de218914901e51b064abb093",
                 "shasum": ""
             },
             "require": {
@@ -6768,7 +6930,7 @@
                 "spy",
                 "stub"
             ],
-            "time": "2020-01-20T15:57:02+00:00"
+            "time": "2020-03-05T15:02:03+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
@@ -7867,16 +8029,16 @@
         },
         {
             "name": "symfony/config",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "cbfef5ae91ccd3b06621c18d58cd355c68c87ae9"
+                "reference": "8ba41fe053683e1e6e3f6fa21f07ea5c4dd9e4c0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/cbfef5ae91ccd3b06621c18d58cd355c68c87ae9",
-                "reference": "cbfef5ae91ccd3b06621c18d58cd355c68c87ae9",
+                "url": "https://api.github.com/repos/symfony/config/zipball/8ba41fe053683e1e6e3f6fa21f07ea5c4dd9e4c0",
+                "reference": "8ba41fe053683e1e6e3f6fa21f07ea5c4dd9e4c0",
                 "shasum": ""
             },
             "require": {
@@ -7927,96 +8089,34 @@
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-04T09:32:40+00:00"
-        },
-        {
-            "name": "symfony/console",
-            "version": "v4.4.5",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/console.git",
-                "reference": "4fa15ae7be74e53f6ec8c83ed403b97e23b665e9"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/4fa15ae7be74e53f6ec8c83ed403b97e23b665e9",
-                "reference": "4fa15ae7be74e53f6ec8c83ed403b97e23b665e9",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1.3",
-                "symfony/polyfill-mbstring": "~1.0",
-                "symfony/polyfill-php73": "^1.8",
-                "symfony/service-contracts": "^1.1|^2"
-            },
-            "conflict": {
-                "symfony/dependency-injection": "<3.4",
-                "symfony/event-dispatcher": "<4.3|>=5",
-                "symfony/lock": "<4.4",
-                "symfony/process": "<3.3"
-            },
-            "provide": {
-                "psr/log-implementation": "1.0"
-            },
-            "require-dev": {
-                "psr/log": "~1.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/event-dispatcher": "^4.3",
-                "symfony/lock": "^4.4|^5.0",
-                "symfony/process": "^3.4|^4.0|^5.0",
-                "symfony/var-dumper": "^4.3|^5.0"
-            },
-            "suggest": {
-                "psr/log": "For using the console logger",
-                "symfony/event-dispatcher": "",
-                "symfony/lock": "",
-                "symfony/process": ""
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Console\\": ""
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
                 },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
                 {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
                 },
                 {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
                 }
             ],
-            "description": "Symfony Console Component",
-            "homepage": "https://symfony.com",
-            "time": "2020-02-24T13:10:00+00:00"
+            "time": "2020-04-15T15:56:18+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v3.4.38",
+            "version": "v3.4.40",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "ee9b946e7223b11257329a054c64396b19d619e1"
+                "reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/ee9b946e7223b11257329a054c64396b19d619e1",
-                "reference": "ee9b946e7223b11257329a054c64396b19d619e1",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/9ccf6e78077a3fc1596e6c7b5958008965a11518",
+                "reference": "9ccf6e78077a3fc1596e6c7b5958008965a11518",
                 "shasum": ""
             },
             "require": {
@@ -8056,20 +8156,34 @@
             ],
             "description": "Symfony CssSelector Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-04T08:04:52+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-16T08:31:04+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "ebb2e882e8c9e2eb990aa61ddcd389848466e342"
+                "reference": "9d0c2807962f7f12264ab459f48fb541dbd386bd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ebb2e882e8c9e2eb990aa61ddcd389848466e342",
-                "reference": "ebb2e882e8c9e2eb990aa61ddcd389848466e342",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/9d0c2807962f7f12264ab459f48fb541dbd386bd",
+                "reference": "9d0c2807962f7f12264ab459f48fb541dbd386bd",
                 "shasum": ""
             },
             "require": {
@@ -8129,20 +8243,34 @@
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-29T09:50:10+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-04-16T16:36:56+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "4ad8e149799d3128621a3a1f70e92b9897a8930d"
+                "reference": "abc8e3618bfdb55e44c8c6a00abd333f831bbfed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/4ad8e149799d3128621a3a1f70e92b9897a8930d",
-                "reference": "4ad8e149799d3128621a3a1f70e92b9897a8930d",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abc8e3618bfdb55e44c8c6a00abd333f831bbfed",
+                "reference": "abc8e3618bfdb55e44c8c6a00abd333f831bbfed",
                 "shasum": ""
             },
             "require": {
@@ -8199,7 +8327,21 @@
             ],
             "description": "Symfony EventDispatcher Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-04T09:32:40+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
@@ -8261,16 +8403,16 @@
         },
         {
             "name": "symfony/filesystem",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "266c9540b475f26122b61ef8b23dd9198f5d1cfd"
+                "reference": "a3ebf3bfd8a98a147c010a568add5a8aa4edea0f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/266c9540b475f26122b61ef8b23dd9198f5d1cfd",
-                "reference": "266c9540b475f26122b61ef8b23dd9198f5d1cfd",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/a3ebf3bfd8a98a147c010a568add5a8aa4edea0f",
+                "reference": "a3ebf3bfd8a98a147c010a568add5a8aa4edea0f",
                 "shasum": ""
             },
             "require": {
@@ -8307,20 +8449,34 @@
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2020-01-21T08:20:44+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-04-12T14:39:55+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357"
+                "reference": "5729f943f9854c5781984ed4907bbb817735776b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/ea69c129aed9fdeca781d4b77eb20b62cf5d5357",
-                "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/5729f943f9854c5781984ed4907bbb817735776b",
+                "reference": "5729f943f9854c5781984ed4907bbb817735776b",
                 "shasum": ""
             },
             "require": {
@@ -8356,20 +8512,34 @@
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-14T07:42:58+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "symfony/polyfill-php70",
-            "version": "v1.14.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php70.git",
-                "reference": "419c4940024c30ccc033650373a1fe13890d3255"
+                "reference": "2a18e37a489803559284416df58c71ccebe50bf0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/419c4940024c30ccc033650373a1fe13890d3255",
-                "reference": "419c4940024c30ccc033650373a1fe13890d3255",
+                "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/2a18e37a489803559284416df58c71ccebe50bf0",
+                "reference": "2a18e37a489803559284416df58c71ccebe50bf0",
                 "shasum": ""
             },
             "require": {
@@ -8379,7 +8549,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.14-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
@@ -8415,20 +8585,20 @@
                 "portable",
                 "shim"
             ],
-            "time": "2020-01-13T11:15:53+00:00"
+            "time": "2020-02-27T09:26:54+00:00"
         },
         {
             "name": "symfony/polyfill-php72",
-            "version": "v1.14.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php72.git",
-                "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf"
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf",
-                "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/37b0976c78b94856543260ce09b460a7bc852747",
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747",
                 "shasum": ""
             },
             "require": {
@@ -8437,7 +8607,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.14-dev"
+                    "dev-master": "1.15-dev"
                 }
             },
             "autoload": {
@@ -8470,78 +8640,20 @@
                 "portable",
                 "shim"
             ],
-            "time": "2020-01-13T11:15:53+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php73",
-            "version": "v1.14.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/5e66a0fa1070bf46bec4bea7962d285108edd675",
-                "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.14-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php73\\": ""
-                },
-                "files": [
-                    "bootstrap.php"
-                ],
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "time": "2020-01-13T11:15:53+00:00"
+            "time": "2020-02-27T09:26:54+00:00"
         },
         {
             "name": "symfony/process",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "bf9166bac906c9e69fb7a11d94875e7ced97bcd7"
+                "reference": "4b6a9a4013baa65d409153cbb5a895bf093dc7f4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/bf9166bac906c9e69fb7a11d94875e7ced97bcd7",
-                "reference": "bf9166bac906c9e69fb7a11d94875e7ced97bcd7",
+                "url": "https://api.github.com/repos/symfony/process/zipball/4b6a9a4013baa65d409153cbb5a895bf093dc7f4",
+                "reference": "4b6a9a4013baa65d409153cbb5a895bf093dc7f4",
                 "shasum": ""
             },
             "require": {
@@ -8577,78 +8689,34 @@
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2020-02-07T20:06:44+00:00"
-        },
-        {
-            "name": "symfony/service-contracts",
-            "version": "v1.1.8",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1.3",
-                "psr/container": "^1.0"
-            },
-            "suggest": {
-                "symfony/service-implementation": ""
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.1-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Contracts\\Service\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
+            "funding": [
                 {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
                 },
                 {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
                 }
             ],
-            "description": "Generic abstractions related to writing services",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
-            "time": "2019-10-14T12:27:06+00:00"
+            "time": "2020-04-15T15:56:18+00:00"
         },
         {
             "name": "symfony/stopwatch",
-            "version": "v4.4.5",
+            "version": "v4.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/stopwatch.git",
-                "reference": "abc08d7c48987829bac301347faa10f7e8bbf4fb"
+                "reference": "e0324d3560e4128270e3f08617480d9233d81cfc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/abc08d7c48987829bac301347faa10f7e8bbf4fb",
-                "reference": "abc08d7c48987829bac301347faa10f7e8bbf4fb",
+                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/e0324d3560e4128270e3f08617480d9233d81cfc",
+                "reference": "e0324d3560e4128270e3f08617480d9233d81cfc",
                 "shasum": ""
             },
             "require": {
@@ -8685,7 +8753,21 @@
             ],
             "description": "Symfony Stopwatch Component",
             "homepage": "https://symfony.com",
-            "time": "2020-01-04T13:00:46+00:00"
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "textalk/websocket",
@@ -8808,16 +8890,16 @@
         },
         {
             "name": "webmozart/assert",
-            "version": "1.7.0",
+            "version": "1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/webmozart/assert.git",
-                "reference": "aed98a490f9a8f78468232db345ab9cf606cf598"
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/aed98a490f9a8f78468232db345ab9cf606cf598",
-                "reference": "aed98a490f9a8f78468232db345ab9cf606cf598",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
                 "shasum": ""
             },
             "require": {
@@ -8825,7 +8907,7 @@
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
-                "vimeo/psalm": "<3.6.0"
+                "vimeo/psalm": "<3.9.1"
             },
             "require-dev": {
                 "phpunit/phpunit": "^4.8.36 || ^7.5.13"
@@ -8852,7 +8934,7 @@
                 "check",
                 "validate"
             ],
-            "time": "2020-02-14T12:15:55+00:00"
+            "time": "2020-04-18T12:12:48+00:00"
         }
     ],
     "aliases": [],
@@ -8869,5 +8951,6 @@
     "platform-dev": [],
     "platform-overrides": {
         "php": "7.2"
-    }
+    },
+    "plugin-api-version": "1.1.0"
 }
diff --git a/config/application.config.php b/config/application.config.php
index 04d3e88b76d508db42d48e382535fcbd944432e9..f9fa9b93b78008e1385d0e63e69f1ae834da5156 100644
--- a/config/application.config.php
+++ b/config/application.config.php
@@ -8,7 +8,6 @@ $modules = [
     'VuFindTheme', 'VuFindSearch', 'VuFind', 'VuFindAdmin', 'VuFindApi'
 ];
 if (PHP_SAPI == 'cli' && APPLICATION_ENV !== 'testing') {
-    $modules[] = 'Laminas\Mvc\Console';
     $modules[] = 'VuFindConsole';
 }
 if (APPLICATION_ENV == 'development') {
diff --git a/install.php b/install.php
index d4c05aa88c022d49eb488319b5b3b7421ab53a29..a8c2e2a7e60acb2ef8289ed3207469d1c08b98b5 100644
--- a/install.php
+++ b/install.php
@@ -26,662 +26,15 @@
  * @link     https://vufind.org/wiki/installation Wiki
  */
 
-require_once __DIR__ . '/vendor/autoload.php';
-
-use Laminas\Console\Getopt;
-
-define('MULTISITE_NONE', 0);
-define('MULTISITE_DIR_BASED', 1);
-define('MULTISITE_HOST_BASED', 2);
-
-$baseDir = str_replace('\\', '/', dirname(__FILE__));
-$overrideDir = $baseDir . '/local';
-$host = $module = '';
-$multisiteMode = MULTISITE_NONE;
-$basePath = '/vufind';
-
-try {
-    $opts = new Getopt(
-        array(
-        'use-defaults' =>
-           'Use VuFind Defaults to Configure (ignores any other arguments passed)',
-        'overridedir=s' =>
-           "Where would you like to store your local settings? [{$baseDir}/local]",
-        'module-name=s' =>
-           'What module name would you like to use? Use disabled, to not use',
-        'basepath=s' =>
-           "What base path should be used in VuFind's URL? [{$basePath}]",
-        'multisite-w' =>
-           'Specify we are going to setup a multisite. Options: directory and host',
-        'hostname=s' =>
-            'Specify the hostname for the VuFind Site, When multisite=host',
-        'non-interactive' =>
-            'Use settings if provided via arguments, otherwise use defaults',
-      )
-    );
-    $opts->parse();
-} catch (Exception $e) {
-    echo is_callable([$e, 'getUsageMessage'])
-        ? $e->getUsageMessage() : $e->getMessage() . "\n";
-    exit;
-}
-
-echo "VuFind has been found in {$baseDir}.\n\n";
-
-// Are we allowing user interaction?
-$interactive = !$opts->getOption('non-interactive');
-$userInputNeeded = array();
-
-// Load user settings if we are not forcing defaults:
-if (!$opts->getOption('use-defaults')) {
-    if ($opts->getOption('overridedir')) {
-        $overrideDir = $opts->getOption('overridedir');
-    } else if ($interactive) {
-        $userInputNeeded['overrideDir'] = true;
-    }
-    if ($opts->getOption('module-name')) {
-        if ($opts->getOption('module-name') !== 'disabled') {
-            $module = $opts->getOption('module-name');
-            if (($result = validateModules($module)) !== true) {
-                die($result . "\n");
-            }
-        }
-    } else if ($interactive) {
-        $userInputNeeded['module'] = true;
-    }
-
-    if ($opts->getOption('basepath')) {
-        $basePath = $opts->getOption('basepath');
-        if (($result = validateBasePath($basePath, true)) !== true) {
-            die($result . "\n");
-        }
-    } else if ($interactive) {
-        $userInputNeeded['basePath'] = true;
-    }
-
-    // We assume "single site" mode unless the --multisite switch is set:
-    if ($opts->getOption('multisite')) {
-        if ($opts->getOption('multisite') === 'directory') {
-            $multisiteMode = MULTISITE_DIR_BASED;
-        } else if ($opts->getOption('multisite') === 'host') {
-            $multisiteMode = MULTISITE_HOST_BASED;
-        } else if (($bad = $opts->getOption('multisite')) && $bad !== true) {
-            die('Unexpected multisite mode: ' . $bad . "\n");
-        } else if ($interactive) {
-            $userInputNeeded['multisiteMode'] = true;
-        }
-    }
-
-    // Now that we've validated as many parameters as possible, retrieve
-    // user input where needed.
-    if (isset($userInputNeeded['overrideDir'])) {
-        $overrideDir = getOverrideDir($overrideDir);
-    }
-    if (isset($userInputNeeded['module'])) {
-        $module = getModule();
-    }
-    if (isset($userInputNeeded['basePath'])) {
-        $basePath = getBasePath($basePath);
-    }
-    if (isset($userInputNeeded['multisiteMode'])) {
-        $multisiteMode = getMultisiteMode();
-    }
-
-    // Load supplemental multisite parameters:
-    if ($multisiteMode == MULTISITE_HOST_BASED) {
-        if ($opts->getOption('hostname')) {
-             $host = $opts->getOption('hostname');
-        } else if ($interactive) {
-             $host = getHost();
-        }
-    }
-}
-
-// Make sure the override directory is initialized (using defaults or CLI
-// parameters will not have initialized it yet; attempt to reinitialize it
-// here is harmless if it was already initialized in interactive mode):
-initializeOverrideDir($overrideDir, true);
-
-// Normalize the module setting to remove whitespace:
-$module = preg_replace('/\s/', '', $module);
-
-// Build the Windows start file in case we need it:
-buildWindowsConfig($baseDir, $overrideDir, $module);
-
-// Build the import configuration:
-buildImportConfig($baseDir, $overrideDir, 'import.properties');
-buildImportConfig($baseDir, $overrideDir, 'import_auth.properties');
-
-// Build the custom module, if necessary:
-if (!empty($module)) {
-    buildModules($baseDir, $module);
-}
-
-// Build the final configuration:
-buildApacheConfig($baseDir, $overrideDir, $basePath, $module, $multisiteMode, $host);
-
-// Report success:
-echo "Apache configuration written to {$overrideDir}/httpd-vufind.conf.\n\n";
-echo "You now need to load this configuration into Apache.\n";
-getApacheLocation($overrideDir);
-if (!empty($host)) {
-    echo "Since you are using a host-based multisite configuration, you will also" .
-        "\nneed to do some virtual host configuration. See\n" .
-        "     http://httpd.apache.org/docs/2.2/vhosts/\n\n";
-}
-if ('/' == $basePath) {
-    echo "Since you are installing VuFind at the root of your domain, you will also"
-        . "\nneed to edit your Apache configuration to change DocumentRoot to:\n"
-        . $baseDir . "/public\n\n";
-}
-echo "Once the configuration is linked, restart Apache.  You should now be able\n";
-echo "to access VuFind at http://localhost{$basePath}\n\n";
-echo "For proper use of command line tools, you should also ensure that your\n";
-if (empty($module)) {
-    echo "VUFIND_HOME and VUFIND_LOCAL_DIR environment variables are set to\n";
-    echo "{$baseDir} and {$overrideDir} respectively.\n\n";
-} else {
-    echo "VUFIND_HOME, VUFIND_LOCAL_MODULES and VUFIND_LOCAL_DIR environment\n";
-    echo "variables are set to {$baseDir}, {$module} and {$overrideDir} ";
-    echo "respectively.\n\n";
-}
-
-/**
- * Display system-specific information for where configuration files are found and/or
- * symbolic links should be created.
- *
- * @param string $overrideDir Path to VuFind's local override directory
- *
- * @return void
- */
-function getApacheLocation($overrideDir)
-{
-    // There is one special case for Windows, and a variety of different
-    // Unix-flavored possibilities that all work similarly.
-    if (strtoupper(substr(php_uname('s'), 0, 3)) === 'WIN') {   // Windows
-        echo "Go to Start -> Apache HTTP Server -> Edit the Apache httpd.conf\n";
-        echo "and add this line to your httpd.conf file: \n";
-        echo "     Include {$overrideDir}/httpd-vufind.conf\n\n";
-        echo "If you are using a bundle like XAMPP and do not have this start\n";
-        echo "menu option, you should find and edit your httpd.conf file manually\n";
-        echo "(usually in a location like c:\\xampp\\apache\\conf).\n\n";
-    } else {
-        if (is_dir('/etc/httpd/conf.d')) {                      // Mandriva / RedHat
-            $confD = '/etc/httpd/conf.d';
-            $httpdConf = '/etc/httpd/conf/httpd.conf';
-        } else if (is_dir('/etc/apache2/2.2/conf.d')) {         // Solaris
-            $confD = '/etc/apache2/2.2/conf.d';
-            $httpdConf = '/etc/apache2/2.2/httpd.conf';
-        } else if (is_dir('/etc/apache2/conf-enabled')) {   // new Ubuntu / OpenSUSE
-            $confD = '/etc/apache2/conf-enabled';
-            $httpdConf = '/etc/apache2/apache2.conf';
-        } else if (is_dir('/etc/apache2/conf.d')) {         // old Ubuntu / OpenSUSE
-            $confD = '/etc/apache2/conf.d';
-            $httpdConf = '/etc/apache2/httpd.conf';
-        } else if (is_dir('/opt/local/apache2/conf/extra')) {   // Mac with Mac Ports
-            $confD = '/opt/local/apache2/conf/extra';
-            $httpdConf = '/opt/local/apache2/conf/httpd.conf';
-        } else {
-            $confD = '/path/to/apache/conf.d';
-            $httpdConf = false;
-        }
-
-        // Check if httpd.conf really exists before recommending a specific path;
-        // if missing, just use the generic name:
-        $httpdConf = ($httpdConf && file_exists($httpdConf))
-            ? $httpdConf : 'httpd.conf';
-
-        // Suggest a symlink name based on the local directory, so if running in
-        // multisite mode, we don't use the same symlink for multiple instances:
-        $symlink = basename($overrideDir);
-        $symlink = ($symlink == 'local') ? 'vufind' : ('vufind-' . $symlink);
-        $symlink .= '.conf';
-
-        echo "You can do it in either of two ways:\n\n";
-        echo "    a) Add this line to your {$httpdConf} file:\n";
-        echo "       Include {$overrideDir}/httpd-vufind.conf\n\n";
-        echo "    b) Link the configuration to Apache's config directory like this:";
-        echo "\n       ln -s {$overrideDir}/httpd-vufind.conf {$confD}/{$symlink}\n";
-        echo "\nOption b is preferable if your platform supports it,\n";
-        echo "but option a is more certain to be supported.\n\n";
-    }
-}
-
-/**
- * Validate a base path. Returns true on success, message on failure.
- *
- * @param string $basePath   String to validate.
- * @param bool   $allowEmpty Are empty values acceptable?
- *
- * @return bool|string
- */
-function validateBasePath($basePath, $allowEmpty = false)
-{
-    if ($allowEmpty && empty($basePath)) {
-        return true;
-    }
-    return preg_match('/^\/\w*$/', $basePath)
-        ? true
-        : 'Error: Base path must be alphanumeric and start with a slash.';
-}
-
-/**
- * Get a base path from the user (or return a default).
- *
- * @param string $basePath Default value
- *
- * @return string
- */
-function getBasePath($basePath)
-{
-    // Get VuFind base path:
-    while (true) {
-        $basePathInput = getInput(
-            "What base path should be used in VuFind's URL? [{$basePath}] "
-        );
-        if (!empty($basePathInput)) {
-            if (($result = validateBasePath($basePathInput)) !== true) {
-                echo "$result\n\n";
-            } else {
-                return $basePathInput;
-            }
-        } else {
-            return $basePath;
-        }
-    }
-}
-
-/**
- * Initialize the override directory and report success or failure.
- *
- * @param string $dir        Path to attempt to initialize
- * @param bool   $dieOnError Should we die outright if we fail?
- *
- * @return void
- */
-function initializeOverrideDir($dir, $dieOnError = false)
-{
-    $dirStatus = buildDirs(
-        array(
-            $dir,
-            $dir . '/cache',
-            $dir . '/config',
-            $dir . '/harvest',
-            $dir . '/import'
-        )
-    );
-    if ($dieOnError && ($dirStatus !== true)) {
-        die("Cannot initialize local override directory: {$dir}\n");
-    }
-    return $dirStatus === true;
-}
-
-/**
- * Get an override directory from the user (or return a default).
- *
- * @param string $overrideDir Default value
- *
- * @return string
- */
-function getOverrideDir($overrideDir)
-{
-    // Get override directory path:
-    while (true) {
-        $overrideDirInput = getInput(
-            "Where would you like to store your local settings? [{$overrideDir}] "
-        );
-        if (!empty($overrideDirInput)) {
-            if (!initializeOverrideDir($overrideDirInput)) {
-                echo "Error: Cannot initialize settings in '$overrideDirInput'.\n\n";
-            } else {
-                return str_replace('\\', '/', realpath($overrideDirInput));
-            }
-        } else {
-            return $overrideDir;
-        }
-    }
-}
-
-/**
- * Validate a comma-separated list of module names. Returns true on success, message
- * on failure.
- *
- * @param string $modules Module name to validate.
- *
- * @return bool|string
- */
-function validateModules($modules)
-{
-    foreach (explode(',', $modules) as $module) {
-        $result = validateModule(trim($module));
-        if ($result !== true) {
-            return $result;
-        }
-    }
-    return true;
-}
-
-/**
- * Validate the custom module name. Returns true on success, message on failure.
- *
- * @param string $module Module name to validate.
- *
- * @return bool|string
- */
-function validateModule($module)
-{
-    $regex = '/^[a-zA-Z][0-9a-zA-Z_]*$/';
-    $illegalModules = array(
-        'VuFind', 'VuFindAdmin', 'VuFindConsole', 'VuFindDevTools',
-        'VuFindLocalTemplate', 'VuFindSearch', 'VuFindTest', 'VuFindTheme',
-    );
-    if (in_array($module, $illegalModules)) {
-        return "{$module} is a reserved module name; please try another.";
-    } else if (empty($module) || preg_match($regex, $module)) {
-        return true;
-    } else {
-        return "Illegal name: {$module}; please use alphanumeric text.";
-    }
-}
-
-/**
- * Get the custom module name from the user (or blank for none).
- *
- * @return string
- */
-function getModule()
-{
-    // Get custom module name:
-    echo "\nVuFind supports use of a custom module for storing local code ";
-    echo "changes.\nIf you do not plan to customize the code, you can ";
-    echo "skip this step.\nIf you decide to use a custom module, the name ";
-    echo "you choose will be used for\nthe module's directory name and its ";
-    echo "PHP namespace.\n";
-    while (true) {
-        $moduleInput = trim(
-            getInput(
-                "\nWhat module name would you like to use? [blank for none] "
-            )
-        );
-        if (($result = validateModules($moduleInput)) === true) {
-            return $moduleInput;
-        }
-        echo "\n$result\n";
-    }
-}
-
-/**
- * Get the user's preferred multisite mode.
- *
- * @return int
- */
-function getMultisiteMode()
-{
-    echo "\nWhen running multiple VuFind sites against a single installation, you"
-        . "need\nto decide how to distinguish between instances.  Choose an option:"
-        . "\n\n" . MULTISITE_DIR_BASED
-        . ".) Directory-based (i.e. http://server/vufind1 vs. http://server/vufind2)"
-        . "\n" . MULTISITE_HOST_BASED
-        . ".) Host-based (i.e. http://vufind1.server vs. http://vufind2.server)"
-        . "\n\nor enter " . MULTISITE_NONE . " to disable multisite mode.\n";
-    $legal = array(MULTISITE_NONE, MULTISITE_DIR_BASED, MULTISITE_HOST_BASED);
-    while (true) {
-        $input = getInput("\nWhich option do you want? ");
-        if (!is_numeric($input) || !in_array(intval($input), $legal)) {
-            echo "Invalid selection.";
-        } else {
-            return intval($input);
-        }
-    }
-}
-
-/**
- * Validate the user's hostname input. Returns true on success, message on failure.
- *
- * @param string $host String to check
- *
- * @return bool|string
- */
-function validateHost($host)
-{
-    // From http://stackoverflow.com/questions/106179/
-    //             regular-expression-to-match-hostname-or-ip-address
-    $valid = "/^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*"
-        . "([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/";
-    return preg_match($valid, $host)
-        ? true
-        : 'Invalid hostname.';
-}
-
-/**
- * Get the user's hostname preference.
- *
- * @return string
- */
-function getHost()
-{
-    while (true) {
-        $input = getInput("\nPlease enter the hostname for your site: ");
-        if (($result = validateHost($input)) === true) {
-            return $input;
-        } else {
-            echo "$result\n";
-        }
-    }
-}
-
-/**
- * readline() does not exist on Windows.  This is a simple wrapper for portability.
- *
- * @param string $prompt Prompt to display to the user.
- *
- * @return string        User-entered response.
- */
-function getInput($prompt)
-{
-    return \Laminas\Console\Prompt\Line::prompt($prompt, true);
-}
-
-/**
- * Generate the Apache configuration.
- *
- * @param string $baseDir     The VuFind base directory
- * @param string $overrideDir The VuFind override directory
- * @param string $basePath    The VuFind URL base path
- * @param string $module      The VuFind custom module name (or empty for none)
- * @param int    $multi       Multisite mode preference
- * @param string $host        Virtual host name (or empty for none)
- *
- * @return void
- */
-function buildApacheConfig($baseDir, $overrideDir, $basePath, $module, $multi, $host)
-{
-    $baseConfig = $baseDir . '/config/vufind/httpd-vufind.conf';
-    $config = @file_get_contents($baseConfig);
-    if (empty($config)) {
-        die("Problem reading {$baseConfig}.\n\n");
-    }
-    $config = str_replace('/usr/local/vufind/local', '%override-dir%', $config);
-    $config = str_replace('/usr/local/vufind', '%base-dir%', $config);
-    $config = preg_replace('|([^/])\/vufind|', '$1%base-path%', $config);
-    $config = str_replace('%override-dir%', $overrideDir, $config);
-    $config = str_replace('%base-dir%', $baseDir, $config);
-    $config = str_replace('%base-path%', $basePath, $config);
-    // Special cases for root basePath:
-    if ('/' == $basePath) {
-        $config = str_replace('//', '/', $config);
-        $config = str_replace('Alias /', '#Alias /', $config);
-    }
-    if (!empty($module)) {
-        $config = str_replace(
-            "#SetEnv VUFIND_LOCAL_MODULES VuFindLocalTemplate",
-            "SetEnv VUFIND_LOCAL_MODULES {$module}", $config
-        );
-    }
-
-    // In multisite mode, we need to make environment variables conditional:
-    switch ($multi) {
-    case MULTISITE_DIR_BASED:
-        $config = preg_replace(
-            '/SetEnv\s+(\w+)\s+(.*)/',
-            'SetEnvIf Request_URI "^' . $basePath . '" $1=$2',
-            $config
-        );
-        break;
-    case MULTISITE_HOST_BASED:
-        if (($result = validateHost($host)) !== true) {
-            die($result . "\n");
-        }
-        $config = preg_replace(
-            '/SetEnv\s+(\w+)\s+(.*)/',
-            'SetEnvIfNoCase Host ' . str_replace('.', '\.', $host) . ' $1=$2',
-            $config
-        );
-        break;
-    }
-
-    $target = $overrideDir . '/httpd-vufind.conf';
-    if (file_exists($target)) {
-        $bak = $target . '.bak.' . time();
-        copy($target, $bak);
-        echo "Backed up existing Apache configuration to $bak.\n";
-    }
-    if (!@file_put_contents($target, $config)) {
-        die("Problem writing {$overrideDir}/httpd-vufind.conf.\n\n");
-    }
-}
-
-/**
- * Build the Windows-specific startup configuration.
- *
- * @param string $baseDir     The VuFind base directory
- * @param string $overrideDir The VuFind override directory
- * @param string $module      The VuFind custom module name (or empty for none)
- *
- * @return void
- */
-function buildWindowsConfig($baseDir, $overrideDir, $module)
-{
-    $batch = "@set VUFIND_HOME={$baseDir}\n" .
-        "@set VUFIND_LOCAL_DIR={$overrideDir}\n" .
-        (empty($module) ? '' : "@set VUFIND_LOCAL_MODULES={$module}\n");
-    if (!@file_put_contents($baseDir . '/env.bat', $batch)) {
-        die("Problem writing {$baseDir}/env.bat.\n\n");
-    }
-}
-
-/**
- * Configure a SolrMarc properties file.
- *
- * @param string $baseDir     The VuFind base directory
- * @param string $overrideDir The VuFind override directory
- * @param string $filename    The properties file to configure
- *
- * @return void
- */
-function buildImportConfig($baseDir, $overrideDir, $filename)
-{
-    $target = $overrideDir . '/import/' . $filename;
-    if (file_exists($target)) {
-        echo "Warning: $target already exists; skipping file creation.\n";
-    } else {
-        $import = @file_get_contents($baseDir . '/import/' . $filename);
-        $import = str_replace("/usr/local/vufind", $baseDir, $import);
-        $import = preg_replace(
-            "/^\s*solrmarc.path\s*=.*$/m",
-            "solrmarc.path = {$overrideDir}/import|{$baseDir}/import", $import
-        );
-        if (!@file_put_contents($target, $import)) {
-            die("Problem writing {$overrideDir}/import/{$filename}.\n\n");
-        }
-    }
-}
-
-/**
- * Build a set of directories.
- *
- * @param array $dirs Directories to build
- *
- * @return bool|string True on success, name of problem directory on failure
- */
-function buildDirs($dirs)
-{
-    foreach ($dirs as $dir) {
-        if (!is_dir($dir) && !@mkdir($dir)) {
-            return $dir;
-        }
-    }
-    return true;
-}
-
-/**
- * Make sure all modules exist (and create them if they do not.
- *
- * @param string $baseDir The VuFind base directory
- * @param string $modules The comma-separated list of modules (assumed valid!)
- *
- * @return void
- */
-function buildModules($baseDir, $modules)
-{
-    foreach (explode(',', $modules) as $module) {
-        $moduleDir = $baseDir . '/module/' . $module;
-        // Is module missing? If so, create it from the template:
-        if (!file_exists($moduleDir . '/Module.php')) {
-            buildModule($baseDir, $module);
-        }
-    }
-}
-
-/**
- * Build the module for storing local code changes.
- *
- * @param string $baseDir The VuFind base directory
- * @param string $module  The name of the new module (assumed valid!)
- *
- * @return void
- */
-function buildModule($baseDir, $module)
-{
-    // Create directories:
-    $moduleDir = $baseDir . '/module/' . $module;
-    $dirStatus = buildDirs(
-        array(
-            $moduleDir,
-            $moduleDir . '/config',
-            $moduleDir . '/src',
-            $moduleDir . '/src/' . $module
-        )
-    );
-    if ($dirStatus !== true) {
-        die("Problem creating {$dirStatus}.\n");
-    }
-
-    // Copy configuration:
-    $configFile = $baseDir . '/module/VuFindLocalTemplate/config/module.config.php';
-    $config = @file_get_contents($configFile);
-    if (!$config) {
-        die("Problem reading {$configFile}.\n");
-    }
-    $success = @file_put_contents(
-        $moduleDir . '/config/module.config.php',
-        str_replace('VuFindLocalTemplate', $module, $config)
-    );
-    if (!$success) {
-        die("Problem writing {$moduleDir}/config/module.config.php.\n");
-    }
-
-    // Copy PHP code:
-    $moduleFile = $baseDir . '/module/VuFindLocalTemplate/Module.php';
-    $contents = @file_get_contents($moduleFile);
-    if (!$contents) {
-        die("Problem reading {$moduleFile}.\n");
-    }
-    $success = @file_put_contents(
-        $moduleDir . '/Module.php',
-        str_replace('VuFindLocalTemplate', $module, $contents)
-    );
-    if (!$success) {
-        die("Problem writing {$moduleDir}/Module.php.\n");
-    }
+if (!file_exists(__DIR__ . '/vendor/autoload.php')) {
+    die("Please run 'composer install' to load dependencies.\n");
 }
+require_once __DIR__ . '/vendor/autoload.php';
+require_once __DIR__
+    . '/module/VuFindConsole/src/VuFindConsole/Command/Install/InstallCommand.php';
+
+$command = new \VuFindConsole\Command\Install\InstallCommand($argv[0]);
+$application = new \Symfony\Component\Console\Application();
+$application->add($command);
+$application->setDefaultCommand($command->getName(), true);
+return $application->run();
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index e9bda4bd2194f17e41a7adedf6bd4ffd8b5d605d..c1ee018af9c4986de69bd6e3ba86c17ab7b21a2d 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -437,9 +437,6 @@ $config = [
             'Laminas\Mvc\I18n\Translator' => 'VuFind\I18n\Translator\TranslatorFactory',
             'Laminas\Session\SessionManager' => 'VuFind\Session\ManagerFactory',
         ],
-        'delegators' => [
-            'VuFind\Http\PhpEnvironment\Request' => [ \Laminas\Mvc\Console\Service\ConsoleRequestDelegatorFactory::class ],
-        ],
         'initializers' => [
             'VuFind\ServiceManager\ServiceInitializer',
         ],
diff --git a/module/VuFind/src/VuFind/Bootstrapper.php b/module/VuFind/src/VuFind/Bootstrapper.php
index 60cef717526320aa02b9ab3f31d81a2bb60cc0b4..b5f0437ffbe7e089202db4e5de1cba1d3a78209c 100644
--- a/module/VuFind/src/VuFind/Bootstrapper.php
+++ b/module/VuFind/src/VuFind/Bootstrapper.php
@@ -27,7 +27,6 @@
  */
 namespace VuFind;
 
-use Laminas\Console\Console;
 use Laminas\Mvc\MvcEvent;
 use Laminas\Router\Http\RouteMatch;
 
@@ -133,7 +132,7 @@ class Bootstrapper
     {
         // If the system is unavailable and we're not in the console, forward to the
         // unavailable page.
-        if (!Console::isConsole() && !($this->config->System->available ?? true)) {
+        if (PHP_SAPI !== 'cli' && !($this->config->System->available ?? true)) {
             $callback = function ($e) {
                 $routeMatch = new RouteMatch(
                     ['controller' => 'Error', 'action' => 'Unavailable'], 1
@@ -175,7 +174,7 @@ class Bootstrapper
     {
         $callback = function ($event) {
             $serviceManager = $event->getApplication()->getServiceManager();
-            if (!Console::isConsole()) {
+            if (PHP_SAPI !== 'cli') {
                 $viewModel = $serviceManager->get('ViewManager')->getViewModel();
 
                 // Grab the template name from the first child -- we can use this to
@@ -278,7 +277,7 @@ class Bootstrapper
     protected function initLanguage()
     {
         // Language not supported in CLI mode:
-        if (Console::isConsole()) {
+        if (PHP_SAPI == 'cli') {
             return;
         }
 
@@ -366,7 +365,7 @@ class Bootstrapper
     protected function initExceptionBasedHttpStatuses()
     {
         // HTTP statuses not needed in console mode:
-        if (Console::isConsole()) {
+        if (PHP_SAPI == 'cli') {
             return;
         }
 
@@ -412,7 +411,7 @@ class Bootstrapper
                     $exception = $event->getParam('exception');
                     // Console request does not include server,
                     // so use a dummy in that case.
-                    $server = Console::isConsole()
+                    $server = (PHP_SAPI == 'cli')
                         ? new \Laminas\Stdlib\Parameters(['env' => 'console'])
                         : $event->getRequest()->getServer();
                     if (!empty($exception)) {
diff --git a/module/VuFind/src/VuFind/Cookie/CookieManagerFactory.php b/module/VuFind/src/VuFind/Cookie/CookieManagerFactory.php
index 08fe2cba934ed34f64ec99e3dde4d2f171534767..5c27503974d0b1f25d6a3f1d34b7f88370ef413c 100644
--- a/module/VuFind/src/VuFind/Cookie/CookieManagerFactory.php
+++ b/module/VuFind/src/VuFind/Cookie/CookieManagerFactory.php
@@ -28,7 +28,6 @@
 namespace VuFind\Cookie;
 
 use Interop\Container\ContainerInterface;
-use Laminas\Console\Console;
 use Laminas\ServiceManager\Factory\FactoryInterface;
 
 /**
@@ -66,7 +65,7 @@ class CookieManagerFactory implements FactoryInterface
             ->get('config');
         $path = '/';
         if ($config->Cookies->limit_by_path ?? false) {
-            $path = Console::isConsole()
+            $path = (PHP_SAPI == 'cli')
                 ? '' : $container->get('Request')->getBasePath();
             if (empty($path)) {
                 $path = '/';
diff --git a/module/VuFind/src/VuFind/Log/LoggerFactory.php b/module/VuFind/src/VuFind/Log/LoggerFactory.php
index 7f74574448c760218edf42de6565d43c47cf2818..01525e6c3e3c59710c6e073a6d0e24f61d2f8999 100644
--- a/module/VuFind/src/VuFind/Log/LoggerFactory.php
+++ b/module/VuFind/src/VuFind/Log/LoggerFactory.php
@@ -29,7 +29,6 @@ namespace VuFind\Log;
 
 use Interop\Container\ContainerInterface;
 use Laminas\Config\Config;
-use Laminas\Console\Console;
 use Laminas\Log\Writer\WriterInterface;
 use Laminas\ServiceManager\Factory\FactoryInterface;
 
@@ -188,7 +187,7 @@ class LoggerFactory implements FactoryInterface
         // Query parameters do not apply in console mode; if we do have a debug
         // query parameter, and the appropriate permission is set, activate dynamic
         // debug:
-        if (!Console::isConsole()
+        if (PHP_SAPI !== 'cli'
             && $container->get('Request')->getQuery()->get('debug')
         ) {
             return $container->get(\ZfcRbac\Service\AuthorizationService::class)
diff --git a/module/VuFind/src/VuFind/Role/PermissionProvider/IpRange.php b/module/VuFind/src/VuFind/Role/PermissionProvider/IpRange.php
index cee13c4830613152b4275e7390e9efc58d3011c9..a4a3cdfe6e55ec5983c54e70e0a744fe2ac37e34 100644
--- a/module/VuFind/src/VuFind/Role/PermissionProvider/IpRange.php
+++ b/module/VuFind/src/VuFind/Role/PermissionProvider/IpRange.php
@@ -30,7 +30,6 @@
  */
 namespace VuFind\Role\PermissionProvider;
 
-use Laminas\Console\Console;
 use Laminas\Stdlib\RequestInterface;
 use VuFind\Net\IpAddressUtils;
 
@@ -83,7 +82,7 @@ class IpRange implements PermissionProviderInterface
      */
     public function getPermissions($options)
     {
-        if (Console::isConsole()) {
+        if (PHP_SAPI == 'cli') {
             return [];
         }
         // Check if any regex matches....
diff --git a/module/VuFind/src/VuFind/Sitemap/Generator.php b/module/VuFind/src/VuFind/Sitemap/Generator.php
index e9505f6f3be4070dc93f445d279b3e8e86fbb3b6..e9c36f991dd8c4875ef909539704256bced1ccdc 100644
--- a/module/VuFind/src/VuFind/Sitemap/Generator.php
+++ b/module/VuFind/src/VuFind/Sitemap/Generator.php
@@ -28,7 +28,6 @@
 namespace VuFind\Sitemap;
 
 use Laminas\Config\Config;
-use Laminas\Console\Console;
 use VuFind\Search\BackendManager;
 use VuFindSearch\Backend\Solr\Backend;
 use VuFindSearch\Backend\Solr\Response\Json\RecordCollectionFactory;
@@ -125,11 +124,11 @@ class Generator
     protected $warnings = [];
 
     /**
-     * Verbose mode
+     * Verbose callback
      *
-     * @var bool
+     * @var \Callable
      */
-    protected $verbose = false;
+    protected $verbose = null;
 
     /**
      * Mode of retrieving IDs from the index (may be 'terms' or 'search')
@@ -183,11 +182,12 @@ class Generator
     }
 
     /**
-     * Get/set verbose mode
+     * Get/set verbose callback
      *
-     * @param bool $newMode New verbose mode
+     * @param \Callable|null $newMode Callback for writing verbose messages (or null
+     * to disable them)
      *
-     * @return bool Current or new verbose mode
+     * @return \Callable|null Current verbose callback (null if disabled)
      */
     public function setVerbose($newMode = null)
     {
@@ -197,6 +197,20 @@ class Generator
         return $this->verbose;
     }
 
+    /**
+     * Write a verbose message (if configured to do so)
+     *
+     * @param string $msg Message to display
+     *
+     * @return void
+     */
+    protected function verboseMsg($msg)
+    {
+        if (is_callable($this->verbose)) {
+            call_user_func($this->verbose, $msg);
+        }
+    }
+
     /**
      * Get/set base url
      *
@@ -266,11 +280,9 @@ class Generator
         $this->buildIndex($currentPage - 1);
 
         // Display total elapsed time in verbose mode:
-        if ($this->verbose) {
-            Console::writeLine(
-                'Elapsed time (in seconds): ' . round($this->getTime() - $startTime)
-            );
-        }
+        $this->verboseMsg(
+            'Elapsed time (in seconds): ' . round($this->getTime() - $startTime)
+        );
     }
 
     /**
@@ -325,9 +337,7 @@ class Generator
             // Update total record count:
             $recordCount += count($result['ids']);
 
-            if ($this->verbose) {
-                Console::writeLine("Page $currentPage, $recordCount processed");
-            }
+            $this->verboseMsg("Page $currentPage, $recordCount processed");
 
             // Update counter:
             $currentPage++;
diff --git a/module/VuFind/src/VuFind/XSLT/Importer.php b/module/VuFind/src/VuFind/XSLT/Importer.php
index 3fd9a5b2c8307b73f9928c25e28916acd9aaf47f..d4a1263a85d97a5097eb53bfc426e954e37381d8 100644
--- a/module/VuFind/src/VuFind/XSLT/Importer.php
+++ b/module/VuFind/src/VuFind/XSLT/Importer.php
@@ -28,7 +28,6 @@
 namespace VuFind\XSLT;
 
 use DOMDocument;
-use Laminas\Console\Console;
 use Laminas\ServiceManager\ServiceLocatorInterface;
 use VuFind\Config\Locator as ConfigLocator;
 use VuFindSearch\Backend\Solr\Document\RawXMLDocument;
@@ -71,7 +70,7 @@ class Importer
      * @param bool   $testMode   Are we in test-only mode?
      *
      * @throws \Exception
-     * @return void
+     * @return string            Transformed XML
      */
     public function save($xmlFile, $properties, $index = 'Solr',
         $testMode = false
@@ -83,9 +82,8 @@ class Importer
         if (!$testMode) {
             $solr = $this->serviceLocator->get(\VuFind\Solr\Writer::class);
             $solr->save($index, new RawXMLDocument($xml));
-        } else {
-            Console::write($xml . "\n");
         }
+        return $xml;
     }
 
     /**
diff --git a/module/VuFindConsole/Module.php b/module/VuFindConsole/Module.php
index 04768b93beded7611be0993af6e81c9b65d302f5..d2b517de4f10704ad97058021aa8cdbe0e5baa37 100644
--- a/module/VuFindConsole/Module.php
+++ b/module/VuFindConsole/Module.php
@@ -27,8 +27,6 @@
  */
 namespace VuFindConsole;
 
-use Laminas\Console\Adapter\AdapterInterface as Console;
-
 /**
  * Code module for VuFind's console functionality
  *
@@ -38,8 +36,7 @@ use Laminas\Console\Adapter\AdapterInterface as Console;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development
  */
-class Module implements \Laminas\ModuleManager\Feature\ConsoleUsageProviderInterface,
-    \Laminas\ModuleManager\Feature\ConsoleBannerProviderInterface
+class Module
 {
     /**
      * Get module configuration
@@ -66,76 +63,4 @@ class Module implements \Laminas\ModuleManager\Feature\ConsoleUsageProviderInter
             ],
         ];
     }
-
-    /**
-     * Returns a string containing a banner text, that describes the module and/or
-     * the application.
-     * The banner is shown in the console window, when the user supplies invalid
-     * command-line parameters or invokes the application with no parameters.
-     *
-     * The method is called with active Laminas\Console\Adapter\AdapterInterface that
-     * can be used to directly access Console and send output.
-     *
-     * @param Console $console Console adapter
-     *
-     * @return string|null
-     *
-     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
-     */
-    public function getConsoleBanner(Console $console)
-    {
-        return 'VuFind';
-    }
-
-    /**
-     * Return usage information
-     *
-     * @param Console $console Console adapter
-     *
-     * @return array
-     *
-     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
-     */
-    public function getConsoleUsage(Console $console)
-    {
-        return [
-            'compile theme' => 'Flatten a theme hierarchy for improved performance',
-            'generate dynamicroute' => 'Add a dynamic route',
-            'generate extendclass' => 'Subclass a service, w/ lookup by class name',
-            'generate extendservice' => 'Override a service with a new child class',
-            'generate nontabrecordaction' => 'Add routes for non-tab record action',
-            'generate plugin' => 'Create a new plugin class',
-            'generate recordroute' => 'Add a record route',
-            'generate staticroute' => 'Add a static route',
-            'generate theme' => 'Create and configure a new theme',
-            'harvest harvest_oai' => 'OAI-PMH harvester',
-            'harvest merge-marc' => 'MARC merge tool',
-            'import import-xsl' => 'XSLT importer',
-            'import webcrawl' => 'Web crawler',
-            'language addusingtemplate' => 'Build new language strings from '
-                . 'existing ones using a template',
-            'language copystring' => 'Copy one language string to another',
-            'language delete' => 'Remove a language string from all files',
-            'language normalize' => 'Normalize a directory of language files',
-            'scheduledsearch notify' => 'Send scheduled search email notifications',
-            'util cleanup_record_cache' => 'Remove unused records from the cache',
-            'util commit' => 'Solr commit tool',
-            'util createHierarchyTrees' => 'Cache populator for hierarchies',
-            'util cssBuilder' => 'LESS compiler',
-            'util deletes' => 'Tool for deleting Solr records',
-            'util expire_auth_hashes' => 'Database auth_hash table cleanup',
-            'util expire_external_sessions'
-                => 'Database external_session table cleanup',
-            'util expire_searches' => 'Database search table cleanup',
-            'util expire_sessions' => 'Database session table cleanup',
-            'util index_reserves' => 'Solr reserves indexer',
-            'util lint_marc' => 'MARC validator',
-            'util optimize' => 'Solr optimize tool',
-            'util sitemap' => 'XML sitemap generator',
-            'util suppressed' => 'Remove ILS-suppressed records from Solr',
-            'util switch_db_hash' => 'Switch the hashing algorithm in the database '
-                . 'and config. Expects new algorithm and (optional) new key as'
-                . ' parameters.',
-        ];
-    }
 }
diff --git a/module/VuFindConsole/config/module.config.php b/module/VuFindConsole/config/module.config.php
index 7f694e5660855bce6d1b6413304fecbeee68e32d..af327c2de49224b16fda398df09c6503ca233724 100644
--- a/module/VuFindConsole/config/module.config.php
+++ b/module/VuFindConsole/config/module.config.php
@@ -24,25 +24,11 @@ $config = [
             'util' => 'VuFindConsole\Controller\UtilController',
         ],
     ],
-    'console' => [
-        'router'  => [
-            'routes'  => [
-                'default-route' => [
-                    'type' => 'catchall',
-                    'options' => [
-                        'route' => '',
-                        'defaults' => [
-                            'controller' => 'redirect',
-                            'action' => 'consoledefault',
-                        ],
-                    ],
-                ],
-            ],
-        ],
-    ],
     'service_manager' => [
         'factories' => [
             'VuFind\Sitemap\Generator' => 'VuFind\Sitemap\GeneratorFactory',
+            'VuFindConsole\Command\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
+            'VuFindConsole\ConsoleRunner' => 'VuFindConsole\ConsoleRunnerFactory',
             'VuFindConsole\Generator\GeneratorTools' => 'VuFindConsole\Generator\GeneratorToolsFactory',
         ],
     ],
@@ -50,46 +36,11 @@ $config = [
         // CLI tools are admin-oriented, so we should always output full errors:
         'display_exceptions' => true,
     ],
+    'vufind' => [
+        'plugin_managers' => [
+            'command' => [ /* see VuFindConsole\Command\PluginManager for defaults */ ],
+        ],
+    ],
 ];
 
-$routes = [
-    'compile/theme' => 'compile theme [--force] [<source>] [<target>]',
-    'generate/dynamicroute' => 'generate dynamicroute [<name>] [<newController>] [<newAction>] [<module>]',
-    'generate/extendclass' => 'generate extendclass [--extendfactory] [<class>] [<target>]',
-    'generate/extendservice' => 'generate extendservice [<source>] [<target>]',
-    'generate/nontabrecordaction' => 'generate nontabrecordaction [<newAction>] [<module>]',
-    'generate/plugin' => 'generate plugin [<class>] [<factory>]',
-    'generate/recordroute' => 'generate recordroute [<base>] [<newController>] [<module>]',
-    'generate/staticroute' => 'generate staticroute [<name>] [<module>]',
-    'generate/theme' => 'generate theme [<themename>]',
-    'generate/thememixin' => 'generate thememixin [<name>]',
-    'harvest/harvest_oai' => 'harvest harvest_oai [...params]',
-    'harvest/merge-marc' => 'harvest merge-marc [<dir>]',
-    'import/import-xsl' => 'import import-xsl [--test-only] [--index=] [<xml>] [<properties>]',
-    'import/webcrawl' => 'import webcrawl [--test-only] [--index=]',
-    'language/addusingtemplate' => 'language addusingtemplate [<target>] [<template>]',
-    'language/copystring' => 'language copystring [<source>] [<target>]',
-    'language/delete' => 'language delete [<target>]',
-    'language/normalize' => 'language normalize [<target>]',
-    'scheduledsearch/notify' => 'scheduledsearch notify',
-    'util/cleanup_record_cache' => 'util (cleanuprecordcache|cleanup_record_cache) [--help|-h]',
-    'util/commit' => 'util commit [<core>]',
-    'util/createHierarchyTrees' => 'util createHierarchyTrees [--skip-xml|-sx] [--skip-json|-sj] [<backend>] [--help|-h]',
-    'util/cssBuilder' => 'util cssBuilder [...themes]',
-    'util/deletes' => 'util deletes [--verbose] [<filename>] [<format>] [<index>]',
-    'util/expire_auth_hashes' => 'util expire_auth_hashes [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
-    'util/expire_external_sessions' => 'util expire_external_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
-    'util/expire_searches' => 'util expire_searches [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
-    'util/expire_sessions' => 'util expire_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
-    'util/index_reserves' => 'util index_reserves [--help|-h] [-d=s] [-t=s] [-f=s]',
-    'util/lint_marc' => 'util lint_marc [<filename>]',
-    'util/optimize' => 'util optimize [<core>]',
-    'util/sitemap' => 'util sitemap [--help|-h] [--verbose] [--baseurl=s] [--basesitemapurl=s]',
-    'util/suppressed' => 'util suppressed [--help|-h] [--authorities] [--outfile=s]',
-    'util/switch_db_hash' => 'util switch_db_hash [<newhash>] [<newkey>]',
-];
-
-$routeGenerator = new \VuFindConsole\Route\RouteGenerator();
-$routeGenerator->addRoutes($config, $routes);
-
 return $config;
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..13d5f3b6f353dba48530d3b44eaddd93f0da000f
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommand.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * Console command: Compile themes.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Compile;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindTheme\ThemeCompiler;
+
+/**
+ * Console command: Compile themes.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'compile/theme';
+
+    /**
+     * Theme compiler
+     *
+     * @var ThemeCompiler
+     */
+    protected $compiler;
+
+    /**
+     * Constructor
+     *
+     * @param ThemeCompiler $compiler Theme compiler
+     * @param string|null   $name     The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(ThemeCompiler $compiler, $name = null)
+    {
+        $this->compiler = $compiler;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Theme compiler')
+            ->setHelp('Flattens a theme hierarchy for improved performance.')
+            ->addArgument(
+                'source',
+                InputArgument::REQUIRED,
+                'the source theme to compile'
+            )->addArgument(
+                'target',
+                InputArgument::OPTIONAL,
+                'the target name for the compiled theme '
+                . '(defaults to <source> with _compiled appended)'
+            )->addOption(
+                'force',
+                'f',
+                InputOption::VALUE_NONE,
+                'If <target> exists, it will only be overwritten when this is set'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $source = $input->getArgument('source');
+        $target = $input->getArgument('target');
+        if (empty($target)) {
+            $target = "{$source}_compiled";
+        }
+        $force = $input->getOption('force') ? true : false;
+        if (!$this->compiler->compile($source, $target, $force)) {
+            $output->writeln($this->compiler->getLastError());
+            return 1;
+        }
+        $output->writeln('Success.');
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..acb3b673b2afa2565d1bca597bb901ba1959eebc
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Compile/ThemeCommandFactory.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Factory for console command: Compile themes.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Compile;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for console command: Compile themes.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+        return new $requestedName(
+            $container->get(\VuFindTheme\ThemeCompiler::class)
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Route/RouteGenerator.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommand.php
similarity index 58%
rename from module/VuFindConsole/src/VuFindConsole/Route/RouteGenerator.php
rename to module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommand.php
index 60279b85378337af169e431ef50301562b36acae..5cd832a6c585b4a4018a4c47b6e5d5caaccc56d3 100644
--- a/module/VuFindConsole/src/VuFindConsole/Route/RouteGenerator.php
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommand.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * Route Generator Class
+ * Abstract base class for generator commands.
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2010.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -20,43 +20,44 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
  *
  * @category VuFind
- * @package  Route
+ * @package  Console
  * @author   Demian Katz <demian.katz@villanova.edu>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-namespace VuFindConsole\Route;
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Command\Command;
+use VuFindConsole\Generator\GeneratorTools;
 
 /**
- * Route Generator Class
+ * Abstract base class for generator commands.
  *
  * @category VuFind
- * @package  Route
+ * @package  Console
  * @author   Demian Katz <demian.katz@villanova.edu>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-class RouteGenerator
+abstract class AbstractCommand extends Command
 {
     /**
-     * Add console routes to the configuration.
+     * Generator tools
      *
-     * @param array $config Configuration array to update
-     * @param array $routes Array of Controller/Action strings => route values
+     * @var GeneratorTools
+     */
+    protected $generatorTools;
+
+    /**
+     * Constructor
      *
-     * @return void
+     * @param GeneratorTools $tools Generator tools
+     * @param string|null    $name  The name of the command; passing null means it
+     * must be set in configure()
      */
-    public function addRoutes(& $config, $routes)
+    public function __construct(GeneratorTools $tools, $name = null)
     {
-        foreach ($routes as $key => $route) {
-            list($controller, $action) = explode('/', $key);
-            $name = $controller . '-' . $action;
-            $config['console']['router']['routes'][$name] = [
-                'options' => [
-                    'route' => $route,
-                    'defaults' => compact('controller', 'action'),
-                ]
-            ];
-        }
+        $this->generatorTools = $tools;
+        parent::__construct($name);
     }
 }
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b177c0c74aadca25bb756f550293214ad6baeb2
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Shared factory for generator commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Shared factory for generator commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFindConsole\Generator\GeneratorTools::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..ca54e8b58535688d13daa9b3a6f22df46f7065b7
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommand.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Abstract base class for generator commands relying on the service container.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Abstract base class for generator commands relying on the service container.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractContainerAwareCommand extends AbstractCommand
+{
+    /**
+     * Top-level service container
+     *
+     * @var ContainerInterface
+     */
+    protected $container;
+
+    /**
+     * Constructor
+     *
+     * @param GeneratorTools     $tools     Generator tools
+     * @param ContainerInterface $container Top-level service container
+     * @param string|null        $name      The name of the command; passing null
+     * means it must be set in configure()
+     */
+    public function __construct(GeneratorTools $tools, ContainerInterface $container,
+        $name = null
+    ) {
+        $this->container = $container;
+        parent::__construct($tools, $name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..241901fc09d27825ba7b362e9e1d76ce9990ef54
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractContainerAwareCommandFactory.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Factory for console generator commands that rely on a service container.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for console generator commands that rely on a service container.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractContainerAwareCommandFactory extends AbstractCommandFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return parent::__invoke(
+            $container, $requestedName, array_merge([$container], $options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..7ba12094c60bf3812029e63e4dcf6a51a6d72a69
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommand.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Abstract base class for route generator commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use VuFind\Route\RouteGenerator;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Abstract base class for route generator commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractRouteCommand extends AbstractCommand
+{
+    /**
+     * Route generator
+     *
+     * @var RouteGenerator
+     */
+    protected $routeGenerator;
+
+    /**
+     * Constructor
+     *
+     * @param GeneratorTools $tools    Generator tools
+     * @param RouteGenerator $routeGen Route generator
+     * @param string|null    $name     The name of the command; passing null
+     * means it must be set in configure()
+     */
+    public function __construct(GeneratorTools $tools, RouteGenerator $routeGen,
+        $name = null
+    ) {
+        $this->routeGenerator = $routeGen;
+        parent::__construct($tools, $name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..40ddaaf7a62d1928a05f416ffcba9147e1ab9087
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractRouteCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Shared factory for route generator commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use VuFind\Route\RouteGenerator;
+
+/**
+ * Shared factory for route generator commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractRouteCommandFactory extends AbstractCommandFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $generator = new RouteGenerator();
+        return parent::__invoke(
+            $container, $requestedName, array_merge([$generator], $options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractThemeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractThemeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..591ae77eaa0df5c99307c95107ad6b8009516992
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/AbstractThemeCommand.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Abstract base class for theme resource generator commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindTheme\GeneratorInterface;
+
+/**
+ * Abstract base class for theme resource generator commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractThemeCommand extends Command
+{
+    /**
+     * Theme resource generator
+     *
+     * @var GeneratorInterface
+     */
+    protected $generator;
+
+    /**
+     * Type of resource being generated (used in help messages)
+     *
+     * @var string
+     */
+    protected $type;
+
+    /**
+     * Extra text to append to the output when generation is successful.
+     *
+     * @var string
+     */
+    protected $extraSuccessMessage = '';
+
+    /**
+     * Constructor
+     *
+     * @param GeneratorInterface $generator Generator to call
+     * @param string|null        $name      The name of the command; passing null
+     * means it must be set in configure()
+     */
+    public function __construct(GeneratorInterface $generator, $name = null)
+    {
+        $this->generator = $generator;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription(ucwords($this->type) . ' generator')
+            ->setHelp('Creates and configures a new ' . $this->type . '.')
+            ->addArgument(
+                'name',
+                InputArgument::OPTIONAL,
+                'name of ' . $this->type
+                . ' to generate. Defaults to custom  if unspecified.'
+            );
+    }
+
+    /**
+     * Run the generator.
+     *
+     * @param string $name Name of resource to generate
+     *
+     * @return bool
+     */
+    protected function generate($name)
+    {
+        return $this->generator->generate($name);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $name = $input->getArgument('name');
+        if (empty($name)) {
+            $output->writeln("\tNo {$this->type} name provided, using \"custom\"");
+            $name = 'custom';
+        }
+
+        $this->generator->setOutputInterface($output);
+
+        if (!$this->generate($name)) {
+            $output->writeln($this->generator->getLastError());
+            return 1;
+        }
+        $output->writeln(rtrim("\tFinished. {$this->extraSuccessMessage}"));
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/DynamicRouteCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/DynamicRouteCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..08624bdbc28878bc89b739338989f556d00534f8
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/DynamicRouteCommand.php
@@ -0,0 +1,111 @@
+<?php
+/**
+ * Console command: Generate dynamic route.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: Generate dynamic route.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DynamicRouteCommand extends AbstractRouteCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/dynamicroute';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Dynamic route generator')
+            ->setHelp('Adds a dynamic route.')
+            ->addArgument(
+                'route',
+                InputArgument::REQUIRED,
+                'the route name (used by router), e.g. customList'
+            )->addArgument(
+                'controller',
+                InputArgument::REQUIRED,
+                'the controller name (used in URL), e.g. MyResearch'
+            )->addArgument(
+                'action',
+                InputArgument::REQUIRED,
+                'the action and segment params, e.g. CustomList/[:id]'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new route will be generated'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $route = $input->getArgument('route');
+        $controller = $input->getArgument('controller');
+        $action = $input->getArgument('action');
+        $module = $input->getArgument('target_module');
+
+        $this->generatorTools->setOutputInterface($output);
+
+        // Create backup of configuration
+        $configPath = $this->generatorTools->getModuleConfigPath($module);
+        $this->generatorTools->backUpFile($configPath);
+
+        // Append the route
+        $config = include $configPath;
+        $this->routeGenerator
+            ->addDynamicRoute($config, $route, $controller, $action);
+
+        // Write updated configuration
+        $this->generatorTools->writeModuleConfig($configPath, $config);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendClassCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendClassCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..b999c1fc1329c867779006e5a8ea4ab121bf12b4
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendClassCommand.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Console command: extend class.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: extend class.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExtendClassCommand extends AbstractContainerAwareCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/extendclass';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Subclass generator')
+            ->setHelp('Subclasses a service, with lookup by class name.')
+            ->addArgument(
+                'class_name',
+                InputArgument::REQUIRED,
+                'the name of the class you wish to extend'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new class will be generated'
+            )->addOption(
+                'extendfactory',
+                null,
+                InputOption::VALUE_NONE,
+                'when set, subclass the factory; otherwise, use existing factory'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $class = $input->getArgument('class_name');
+        $target = $input->getArgument('target_module');
+        $extendFactory = $input->getOption('extendfactory');
+
+        try {
+            $this->generatorTools->setOutputInterface($output);
+            $this->generatorTools->extendClass(
+                $this->container, $class, $target, $extendFactory
+            );
+        } catch (\Exception $e) {
+            $output->writeln($e->getMessage());
+            return 1;
+        }
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendServiceCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendServiceCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..d41a7ab42f4d3fd63bba47a6fe8e02bcb6b24177
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ExtendServiceCommand.php
@@ -0,0 +1,97 @@
+<?php
+/**
+ * Console command: extend service.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: extend service.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExtendServiceCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/extendservice';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Service generator')
+            ->setHelp('Override a service with a new child class.')
+            ->addArgument(
+                'config_path',
+                InputArgument::REQUIRED,
+                "the path to the service in the framework config\ne.g."
+                . ' controllers/factories/VuFind\\\\Controller\\\\AjaxController'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new class will be generated'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $source = $input->getArgument('config_path');
+        $target = $input->getArgument('target_module');
+
+        try {
+            $this->generatorTools->setOutputInterface($output);
+            $this->generatorTools->extendService($source, $target);
+        } catch (\Exception $e) {
+            $output->writeln($e->getMessage());
+            return 1;
+        }
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..e63b066f548aca958638543a3879fc9667e1e4da
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommand.php
@@ -0,0 +1,138 @@
+<?php
+/**
+ * Console command: Generate non-tab record action route.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Console command: Generate non-tab record action route.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class NonTabRecordActionCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/nontabrecordaction';
+
+    /**
+     * Main framework configuration
+     *
+     * @var array
+     */
+    protected $mainConfig;
+
+    /**
+     * Constructor
+     *
+     * @param GeneratorTools $tools      Generator tools
+     * @param array          $mainConfig Main framework configuration
+     * @param string|null    $name       The name of the command; passing null
+     * means it must be set in configure()
+     */
+    public function __construct(GeneratorTools $tools, array $mainConfig,
+        $name = null
+    ) {
+        $this->mainConfig = $mainConfig;
+        parent::__construct($tools, $name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Non-tab record action route generator')
+            ->setHelp('Adds routes for a non-tab record action.')
+            ->addArgument(
+                'action',
+                InputArgument::REQUIRED,
+                'new action to add'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new routes will be generated'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $action = $input->getArgument('action');
+        $module = $input->getArgument('target_module');
+
+        $this->generatorTools->setOutputInterface($output);
+
+        // Create backup of configuration
+        $configPath = $this->generatorTools->getModuleConfigPath($module);
+        $this->generatorTools->backUpFile($configPath);
+
+        // Append the routes
+        $config = include $configPath;
+        foreach ($this->mainConfig['router']['routes'] as $key => $val) {
+            if (isset($val['options']['route'])
+                && substr($val['options']['route'], -14) == '[:id[/[:tab]]]'
+            ) {
+                $newRoute = $key . '-' . strtolower($action);
+                if (isset($this->mainConfig['router']['routes'][$newRoute])) {
+                    $output->writeln($newRoute . ' already exists; skipping.');
+                } else {
+                    $val['options']['route'] = str_replace(
+                        '[:id[/[:tab]]]', "[:id]/$action", $val['options']['route']
+                    );
+                    $val['options']['defaults']['action'] = $action;
+                    $config['router']['routes'][$newRoute] = $val;
+                }
+            }
+        }
+
+        // Write updated configuration
+        $this->generatorTools->writeModuleConfig($configPath, $config);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..1fe99c09fc8e70de3fee9c09c5f0ff04bd93e784
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/NonTabRecordActionCommandFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Factory for non-tab record action route generator command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for non-tab record action route generator command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class NonTabRecordActionCommandFactory extends AbstractCommandFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $config = $container->get('Config');
+        return parent::__invoke(
+            $container, $requestedName, array_merge([$config], $options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..ec5ad658913d4b73e774b3666e4de13cd601f88a
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/PluginCommand.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Console command: Generate plugin.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: Generate plugin.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class PluginCommand extends AbstractContainerAwareCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/plugin';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Plugin generator')
+            ->setHelp('Creates a new plugin class.')
+            ->addArgument(
+                'class_name',
+                InputArgument::REQUIRED,
+                'the name of the class you wish to create'
+            )->addArgument(
+                'factory',
+                InputArgument::OPTIONAL,
+                'an existing factory to use (omit to generate a new one)'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $class = $input->getArgument('class_name');
+        $factory = $input->getArgument('factory');
+        try {
+            $this->generatorTools->setOutputInterface($output);
+            $this->generatorTools->createPlugin($this->container, $class, $factory);
+        } catch (\Exception $e) {
+            $output->writeln($e->getMessage());
+            return 1;
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/RecordRouteCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/RecordRouteCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..51e523ea39d75d81a8ca4810c0fcaddde1e718e0
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/RecordRouteCommand.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Console command: Generate record route.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: Generate record route.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class RecordRouteCommand extends AbstractRouteCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/recordroute';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Record route generator')
+            ->setHelp('Adds a record route.')
+            ->addArgument(
+                'base',
+                InputArgument::REQUIRED,
+                'the base route name (used by router), e.g. record'
+            )->addArgument(
+                'controller',
+                InputArgument::REQUIRED,
+                'the controller name (used in URL), e.g. MyResearch'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new route will be generated'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $base = $input->getArgument('base');
+        $controller = $input->getArgument('controller');
+        $module = $input->getArgument('target_module');
+
+        $this->generatorTools->setOutputInterface($output);
+
+        // Create backup of configuration
+        $configPath = $this->generatorTools->getModuleConfigPath($module);
+        $this->generatorTools->backUpFile($configPath);
+
+        // Append the route
+        $config = include $configPath;
+        $this->routeGenerator->addRecordRoute($config, $base, $controller);
+
+        // Write updated configuration
+        $this->generatorTools->writeModuleConfig($configPath, $config);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/StaticRouteCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/StaticRouteCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..9328ac7d65d86e79a34edb06ddeee796e88d5cfd
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/StaticRouteCommand.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Console command: Generate static route.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: Generate static route.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class StaticRouteCommand extends AbstractRouteCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/staticroute';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Static route generator')
+            ->setHelp('Adds a static route.')
+            ->addArgument(
+                'route_definition',
+                InputArgument::REQUIRED,
+                'a Controller/Action string, e.g. Search/Home'
+            )->addArgument(
+                'target_module',
+                InputArgument::REQUIRED,
+                'the module where the new route will be generated'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $route = $input->getArgument('route_definition');
+        $module = $input->getArgument('target_module');
+
+        $this->generatorTools->setOutputInterface($output);
+
+        // Create backup of configuration
+        $configPath = $this->generatorTools->getModuleConfigPath($module);
+        $this->generatorTools->backUpFile($configPath);
+
+        // Append the route
+        $config = include $configPath;
+        $this->routeGenerator->addStaticRoute($config, $route);
+
+        // Write updated configuration
+        $this->generatorTools->writeModuleConfig($configPath, $config);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..01566d3a18cd2c369c1a78cd6cb30e5dd2653392
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommand.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Theme generator command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Laminas\Config\Config;
+use VuFindTheme\ThemeGenerator;
+
+/**
+ * Theme generator command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeCommand extends AbstractThemeCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/theme';
+
+    /**
+     * Type of resource being generated (used in help messages)
+     *
+     * @var string
+     */
+    protected $type = 'theme';
+
+    /**
+     * Configuration from config.ini
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Constructor
+     *
+     * @param ThemeGenerator $generator Generator to call
+     * @param Config         $config    Configuration from config.ini
+     * @param string|null    $name      The name of the command; passing null
+     * means it must be set in configure()
+     */
+    public function __construct(ThemeGenerator $generator, Config $config,
+        $name = null
+    ) {
+        $this->config = $config;
+        parent::__construct($generator, $name);
+    }
+
+    /**
+     * Run the generator.
+     *
+     * @param string $name Name of resource to generate
+     *
+     * @return bool
+     */
+    protected function generate($name)
+    {
+        return parent::generate($name)
+            && $this->generator->configure($this->config, $name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..190e40cca9d1aafeed208bedec4143da790bbeaa
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeCommandFactory.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Factory for theme generator command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for theme generator command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeCommandFactory extends AbstractCommandFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+        return new $requestedName(
+            $container->get(\VuFindTheme\ThemeGenerator::class),
+            $config,
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..ea0fc0c4c120060b3e8aa2503f30bf6eac602162
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommand.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Theme mixin generator command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Symfony\Component\Console\Command\Command;
+
+/**
+ * Theme mixin generator command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeMixinCommand extends AbstractThemeCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'generate/thememixin';
+
+    /**
+     * Type of resource being generated (used in help messages)
+     *
+     * @var string
+     */
+    protected $type = 'theme mixin';
+
+    /**
+     * Extra text to append to the output when generation is successful.
+     *
+     * @var string
+     */
+    protected $extraSuccessMessage
+        = 'Add to your theme.config.php \'mixins\' setting to activate.';
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c447764816fdcbb3e4390ff8f3769f381494623
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Generate/ThemeMixinCommandFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Factory for theme mixin generator command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for theme mixin generator command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ThemeMixinCommandFactory extends AbstractCommandFactory
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFindTheme\MixinGenerator::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..5164e497ad1aa152e75d85a761e3c6af51f9d0fa
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommand.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Console command: VuFind-specific customizations to OAI-PMH harvest command
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Harvest;
+
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: VuFind-specific customizations to OAI-PMH harvest command
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class HarvestOaiCommand extends \VuFindHarvest\OaiPmh\HarvesterCommand
+{
+    /**
+     * The name of the command
+     *
+     * @var string
+     */
+    protected static $defaultName = 'harvest/harvest_oai';
+
+    /**
+     * Warn the user if VUFIND_LOCAL_DIR is not set.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return void
+     */
+    protected function checkLocalSetting(OutputInterface $output)
+    {
+        if (!getenv('VUFIND_LOCAL_DIR')) {
+            $output->writeln(
+                'WARNING: The VUFIND_LOCAL_DIR environment variable is not set.'
+            );
+            $output->writeln(
+                'This should point to your local configuration directory (i.e.'
+            );
+            $output->writeln(realpath(APPLICATION_PATH . '/local') . ').');
+            $output->writeln(
+                'Without it, inappropriate default settings may be loaded.'
+            );
+            $output->writeln('');
+        }
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $this->checkLocalSetting($output);
+
+        // Add the default --ini setting if missing:
+        if (!$input->getOption('ini')) {
+            $ini = \VuFind\Config\Locator::getConfigPath('oai.ini', 'harvest');
+            $input->setOption('ini', $ini);
+        }
+        return parent::execute($input, $output);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7c35e1bc2a203b0fa4f04930894fe5965e2f341
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/HarvestOaiCommandFactory.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Factory for OAI harvest command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Harvest;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for OAI harvest command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class HarvestOaiCommandFactory implements FactoryInterface
+{
+    /**
+     * Get the base directory for harvesting OAI-PMH data.
+     *
+     * @return string
+     */
+    protected function getHarvestRoot()
+    {
+        // Get the base VuFind path:
+        $home = strlen(LOCAL_OVERRIDE_DIR) > 0
+            ? LOCAL_OVERRIDE_DIR
+            : realpath(APPLICATION_PATH . '/..');
+
+        // Build the full harvest path:
+        $dir = $home . '/harvest/';
+
+        // Create the directory if it does not already exist:
+        if (!is_dir($dir) && !mkdir($dir)) {
+            throw new \Exception("Problem creating directory {$dir}.");
+        }
+
+        return $dir;
+    }
+
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFindHttp\HttpService::class)->createClient(),
+            $this->getHarvestRoot(),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Harvest/MergeMarcCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/MergeMarcCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8872e35186282ff628e91c8693a9bacf14ae5d62
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Harvest/MergeMarcCommand.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Console command: Merge MARC records.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Thomas Schwaerzler <thomas.schwaerzler@uibk.ac.at>
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Harvest;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindConsole\Command\RelativeFileAwareCommand;
+
+/**
+ * Console command: Merge MARC records.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Thomas Schwaerzler <thomas.schwaerzler@uibk.ac.at>
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class MergeMarcCommand extends RelativeFileAwareCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'harvest/merge-marc';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('MARC merge tool')
+            ->setHelp(
+                'Merges harvested MARCXML files into a single <collection>; '
+                . 'writes to stdout.'
+            )->addArgument(
+                'directory',
+                InputArgument::REQUIRED,
+                'a directory containing MARC XML files to merge'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $dir = rtrim($input->getArgument('directory'), '/');
+
+        if (!($handle = @opendir($dir))) {
+            $output->writeln("Cannot open directory: {$dir}");
+            return 1;
+        }
+
+        $output->writeln('<collection>');
+        while (false !== ($file = readdir($handle))) {
+            // Only operate on XML files:
+            if (pathinfo($file, PATHINFO_EXTENSION) === "xml") {
+                // get file content
+                $filePath = $dir . '/' . $file;
+                $fileContent = file_get_contents($filePath);
+
+                // output content:
+                $output->writeln("<!-- $filePath -->");
+                $output->write($fileContent);
+            }
+        }
+        $output->writeln('</collection>');
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..adad67904a042b6e104a4f5eeaba52dd457988b5
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommand.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Console command: XSLT importer
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Import;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\XSLT\Importer;
+use VuFindConsole\Command\RelativeFileAwareCommand;
+
+/**
+ * Console command: XSLT importer
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ImportXslCommand extends RelativeFileAwareCommand
+{
+    /**
+     * The name of the command
+     *
+     * @var string
+     */
+    protected static $defaultName = 'import/import-xsl';
+
+    /**
+     * XSLT importer
+     *
+     * @var Importer
+     */
+    protected $importer;
+
+    /**
+     * Constructor
+     *
+     * @param Importer    $importer XSLT importer
+     * @param string|null $name     The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Importer $importer, $name = null)
+    {
+        $this->importer = $importer;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('XSLT importer')
+            ->setHelp('Indexes XML into Solr using XSLT.')
+            ->addArgument(
+                'XML_file',
+                InputArgument::REQUIRED,
+                'source file to index'
+            )->addArgument(
+                'properties_file',
+                InputArgument::REQUIRED,
+                'import configuration file ($VUFIND_LOCAL_DIR/import and '
+                . ' $VUFIND_HOME/import will'
+                . "\nbe searched for this filename; see ojs.properties "
+                . 'for configuration examples)'
+            )->addOption(
+                'test-only',
+                null,
+                InputOption::VALUE_NONE,
+                'activates test mode, which displays transformed XML without '
+                . 'updating Solr'
+            )->addOption(
+                'index',
+                null,
+                InputOption::VALUE_OPTIONAL,
+                'name of search backend to index content into (could be overridden '
+                . "with,\nfor example, SolrAuth to index authority records)",
+                'Solr'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $testMode = $input->getOption('test-only') ? true : false;
+        $index = $input->getOption('index');
+        $xml = $input->getArgument('XML_file');
+        $properties = $input->getArgument('properties_file');
+        // Try to import the document if successful:
+        try {
+            $result = $this->importer->save($xml, $properties, $index, $testMode);
+            if ($testMode) {
+                $output->writeln($result);
+            }
+        } catch (\Exception $e) {
+            $output->writeln('Fatal error: ' . $e->getMessage());
+            if (is_callable([$e, 'getPrevious']) && $e = $e->getPrevious()) {
+                while ($e) {
+                    $output->writeln('Previous exception: ' . $e->getMessage());
+                    $e = $e->getPrevious();
+                }
+            }
+            return 1;
+        }
+        if (!$testMode) {
+            $output->writeln("Successfully imported $xml...");
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..5be0a27041de63e7afd43485394d28d9abeee46d
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Import/ImportXslCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Factory for XSLT import command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Import;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for XSLT import command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ImportXslCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            new \VuFind\XSLT\Importer($container),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..f39d937116979bc6f8d05074890b30adac62b0e4
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommand.php
@@ -0,0 +1,255 @@
+<?php
+/**
+ * Console command: web crawler
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Import;
+
+use Laminas\Config\Config;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Solr\Writer;
+use VuFind\XSLT\Importer;
+
+/**
+ * Console command: web crawler
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class WebCrawlCommand extends Command
+{
+    /**
+     * The name of the command
+     *
+     * @var string
+     */
+    protected static $defaultName = 'import/webcrawl';
+
+    /**
+     * XSLT importer
+     *
+     * @var Importer
+     */
+    protected $importer;
+
+    /**
+     * Solr writer
+     *
+     * @var Writer
+     */
+    protected $solr;
+
+    /**
+     * Configuration from webcrawl.ini
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Constructor
+     *
+     * @param Importer    $importer XSLT importer
+     * @param Writer      $solr     Solr writer
+     * @param Config      $config   Configuration from webcrawl.ini
+     * @param string|null $name     The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Importer $importer, Writer $solr, Config $config,
+        $name = null
+    ) {
+        $this->importer = $importer;
+        $this->solr = $solr;
+        $this->config = $config;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Web crawler')
+            ->setHelp('Crawls websites to populate VuFind\'s web index.')
+            ->addOption(
+                'test-only',
+                null,
+                InputOption::VALUE_NONE,
+                'activates test mode, which displays output without updating Solr'
+            )->addOption(
+                'index',
+                null,
+                InputOption::VALUE_OPTIONAL,
+                'name of search backend to index content into',
+                'SolrWeb'
+            );
+    }
+
+    /**
+     * Download a URL to a temporary file.
+     *
+     * @param string $url URL to download
+     *
+     * @return string     Filename of downloaded content
+     */
+    protected function downloadFile($url)
+    {
+        $file = tempnam('/tmp', 'sitemap');
+        file_put_contents($file, file_get_contents($url));
+        return $file;
+    }
+
+    /**
+     * Remove a temporary file.
+     *
+     * @param string $file Name of file to delete
+     *
+     * @return void
+     */
+    protected function removeTempFile($file)
+    {
+        unlink($file);
+    }
+
+    /**
+     * Process a sitemap URL, either harvesting its contents directly or recursively
+     * reading in child sitemaps.
+     *
+     * @param OutputInterface $output   Output object
+     * @param string          $url      URL of sitemap to read.
+     * @param bool            $verbose  Are we in verbose mode?
+     * @param string          $index    Solr index to update
+     * @param bool            $testMode Are we in test mode?
+     *
+     * @return bool           True on success, false on error.
+     */
+    protected function harvestSitemap(OutputInterface $output, $url,
+        $verbose = false, $index = 'SolrWeb', $testMode = false
+    ) {
+        if ($verbose) {
+            $output->writeln("Harvesting $url...");
+        }
+
+        $retVal = true;
+
+        $file = $this->downloadFile($url);
+        $xml = simplexml_load_file($file);
+        if ($xml) {
+            // Are there any child sitemaps?  If so, pull them in:
+            $results = $xml->sitemap ?? [];
+            foreach ($results as $current) {
+                if (isset($current->loc)) {
+                    $success = $this->harvestSitemap(
+                        $output, (string)$current->loc, $verbose, $index, $testMode
+                    );
+                    if (!$success) {
+                        $retVal = false;
+                    }
+                }
+            }
+            // Only import the current sitemap if it contains URLs!
+            if (isset($xml->url)) {
+                try {
+                    $result = $this->importer->save(
+                        $file, 'sitemap.properties', $index, $testMode
+                    );
+                    if ($testMode) {
+                        $output->writeln($result);
+                    }
+                } catch (\Exception $e) {
+                    if ($verbose) {
+                        $output->writeln(get_class($e) . ': ' . $e->getMessage());
+                    }
+                    $retVal = false;
+                }
+            }
+        }
+        $this->removeTempFile($file);
+        return $retVal;
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Get command line parameters:
+        $testMode = $input->getOption('test-only') ? true : false;
+        $index = $input->getOption('index');
+
+        // Get the time we started indexing -- we'll delete records older than this
+        // date after everything is finished.  Note that we subtract a few seconds
+        // for safety.
+        $startTime = date('Y-m-d\TH:i:s\Z', time() - 5);
+
+        // Are we in verbose mode?
+        $verbose = ($this->config->General->verbose ?? false)
+            || ($input->hasOption('verbose') && $input->getOption('verbose'));
+
+        // Loop through sitemap URLs in the config file.
+        $error = false;
+        foreach ($this->config->Sitemaps->url as $current) {
+            $error = $error || !$this->harvestSitemap(
+                $output, $current, $verbose, $index, $testMode
+            );
+        }
+        if ($error) {
+            $output->writeln("Error encountered during harvest.");
+        }
+
+        // Skip Solr operations if we're in test mode.
+        if (!$testMode) {
+            if ($verbose) {
+                $output->writeln("Deleting old records (prior to $startTime)...");
+            }
+            // Perform the delete of outdated records:
+            $this->solr
+                ->deleteByQuery($index, 'last_indexed:[* TO ' . $startTime . ']');
+            if ($verbose) {
+                $output->writeln('Committing...');
+            }
+            $this->solr->commit($index);
+            if ($verbose) {
+                $output->writeln('Optimizing...');
+            }
+            $this->solr->optimize($index);
+        }
+        return $error ? 1 : 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c9dda49774c9a9e7a140a41aeb57f9daf2276ea
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Import/WebCrawlCommandFactory.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Factory for web crawl command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Import;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for web crawl command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class WebCrawlCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $configLoader = $container->get(\VuFind\Config\PluginManager::class);
+        return new $requestedName(
+            new \VuFind\XSLT\Importer($container),
+            $container->get(\VuFind\Solr\Writer::class),
+            $configLoader->get('webcrawl'),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Install/InstallCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Install/InstallCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8f53eb805711177e138527af61a0a0b346305de2
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Install/InstallCommand.php
@@ -0,0 +1,932 @@
+<?php
+/**
+ * Console command: VuFind installer.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Install;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\Question;
+
+/**
+ * Console command: VuFind installer.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class InstallCommand extends Command
+{
+    const MULTISITE_NONE = 0;
+    const MULTISITE_DIR_BASED = 1;
+    const MULTISITE_HOST_BASED = 2;
+
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'install/install';
+
+    /**
+     * Base directory of VuFind installation.
+     *
+     * @var string
+     */
+    protected $baseDir;
+
+    /**
+     * Local settings directory for VuFind installation.
+     *
+     * @var string
+     */
+    protected $overrideDir;
+
+    /**
+     * Hostname of VuFind installation (used for host-based multi-site).
+     *
+     * @var string
+     */
+    protected $host = '';
+
+    /**
+     * Custom local code module name (if any).
+     *
+     * @var string
+     */
+    protected $module = '';
+
+    /**
+     * Active multi-site mode.
+     *
+     * @var int
+     */
+    protected $multisiteMode = self::MULTISITE_NONE;
+
+    /**
+     * Base path for VuFind URLs.
+     *
+     * @var string
+     */
+    protected $basePath = '/vufind';
+
+    /**
+     * Constructor
+     *
+     * @param string|null $name The name of the command; passing null means it must
+     * be set in configure()
+     */
+    public function __construct($name = null)
+    {
+        $this->baseDir = str_replace(
+            '\\', '/', realpath(__DIR__ . '/../../../../../../')
+        );
+        $this->overrideDir = $this->baseDir . '/local';
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('VuFind installer')
+            ->setHelp('Set up (or modify) initial VuFind installation.')
+            ->addOption(
+                'use-defaults',
+                null,
+                InputOption::VALUE_NONE,
+                'Use VuFind defaults to configure '
+                . '(ignores any other arguments passed)'
+            )->addOption(
+                'overridedir',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'Where would you like to store your local settings?'
+                . " (defaults to {$this->overrideDir} when --non-interactive is set)"
+            )->addOption(
+                'module-name',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'What module name would you like to use? Specify "disabled" to skip'
+            )->addOption(
+                'basepath',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'What base path should be used in VuFind\'s URL?'
+                . " (defaults to {$this->baseDir} when --non-interactive is set)"
+            )->addOption(
+                'multisite',
+                null,
+                InputOption::VALUE_OPTIONAL,
+                'Specify we are going to setup a multisite. '
+                . 'Options: directory and host',
+                false
+            )->addOption(
+                'hostname',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'Specify the hostname for the VuFind Site, when multisite=host'
+            )->addOption(
+                'non-interactive',
+                null,
+                InputOption::VALUE_NONE,
+                'Use settings if provided via arguments, otherwise use defaults'
+            );
+    }
+
+    /**
+     * Write file contents to disk.
+     *
+     * @param string $filename Filename
+     * @param string $content  Content
+     *
+     * @return bool
+     */
+    protected function writeFileToDisk($filename, $content)
+    {
+        return @file_put_contents($filename, $content);
+    }
+
+    /**
+     * Get instructions for editing the Apache configuration under Windows.
+     *
+     * @return string
+     */
+    protected function getWindowsApacheMessage()
+    {
+        return "Go to Start -> Apache HTTP Server -> Edit the Apache httpd.conf\n"
+            . "and add this line to your httpd.conf file: \n"
+            . "     Include {$this->overrideDir}/httpd-vufind.conf\n\n"
+            . "If you are using a bundle like XAMPP and do not have this start\n"
+            . "menu option, you should find and edit your httpd.conf file manually\n"
+            . "(usually in a location like c:\\xampp\\apache\\conf).\n";
+    }
+
+    /**
+     * Get instructions for editing the Apache configuration under Linux.
+     *
+     * @return string
+     */
+    protected function getLinuxApacheMessage()
+    {
+        if (is_dir('/etc/httpd/conf.d')) {                      // Mandriva / RedHat
+            $confD = '/etc/httpd/conf.d';
+            $httpdConf = '/etc/httpd/conf/httpd.conf';
+        } elseif (is_dir('/etc/apache2/2.2/conf.d')) {         // Solaris
+            $confD = '/etc/apache2/2.2/conf.d';
+            $httpdConf = '/etc/apache2/2.2/httpd.conf';
+        } elseif (is_dir('/etc/apache2/conf-enabled')) {   // new Ubuntu / OpenSUSE
+            $confD = '/etc/apache2/conf-enabled';
+            $httpdConf = '/etc/apache2/apache2.conf';
+        } elseif (is_dir('/etc/apache2/conf.d')) {         // old Ubuntu / OpenSUSE
+            $confD = '/etc/apache2/conf.d';
+            $httpdConf = '/etc/apache2/httpd.conf';
+        } elseif (is_dir('/opt/local/apache2/conf/extra')) {   // Mac with Mac Ports
+            $confD = '/opt/local/apache2/conf/extra';
+            $httpdConf = '/opt/local/apache2/conf/httpd.conf';
+        } else {
+            $confD = '/path/to/apache/conf.d';
+            $httpdConf = false;
+        }
+
+        // Check if httpd.conf really exists before recommending a specific path;
+        // if missing, just use the generic name:
+        $httpdConf = ($httpdConf && file_exists($httpdConf))
+            ? $httpdConf : 'httpd.conf';
+
+        // Suggest a symlink name based on the local directory, so if running in
+        // multisite mode, we don't use the same symlink for multiple instances:
+        $symlink = basename($this->overrideDir);
+        $symlink = ($symlink == 'local') ? 'vufind' : ('vufind-' . $symlink);
+        $symlink .= '.conf';
+
+        return "You can do it in either of two ways:\n\n"
+            . "    a) Add this line to your {$httpdConf} file:\n"
+            . "       Include {$this->overrideDir}/httpd-vufind.conf\n\n"
+            . "    b) Link the configuration to Apache's config directory like this:"
+            . "\n       ln -s {$this->overrideDir}/httpd-vufind.conf "
+            . "{$confD}/{$symlink}\n"
+            . "\nOption b is preferable if your platform supports it,\n"
+            . "but option a is more certain to be supported.\n";
+    }
+
+    /**
+     * Display system-specific information for where configuration files are found
+     * and/or symbolic links should be created.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return void
+     */
+    protected function getApacheLocation(OutputInterface $output)
+    {
+        // There is one special case for Windows, and a variety of different
+        // Unix-flavored possibilities that all work similarly.
+        $msg = (strtoupper(substr(php_uname('s'), 0, 3)) === 'WIN')
+            ? $this->getWindowsApacheMessage() : $this->getLinuxApacheMessage();
+        $output->writeln($msg);
+    }
+
+    /**
+     * Validate a base path. Returns true on success, message on failure.
+     *
+     * @param string $basePath   String to validate.
+     * @param bool   $allowEmpty Are empty values acceptable?
+     *
+     * @return bool|string
+     */
+    protected function validateBasePath($basePath, $allowEmpty = false)
+    {
+        if ($allowEmpty && empty($basePath)) {
+            return true;
+        }
+        return preg_match('/^\/\w*$/', $basePath)
+            ? true
+            : 'Error: Base path must be alphanumeric and start with a slash.';
+    }
+
+    /**
+     * Get a base path from the user (or return a default).
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return string
+     */
+    protected function getBasePath(InputInterface $input, OutputInterface $output)
+    {
+        // Get VuFind base path:
+        while (true) {
+            $basePathInput = $this->getInput(
+                $input, $output,
+                "What base path should be used in VuFind's URL? [{$this->basePath}] "
+            );
+            if (empty($basePathInput)) {
+                return $this->basePath;
+            } elseif (($result = $this->validateBasePath($basePathInput)) === true) {
+                return $basePathInput;
+            }
+            $output->writeln($result);
+        }
+    }
+
+    /**
+     * Initialize the override directory and report success or failure.
+     *
+     * @param string $dir Path to attempt to initialize
+     *
+     * @return void
+     */
+    protected function initializeOverrideDir($dir)
+    {
+        return $this->buildDirs(
+            [
+                $dir,
+                $dir . '/cache',
+                $dir . '/config',
+                $dir . '/harvest',
+                $dir . '/import'
+            ]
+        );
+    }
+
+    /**
+     * Get an override directory from the user (or return a default).
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return string
+     */
+    protected function getOverrideDir(InputInterface $input, OutputInterface $output)
+    {
+        // Get override directory path:
+        while (true) {
+            $overrideDirInput = $this->getInput(
+                $input, $output,
+                'Where would you like to store your local settings? '
+                . "[{$this->overrideDir}] "
+            );
+            if (empty($overrideDirInput)) {
+                return $this->overrideDir;
+            } elseif (!$this->initializeOverrideDir($overrideDirInput)) {
+                $output->writeln(
+                    "Error: Cannot initialize settings in '$overrideDirInput'.\n"
+                );
+            }
+            return str_replace('\\', '/', realpath($overrideDirInput));
+        }
+    }
+
+    /**
+     * Validate a comma-separated list of module names. Returns true on success,
+     * message on failure.
+     *
+     * @param string $modules Module names to validate.
+     *
+     * @return bool|string
+     */
+    protected function validateModules($modules)
+    {
+        foreach (explode(',', $modules) as $module) {
+            $result = $this->validateModule(trim($module));
+            if ($result !== true) {
+                return $result;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Validate the custom module name. Returns true on success, message on failure.
+     *
+     * @param string $module Module name to validate.
+     *
+     * @return bool|string
+     */
+    protected function validateModule($module)
+    {
+        $regex = '/^[a-zA-Z][0-9a-zA-Z_]*$/';
+        $illegalModules = [
+            'VuFind', 'VuFindAdmin', 'VuFindConsole', 'VuFindDevTools',
+            'VuFindLocalTemplate', 'VuFindSearch', 'VuFindTest', 'VuFindTheme',
+        ];
+        if (in_array($module, $illegalModules)) {
+            return "{$module} is a reserved module name; please try another.";
+        } elseif (empty($module) || preg_match($regex, $module)) {
+            return true;
+        }
+        return "Illegal name: {$module}; please use alphanumeric text.";
+    }
+
+    /**
+     * Get the custom module name from the user (or blank for none).
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return string
+     */
+    protected function getModule(InputInterface $input, OutputInterface $output)
+    {
+        // Get custom module name:
+        $output->writeln(
+            "\nVuFind supports use of a custom module for storing local code "
+            . "changes.\nIf you do not plan to customize the code, you can "
+            . "skip this step.\nIf you decide to use a custom module, the name "
+            . "you choose will be used for\nthe module's directory name and its "
+            . "PHP namespace."
+        );
+        while (true) {
+            $moduleInput = trim(
+                $this->getInput(
+                    $input, $output,
+                    "\nWhat module name would you like to use? [blank for none] "
+                )
+            );
+            if (($result = $this->validateModules($moduleInput)) === true) {
+                return $moduleInput;
+            }
+            $output->writeln("\n$result");
+        }
+    }
+
+    /**
+     * Get the user's preferred multisite mode.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int
+     */
+    protected function getMultisiteMode(InputInterface $input,
+        OutputInterface $output
+    ) {
+        $output->writeln(
+            "\nWhen running multiple VuFind sites against a single installation, you"
+            . " need\nto decide how to distinguish between instances.  Choose an "
+            . "option:\n\n" . self::MULTISITE_DIR_BASED . ".) Directory-based "
+            . "(i.e. http://server/vufind1 vs. http://server/vufind2)\n"
+            . self::MULTISITE_HOST_BASED
+            . ".) Host-based (i.e. http://vufind1.server vs. http://vufind2.server)"
+            . "\n\nor enter " . self::MULTISITE_NONE . " to disable multisite mode."
+        );
+        $legal = [
+            self::MULTISITE_NONE,
+            self::MULTISITE_DIR_BASED,
+            self::MULTISITE_HOST_BASED
+        ];
+        while (true) {
+            $response = $this->getInput(
+                $input, $output, "\nWhich option do you want? "
+            );
+            if (is_numeric($response) && in_array(intval($response), $legal)) {
+                return intval($response);
+            }
+            $output->writeln("Invalid selection.");
+        }
+    }
+
+    /**
+     * Validate the user's hostname input. Returns true on success, message on
+     * failure.
+     *
+     * @param string $host String to check
+     *
+     * @return bool|string
+     */
+    protected function validateHost($host)
+    {
+        // From http://stackoverflow.com/questions/106179/
+        //             regular-expression-to-match-hostname-or-ip-address
+        $valid = "/^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*"
+            . "([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/";
+        return preg_match($valid, $host)
+            ? true
+            : 'Invalid hostname.';
+    }
+
+    /**
+     * Get the user's hostname preference.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return string
+     */
+    protected function getHost(InputInterface $input, OutputInterface $output)
+    {
+        while (true) {
+            $response = $this->getInput(
+                $input, $output, "\nPlease enter the hostname for your site: "
+            );
+            if (($result = $this->validateHost($response)) === true) {
+                return $response;
+            }
+            $output->writeln($result);
+        }
+    }
+
+    /**
+     * Fetch a single line of input from the user.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     * @param string          $prompt Prompt to display to the user.
+     *
+     * @return string        User-entered response.
+     */
+    protected function getInput(InputInterface $input, OutputInterface $output,
+        string $prompt
+    ): string {
+        $question = new Question($prompt, '');
+        return $this->getHelper('question')->ask($input, $output, $question);
+    }
+
+    /**
+     * Generate the Apache configuration. Returns true on success, error message
+     * otherwise.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return bool|string
+     */
+    protected function buildApacheConfig(OutputInterface $output)
+    {
+        $baseConfig = $this->baseDir . '/config/vufind/httpd-vufind.conf';
+        $config = @file_get_contents($baseConfig);
+        if (empty($config)) {
+            return "Problem reading {$baseConfig}.";
+        }
+        $config = str_replace('/usr/local/vufind/local', '%override-dir%', $config);
+        $config = str_replace('/usr/local/vufind', '%base-dir%', $config);
+        $config = preg_replace('|([^/])\/vufind|', '$1%base-path%', $config);
+        $config = str_replace('%override-dir%', $this->overrideDir, $config);
+        $config = str_replace('%base-dir%', $this->baseDir, $config);
+        $config = str_replace('%base-path%', $this->basePath, $config);
+        // Special cases for root basePath:
+        if ('/' == $this->basePath) {
+            $config = str_replace('//', '/', $config);
+            $config = str_replace('Alias /', '#Alias /', $config);
+        }
+        if (!empty($this->module)) {
+            $config = str_replace(
+                "#SetEnv VUFIND_LOCAL_MODULES VuFindLocalTemplate",
+                "SetEnv VUFIND_LOCAL_MODULES {$this->module}", $config
+            );
+        }
+
+        // In multisite mode, we need to make environment variables conditional:
+        switch ($this->multisiteMode) {
+        case self::MULTISITE_DIR_BASED:
+            $config = preg_replace(
+                '/SetEnv\s+(\w+)\s+(.*)/',
+                'SetEnvIf Request_URI "^' . $this->basePath . '" $1=$2',
+                $config
+            );
+            break;
+        case self::MULTISITE_HOST_BASED:
+            if (($result = $this->validateHost($this->host)) !== true) {
+                return $result;
+            }
+            $config = preg_replace(
+                '/SetEnv\s+(\w+)\s+(.*)/',
+                'SetEnvIfNoCase Host ' . str_replace('.', '\.', $this->host)
+                . ' $1=$2',
+                $config
+            );
+            break;
+        }
+
+        $target = $this->overrideDir . '/httpd-vufind.conf';
+        if (file_exists($target)) {
+            $bak = $target . '.bak.' . time();
+            copy($target, $bak);
+            $output->writeln("Backed up existing Apache configuration to $bak.");
+        }
+        return $this->writeFileToDisk($target, $config)
+            ? true : "Problem writing {$this->overrideDir}/httpd-vufind.conf.";
+    }
+
+    /**
+     * Build the Windows-specific startup configuration. Returns true on success,
+     * error message otherwise.
+     *
+     * @return bool|string
+     */
+    protected function buildWindowsConfig()
+    {
+        $module = empty($this->module)
+            ? '' : "@set VUFIND_LOCAL_MODULES={$this->module}\n";
+        $batch = "@set VUFIND_HOME={$this->baseDir}\n"
+            . "@set VUFIND_LOCAL_DIR={$this->overrideDir}\n" . $module;
+        return $this->writeFileToDisk($this->baseDir . '/env.bat', $batch)
+            ? true : "Problem writing {$this->baseDir}/env.bat.";
+    }
+
+    /**
+     * Configure a SolrMarc properties file. Returns true on success, error message
+     * otherwise.
+     *
+     * @param OutputInterface $output   Output object
+     * @param string          $filename The properties file to configure
+     *
+     * @return bool|string
+     */
+    protected function buildImportConfig(OutputInterface $output, $filename)
+    {
+        $target = $this->overrideDir . '/import/' . $filename;
+        if (file_exists($target)) {
+            $output->writeln(
+                "Warning: $target already exists; skipping file creation."
+            );
+            return true;
+        }
+        $import = @file_get_contents($this->baseDir . '/import/' . $filename);
+        $import = str_replace("/usr/local/vufind", $this->baseDir, $import);
+        $import = preg_replace(
+            "/^\s*solrmarc.path\s*=.*$/m",
+            "solrmarc.path = {$this->overrideDir}/import|{$this->baseDir}/import",
+            $import
+        );
+        if (!$this->writeFileToDisk($target, $import)) {
+            return "Problem writing {$this->overrideDir}/import/{$filename}.";
+        }
+        return true;
+    }
+
+    /**
+     * Build a set of directories.
+     *
+     * @param array $dirs Directories to build
+     *
+     * @return bool|string True on success, name of problem directory on failure
+     */
+    protected function buildDirs($dirs)
+    {
+        foreach ($dirs as $dir) {
+            if (!is_dir($dir) && !@mkdir($dir)) {
+                return $dir;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Make sure all modules exist (and create them if they do not). Returns true
+     * on success, error message otherwise.
+     *
+     * @return bool|string
+     */
+    protected function buildModules()
+    {
+        if (!empty($this->module)) {
+            foreach (explode(',', $this->module) as $module) {
+                $moduleDir = $this->baseDir . '/module/' . $module;
+                // Is module missing? If so, create it from the template:
+                if (!file_exists($moduleDir . '/Module.php')) {
+                    if (($result = $this->buildModule($module)) !== true) {
+                        return $result;
+                    }
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Build the module for storing local code changes. Returns true on success,
+     * error message otherwise.
+     *
+     * @param string $module The name of the new module (assumed valid!)
+     *
+     * @return bool|string
+     */
+    protected function buildModule($module)
+    {
+        // Create directories:
+        $moduleDir = $this->baseDir . '/module/' . $module;
+        $dirStatus = $this->buildDirs(
+            [
+                $moduleDir,
+                $moduleDir . '/config',
+                $moduleDir . '/src',
+                $moduleDir . '/src/' . $module
+            ]
+        );
+        if ($dirStatus !== true) {
+            return "Problem creating {$dirStatus}.";
+        }
+
+        // Copy configuration:
+        $configFile = $this->baseDir
+            . '/module/VuFindLocalTemplate/config/module.config.php';
+        $config = @file_get_contents($configFile);
+        if (!$config) {
+            return "Problem reading {$configFile}.";
+        }
+        $success = $this->writeFileToDisk(
+            $moduleDir . '/config/module.config.php',
+            str_replace('VuFindLocalTemplate', $module, $config)
+        );
+        if (!$success) {
+            return "Problem writing {$moduleDir}/config/module.config.php.";
+        }
+
+        // Copy PHP code:
+        $moduleFile = $this->baseDir . '/module/VuFindLocalTemplate/Module.php';
+        $contents = @file_get_contents($moduleFile);
+        if (!$contents) {
+            return "Problem reading {$moduleFile}.";
+        }
+        $success = $this->writeFileToDisk(
+            $moduleDir . '/Module.php',
+            str_replace('VuFindLocalTemplate', $module, $contents)
+        );
+        return $success ? true : "Problem writing {$moduleDir}/Module.php.";
+    }
+
+    /**
+     * Display an error message and return a failure status.
+     *
+     * @param OutputInterface $output Output object
+     * @param string          $msg    Error message
+     * @param int             $status Error status
+     *
+     * @return int
+     */
+    protected function failWithError(OutputInterface $output, string $msg,
+        int $status = 1
+    ): int {
+        $output->writeln($msg);
+        return $status;
+    }
+
+    /**
+     * Display the final message after successful installation.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return void
+     */
+    protected function displaySuccessMessage(OutputInterface $output)
+    {
+        $output->writeln(
+            "Apache configuration written to {$this->overrideDir}/httpd-vufind.conf."
+            . "\n\nYou now need to load this configuration into Apache."
+        );
+        $this->getApacheLocation($output);
+        if (!empty($this->host)) {
+            $output->writeln(
+                "Since you are using a host-based multisite configuration, you will "
+                . "also \nneed to do some virtual host configuration. See\n"
+                . "     http://httpd.apache.org/docs/2.4/vhosts/\n"
+            );
+        }
+        if ('/' == $this->basePath) {
+            $output->writeln(
+                "Since you are installing VuFind at the root of your domain, you "
+                . "will also\nneed to edit your Apache configuration to change "
+                . "DocumentRoot to:\n" . $this->baseDir . "/public\n"
+            );
+        }
+        $output->writeln(
+            "Once the configuration is linked, restart Apache.  You should now be "
+            . "able\nto access VuFind at http://localhost{$this->basePath}\n\nFor "
+            . "proper use of command line tools, you should also ensure that your\n"
+        );
+        $finalMsg = empty($this->addOptionmodule)
+            ? "VUFIND_HOME and VUFIND_LOCAL_DIR environment variables are set to\n"
+            . "{$this->baseDir} and {$this->overrideDir} respectively."
+            : "VUFIND_HOME, VUFIND_LOCAL_MODULES and VUFIND_LOCAL_DIR environment\n"
+            . "variables are set to {$this->baseDir}, {$this->module} and "
+            . "{$this->overrideDir} respectively.";
+        $output->writeln($finalMsg);
+    }
+
+    /**
+     * Collect input parameters, and return a status (0 = proceed, 1 = fail).
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function collectParameters(InputInterface $input,
+        OutputInterface $output
+    ) {
+        // Are we allowing user interaction?
+        $interactive = !$input->getOption('non-interactive');
+        $userInputNeeded = [];
+
+        // Load user settings if we are not forcing defaults:
+        if (!$input->getOption('use-defaults')) {
+            $overrideDir = trim($input->getOption('overridedir'));
+            if (!empty($overrideDir)) {
+                $this->overrideDir = $overrideDir;
+            } elseif ($interactive) {
+                $userInputNeeded['overrideDir'] = true;
+            }
+            $moduleName = trim($input->getOption('module-name'));
+            if (!empty($moduleName) && $moduleName !== 'disabled') {
+                if (($result = $this->validateModules($moduleName)) !== true) {
+                    return $this->failWithError($output, $result);
+                }
+                $this->module = $moduleName;
+            } elseif ($interactive) {
+                $userInputNeeded['module'] = true;
+            }
+
+            $basePath = trim($input->getOption('basepath'));
+            if (!empty($basePath)) {
+                if (($result = $this->validateBasePath($basePath, true)) !== true) {
+                    return $this->failWithError($output, $result);
+                }
+                $this->basePath = $basePath;
+            } elseif ($interactive) {
+                $userInputNeeded['basePath'] = true;
+            }
+
+            // We assume "single site" mode unless the --multisite option is set;
+            // note that $mode will be null if the user provided the option with
+            // no value specified, and false if the user did not provide the option.
+            $mode = $input->getOption('multisite');
+            if ($mode === 'directory') {
+                $this->multisiteMode = self::MULTISITE_DIR_BASED;
+            } elseif ($mode === 'host') {
+                $this->multisiteMode = self::MULTISITE_HOST_BASED;
+            } elseif ($mode !== true && $mode !== null && $mode !== false) {
+                return $this->failWithError(
+                    $output, 'Unexpected multisite mode: ' . $mode
+                );
+            } elseif ($interactive && $mode !== false) {
+                $userInputNeeded['multisiteMode'] = true;
+            }
+
+            // Now that we've validated as many parameters as possible, retrieve
+            // user input where needed.
+            if (isset($userInputNeeded['overrideDir'])) {
+                $this->overrideDir = $this->getOverrideDir($input, $output);
+            }
+            if (isset($userInputNeeded['module'])) {
+                $this->module = $this->getModule($input, $output);
+            }
+            if (isset($userInputNeeded['basePath'])) {
+                $this->basePath = $this->getBasePath($input, $output);
+            }
+            if (isset($userInputNeeded['multisiteMode'])) {
+                $this->multisiteMode = $this->getMultisiteMode($input, $output);
+            }
+
+            // Load supplemental multisite parameters:
+            if ($this->multisiteMode == self::MULTISITE_HOST_BASED) {
+                $hostOption = trim($input->getOption('hostname'));
+                $this->host = (!empty($hostOption) || !$interactive)
+                    ? $hostOption : $this->getHost($input, $output);
+            }
+        }
+
+        // Normalize the module setting to remove whitespace:
+        $this->module = preg_replace('/\s/', '', $this->module);
+
+        return 0;
+    }
+
+    /**
+     * Process collected parameters, and return a status (0 = proceed, 1 = fail).
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function processParameters(OutputInterface $output)
+    {
+        // Make sure the override directory is initialized (using defaults or CLI
+        // parameters will not have initialized it yet; attempt to reinitialize it
+        // here is harmless if it was already initialized in interactive mode):
+        if (!$this->initializeOverrideDir($this->overrideDir)) {
+            return $this->failWithError(
+                $output,
+                "Cannot initialize local override directory: {$this->overrideDir}"
+            );
+        }
+
+        // Build the Windows start file in case we need it:
+        if (($result = $this->buildWindowsConfig()) !== true) {
+            return $this->failWithError($output, $result);
+        }
+
+        // Build the import configuration:
+        foreach (['import.properties', 'import_auth.properties'] as $file) {
+            if (($result = $this->buildImportConfig($output, $file)) !== true) {
+                return $this->failWithError($output, $result);
+            }
+        }
+
+        // Build the custom module(s), if necessary:
+        if (($result = $this->buildModules()) !== true) {
+            return $this->failWithError($output, $result);
+        }
+
+        // Build the final configuration:
+        if (($result = $this->buildApacheConfig($output)) !== true) {
+            return $this->failWithError($output, $result);
+        }
+        return 0;
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $output->writeln("VuFind has been found in {$this->baseDir}.");
+
+        // Collect and process parameters, and stop if an error is encountered
+        // along the way....
+        if ($this->collectParameters($input, $output) !== 0
+            || $this->processParameters($output) !== 0
+        ) {
+            return 1;
+        }
+
+        // Report success:
+        $this->displaySuccessMessage($output);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..5c73eea3028aed9de31b143596049bda1cd8b9db
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommand.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * Abstract base class for language commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+
+/**
+ * Abstract base class for language commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractCommand extends Command
+{
+    /**
+     * Normalizer for .ini files
+     *
+     * @var ExtendedIniNormalizer
+     */
+    protected $normalizer;
+
+    /**
+     * Reader for .ini files
+     *
+     * @var ExtendedIniReader
+     */
+    protected $reader;
+
+    /**
+     * Constructor
+     *
+     * @param ExtendedIniNormalizer $normalizer  Normalizer for .ini files
+     * @param ExtendedIniReader     $reader      Reader for .ini files
+     * @param string                $languageDir Base language file directory
+     * @param string|null           $name        The name of the command; passing
+     * null means it must be set in configure()
+     */
+    public function __construct(ExtendedIniNormalizer $normalizer = null,
+        ExtendedIniReader $reader = null, $languageDir = null, $name = null
+    ) {
+        $this->normalizer = $normalizer ?? new ExtendedIniNormalizer();
+        $this->reader = $reader ?? new ExtendedIniReader();
+        $this->languageDir = $languageDir
+            ?? realpath(__DIR__ . '/../../../../../../languages');
+        parent::__construct($name);
+    }
+
+    /**
+     * Add a line to a language file
+     *
+     * @param string $filename File to update
+     * @param string $key      Name of language key
+     * @param string $value    Value of translation
+     *
+     * @return void
+     */
+    protected function addLineToFile($filename, $key, $value)
+    {
+        $fHandle = fopen($filename, "a");
+        if (!$fHandle) {
+            throw new \Exception('Cannot open ' . $filename . ' for writing.');
+        }
+        fputs($fHandle, "\n$key = \"" . $value . "\"\n");
+        fclose($fHandle);
+    }
+
+    /**
+     * Extract a text domain and key from a raw language key.
+     *
+     * @param string $raw Raw language key
+     *
+     * @return array [textdomain, key]
+     */
+    protected function extractTextDomain($raw)
+    {
+        $parts = explode('::', $raw, 2);
+        return count($parts) > 1 ? $parts : ['default', $raw];
+    }
+
+    /**
+     * Open the language directory as an object using dir(). Return false on
+     * failure.
+     *
+     * @param OutputInterface $output          Output object
+     * @param string          $domain          Text domain to retrieve.
+     * @param bool            $createIfMissing Should we create a missing directory?
+     *
+     * @return object|bool
+     */
+    protected function getLangDir(OutputInterface $output, $domain = 'default',
+        $createIfMissing = false
+    ) {
+        $subDir = $domain == 'default' ? '' : ('/' . $domain);
+        $langDir = $this->languageDir . $subDir;
+        if ($createIfMissing && !is_dir($langDir)) {
+            mkdir($langDir);
+        }
+        $dir = dir(realpath($langDir));
+        if (!$dir) {
+            $output->writeln("Could not open directory $langDir");
+            return false;
+        }
+        return $dir;
+    }
+
+    /**
+     * Create empty files if they do not already exist.
+     *
+     * @param string $path  Directory path
+     * @param array  $files Filenames to create in directory
+     *
+     * @return void
+     */
+    protected function createMissingFiles($path, $files)
+    {
+        foreach ($files as $file) {
+            if (!file_exists($path . '/' . $file)) {
+                file_put_contents($path . '/' . $file, '');
+            }
+        }
+    }
+
+    /**
+     * Process a language directory.
+     *
+     * @param object   $dir            Directory object from dir() to process
+     * @param Callable $callback       Function to run on all .ini files in $dir
+     * @param bool     $statusCallback Callback function to display status messages
+     * (omit to suppress messages)
+     *
+     * @return void
+     */
+    protected function processDirectory($dir, $callback, $statusCallback = false)
+    {
+        while ($file = $dir->read()) {
+            // Only process .ini files, and ignore native.ini special case file:
+            if (substr($file, -4) == '.ini' && $file !== 'native.ini') {
+                if (is_callable($statusCallback)) {
+                    $statusCallback("Processing $file...");
+                }
+                $callback($dir->path . '/' . $file);
+            }
+        }
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..4e3bcfb5d48973eea430310f1bf8ba28f04804d2
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/AbstractCommandFactory.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Shared factory for language commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+
+/**
+ * Shared factory for language commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            new ExtendedIniNormalizer(),
+            new ExtendedIniReader(),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/AddUsingTemplateCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/AddUsingTemplateCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8cd2ed8412ef0012b806767599cd0b48744e1235
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/AddUsingTemplateCommand.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ * Language command: add string using template.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Language command: add string using template.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AddUsingTemplateCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'language/addusingtemplate';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Template-based string builder')
+            ->setHelp(
+                'Builds new language strings from existing ones using a template'
+            )->addArgument(
+                'target',
+                InputArgument::REQUIRED,
+                "the target key to add (may include 'textdomain::' prefix)"
+            )->addArgument(
+                'template',
+                InputArgument::REQUIRED,
+                'the template to build the string, using ||string||'
+                . ' to import existing strings'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $target = $input->getArgument('target');
+        $template = $input->getArgument('template');
+
+        // Make sure a valid target has been specified:
+        list($targetDomain, $targetKey) = $this->extractTextDomain($target);
+        if (!($targetDir = $this->getLangDir($output, $targetDomain, true))) {
+            return 1;
+        }
+
+        // Extract required source values from template:
+        preg_match_all('/\|\|[^|]+\|\|/', $template, $matches);
+        $lookups = [];
+        foreach ($matches[0] as $current) {
+            $key = trim($current, '|');
+            list($sourceDomain, $sourceKey) = $this->extractTextDomain($key);
+            $lookups[$sourceDomain][$current] = [
+                'key' => $sourceKey,
+                'translations' => []
+            ];
+        }
+
+        // Look up translations of all references in template:
+        foreach ($lookups as $domain => & $tokens) {
+            $sourceDir = $this->getLangDir($output, $domain, false);
+            if (!$sourceDir) {
+                return $this->getFailureResponse();
+            }
+            $sourceCallback = function ($full) use ($domain, & $tokens) {
+                $strings = $this->reader->getTextDomain($full, false);
+                foreach ($tokens as & $current) {
+                    $sourceKey = $current['key'];
+                    if (isset($strings[$sourceKey])) {
+                        $current['translations'][basename($full)]
+                            = $strings[$sourceKey];
+                    }
+                }
+            };
+            $this->processDirectory($sourceDir, $sourceCallback, false);
+        }
+
+        // Fill in template, write results:
+        $targetCallback = function ($full) use (
+            $output, $template, $targetKey, $lookups
+        ) {
+            $lang = basename($full);
+            $in = $out = [];
+            foreach ($lookups as $domain => $tokens) {
+                foreach ($tokens as $token => $details) {
+                    if (!isset($details['translations'][$lang])) {
+                        $output->writeln("Skipping; no match for token: $token");
+                        return;
+                    }
+                    $in[] = $token;
+                    $out[] = $details['translations'][$lang];
+                }
+            }
+            $this->addLineToFile(
+                $full, $targetKey, str_replace($in, $out, $template)
+            );
+            $this->normalizer->normalizeFile($full);
+        };
+        $this->processDirectory($targetDir, $targetCallback, [$output, 'writeln']);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/CopyStringCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/CopyStringCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..1701b370d9149c11ed8e3360136db6d7f52a42de
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/CopyStringCommand.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Language command: copy string.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Language command: copy string.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CopyStringCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'language/copystring';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $note = "(may include 'textdomain::' prefix)";
+        $this
+            ->setDescription('String copier')
+            ->setHelp('Copies one language string to another.')
+            ->addArgument(
+                'source',
+                InputArgument::REQUIRED,
+                'the source key to read ' . $note
+            )->addArgument(
+                'target',
+                InputArgument::REQUIRED,
+                'the target key to write ' . $note
+            );
+    }
+
+    /**
+     * Add a line to a language file
+     *
+     * @param string $filename File to update
+     * @param string $key      Name of language key
+     * @param string $value    Value of translation
+     *
+     * @return void
+     */
+    protected function addLineToFile($filename, $key, $value)
+    {
+        $fHandle = fopen($filename, "a");
+        if (!$fHandle) {
+            throw new \Exception('Cannot open ' . $filename . ' for writing.');
+        }
+        fputs($fHandle, "\n$key = \"" . $value . "\"\n");
+        fclose($fHandle);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $source = $input->getArgument('source');
+        $target = $input->getArgument('target');
+
+        list($sourceDomain, $sourceKey) = $this->extractTextDomain($source);
+        list($targetDomain, $targetKey) = $this->extractTextDomain($target);
+
+        if (!($sourceDir = $this->getLangDir($output, $sourceDomain))
+            || !($targetDir = $this->getLangDir($output, $targetDomain, true))
+        ) {
+            return 1;
+        }
+
+        // First, collect the source values from the source text domain:
+        $sources = [];
+        $sourceCallback = function ($full) use ($output, $sourceKey, & $sources) {
+            $strings = $this->reader->getTextDomain($full, false);
+            if (!isset($strings[$sourceKey])) {
+                $output->writeln('Source key not found.');
+            } else {
+                $sources[basename($full)] = $strings[$sourceKey];
+            }
+        };
+        $this->processDirectory($sourceDir, $sourceCallback, [$output, 'writeln']);
+
+        // Make sure that all target files exist:
+        $this->createMissingFiles($targetDir->path, array_keys($sources));
+
+        // Now copy the values to their destination:
+        $targetCallback = function ($full) use ($output, $targetKey, $sources) {
+            if (isset($sources[basename($full)])) {
+                $this->addLineToFile($full, $targetKey, $sources[basename($full)]);
+                $this->normalizer->normalizeFile($full);
+            }
+        };
+        $this->processDirectory($targetDir, $targetCallback, [$output, 'writeln']);
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/DeleteCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/DeleteCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..b95851219f3d1ef8d0dac8c2652ba06637049cb2
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/DeleteCommand.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Language command: add string using template.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Language command: add string using template.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DeleteCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'language/delete';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Delete string tool')
+            ->setHelp(
+                'Removes a language string from all files'
+            )->addArgument(
+                'target',
+                InputArgument::REQUIRED,
+                "the target key to remove (may include 'textdomain::' prefix)"
+            );
+    }
+
+    /**
+     * Write file contents to disk.
+     *
+     * @param string $filename Filename
+     * @param string $content  Content
+     *
+     * @return bool
+     */
+    protected function writeFileToDisk($filename, $content)
+    {
+        return file_put_contents($filename, $content);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $target = $input->getArgument('target');
+
+        list($domain, $key) = $this->extractTextDomain($target);
+        $target = $key . ' = "';
+
+        if (!($dir = $this->getLangDir($output, $domain))) {
+            return 1;
+        }
+        $callback = function ($full) use ($output, $target) {
+            $lines = file($full);
+            $out = '';
+            $found = false;
+            foreach ($lines as $line) {
+                if (substr($line, 0, strlen($target)) !== $target) {
+                    $out .= $line;
+                } else {
+                    $found = true;
+                }
+            }
+            if ($found) {
+                $this->writeFileToDisk($full, $out);
+                $this->normalizer->normalizeFile($full);
+            } else {
+                $output->writeln('Source key not found.');
+            }
+        };
+        $this->processDirectory($dir, $callback, [$output, 'writeln']);
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Language/NormalizeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Language/NormalizeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..218a4e4b4587517bc5838a478d6cb4bf1e99b9ad
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Language/NormalizeCommand.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * Language command: normalize file or directory.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Language;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Language command: normalize file or directory.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class NormalizeCommand extends AbstractCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'language/normalize';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Language file normalizer')
+            ->setHelp(
+                'Normalizes a file or directory of language strings'
+            )->addArgument(
+                'target',
+                InputArgument::REQUIRED,
+                "a file or directory to normalize"
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $target = $input->getArgument('target');
+
+        if (is_dir($target)) {
+            $this->normalizer->normalizeDirectory($target);
+        } elseif (is_file($target)) {
+            $this->normalizer->normalizeFile($target);
+        } else {
+            $output->writeln("{$target} does not exist.");
+            return 1;
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..998130e7544c2a6e62a579708d7a3551f0b0da2f
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Console command plugin manager
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+namespace VuFindConsole\Command;
+
+use Laminas\ServiceManager\Factory\InvokableFactory;
+
+/**
+ * Console command plugin manager
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
+ */
+class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
+{
+    /**
+     * Default plugin aliases.
+     *
+     * @var array
+     */
+    protected $aliases = [
+        'compile/theme' => Compile\ThemeCommand::class,
+        'generate/dynamicroute' => Generate\DynamicRouteCommand::class,
+        'generate/extendclass' => Generate\ExtendClassCommand::class,
+        'generate/extendservice' => Generate\ExtendServiceCommand::class,
+        'generate/nontabrecordaction' => Generate\NonTabRecordActionCommand::class,
+        'generate/plugin' => Generate\PluginCommand::class,
+        'generate/recordroute' => Generate\RecordRouteCommand::class,
+        'generate/staticroute' => Generate\StaticRouteCommand::class,
+        'generate/theme' => Generate\ThemeCommand::class,
+        'generate/thememixin' => Generate\ThemeMixinCommand::class,
+        'harvest/harvest_oai' => Harvest\HarvestOaiCommand::class,
+        'harvest/merge-marc' => Harvest\MergeMarcCommand::class,
+        'import/import-xsl' => Import\ImportXslCommand::class,
+        'import/webcrawl' => Import\WebCrawlCommand::class,
+        'install/install' => Install\InstallCommand::class,
+        'language/addusingtemplate' => Language\AddUsingTemplateCommand::class,
+        'language/copystring' => Language\CopyStringCommand::class,
+        'language/delete' => Language\DeleteCommand::class,
+        'language/normalize' => Language\NormalizeCommand::class,
+        'scheduledsearch/notify' => ScheduledSearch\NotifyCommand::class,
+        'util/cleanuprecordcache' => Util\CleanUpRecordCacheCommand::class,
+        'util/cleanup_record_cache' => Util\CleanUpRecordCacheCommand::class,
+        'util/commit' => Util\CommitCommand::class,
+        'util/createHierarchyTrees' => Util\CreateHierarchyTreesCommand::class,
+        'util/cssBuilder' => Util\CssBuilderCommand::class,
+        'util/dedupe' => Util\DedupeCommand::class,
+        'util/deletes' => Util\DeletesCommand::class,
+        'util/expire_auth_hashes' => Util\ExpireAuthHashesCommand::class,
+        'util/expire_external_sessions' => Util\ExpireExternalSessionsCommand::class,
+        'util/expire_searches' => Util\ExpireSearchesCommand::class,
+        'util/expire_sessions' => Util\ExpireSessionsCommand::class,
+        'util/index_reserves' => Util\IndexReservesCommand::class,
+        'util/lint_marc' => Util\LintMarcCommand::class,
+        'util/optimize' => Util\OptimizeCommand::class,
+        'util/sitemap' => Util\SitemapCommand::class,
+        'util/suppressed' => Util\SuppressedCommand::class,
+        'util/switch_db_hash' => Util\SwitchDbHashCommand::class,
+    ];
+
+    /**
+     * Default plugin factories.
+     *
+     * @var array
+     */
+    protected $factories = [
+        Compile\ThemeCommand::class => Compile\ThemeCommandFactory::class,
+        Generate\DynamicRouteCommand::class =>
+            Generate\AbstractRouteCommandFactory::class,
+        Generate\ExtendClassCommand::class =>
+            Generate\AbstractContainerAwareCommandFactory::class,
+        Generate\ExtendServiceCommand::class =>
+            Generate\AbstractCommandFactory::class,
+        Generate\NonTabRecordActionCommand::class =>
+            Generate\NonTabRecordActionCommandFactory::class,
+        Generate\PluginCommand::class =>
+            Generate\AbstractContainerAwareCommandFactory::class,
+        Generate\RecordRouteCommand::class =>
+            Generate\AbstractRouteCommandFactory::class,
+        Generate\StaticRouteCommand::class =>
+            Generate\AbstractRouteCommandFactory::class,
+        Generate\ThemeCommand::class =>
+            Generate\ThemeCommandFactory::class,
+        Generate\ThemeMixinCommand::class =>
+            Generate\ThemeMixinCommandFactory::class,
+        Harvest\MergeMarcCommand::class => InvokableFactory::class,
+        Harvest\HarvestOaiCommand::class => Harvest\HarvestOaiCommandFactory::class,
+        Import\ImportXslCommand::class => Import\ImportXslCommandFactory::class,
+        Import\WebCrawlCommand::class => Import\WebCrawlCommandFactory::class,
+        Install\InstallCommand::class => InvokableFactory::class,
+        Language\AddUsingTemplateCommand::class =>
+            Language\AbstractCommandFactory::class,
+        Language\CopyStringCommand::class => Language\AbstractCommandFactory::class,
+        Language\DeleteCommand::class => Language\AbstractCommandFactory::class,
+        Language\NormalizeCommand::class => Language\AbstractCommandFactory::class,
+        ScheduledSearch\NotifyCommand::class =>
+            ScheduledSearch\NotifyCommandFactory::class,
+        Util\CleanUpRecordCacheCommand::class =>
+            Util\CleanUpRecordCacheCommandFactory::class,
+        Util\CommitCommand::class => Util\AbstractSolrCommandFactory::class,
+        Util\CreateHierarchyTreesCommand::class =>
+        Util\CreateHierarchyTreesCommandFactory::class,
+        Util\CssBuilderCommand::class => Util\CssBuilderCommandFactory::class,
+        Util\DedupeCommand::class => InvokableFactory::class,
+        Util\DeletesCommand::class => Util\AbstractSolrCommandFactory::class,
+        Util\ExpireAuthHashesCommand::class =>
+            Util\ExpireAuthHashesCommandFactory::class,
+        Util\ExpireExternalSessionsCommand::class =>
+            Util\ExpireExternalSessionsCommandFactory::class,
+        Util\ExpireSearchesCommand::class =>
+            Util\ExpireSearchesCommandFactory::class,
+        Util\ExpireSessionsCommand::class =>
+            Util\ExpireSessionsCommandFactory::class,
+        Util\IndexReservesCommand::class =>
+            Util\AbstractSolrAndIlsCommandFactory::class,
+        Util\LintMarcCommand::class => InvokableFactory::class,
+        Util\OptimizeCommand::class => Util\AbstractSolrCommandFactory::class,
+        Util\SitemapCommand::class => Util\SitemapCommandFactory::class,
+        Util\SuppressedCommand::class =>
+            Util\AbstractSolrAndIlsCommandFactory::class,
+        Util\SwitchDbHashCommand::class => Util\SwitchDbHashCommandFactory::class,
+    ];
+
+    /**
+     * Constructor
+     *
+     * Make sure plugins are properly initialized.
+     *
+     * @param mixed $configOrContainerInstance Configuration or container instance
+     * @param array $v3config                  If $configOrContainerInstance is a
+     * container, this value will be passed to the parent constructor.
+     */
+    public function __construct($configOrContainerInstance = null,
+        array $v3config = []
+    ) {
+        //$this->addAbstractFactory(PluginFactory::class);
+        parent::__construct($configOrContainerInstance, $v3config);
+    }
+
+    /**
+     * Get a list of all available commands in the plugin manager.
+     *
+     * @return array
+     */
+    public function getCommandList()
+    {
+        return array_keys($this->factories);
+    }
+
+    /**
+     * Return the name of the base class or interface that plug-ins must conform
+     * to.
+     *
+     * @return string
+     */
+    protected function getExpectedInterface()
+    {
+        return \Symfony\Component\Console\Command\Command::class;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/RelativeFileAwareCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/RelativeFileAwareCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..882e0e249c9c0f8dd68bdbed7f9bc1cb20202296
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/RelativeFileAwareCommand.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Abstract base class for commands that take relative paths as parameters.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command;
+
+use Symfony\Component\Console\Command\Command;
+
+/**
+ * Abstract base class for commands that take relative paths as parameters.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class RelativeFileAwareCommand extends Command
+{
+    /**
+     * Constructor
+     *
+     * @param string|null $name The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct($name = null)
+    {
+        // Switch the context back to the original working directory so that
+        // relative paths work as expected. (This constant is set in
+        // public/index.php)
+        if (defined('ORIGINAL_WORKING_DIRECTORY')) {
+            chdir(ORIGINAL_WORKING_DIRECTORY);
+        }
+
+        parent::__construct($name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/ScheduledSearchController.php b/module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommand.php
similarity index 75%
rename from module/VuFindConsole/src/VuFindConsole/Controller/ScheduledSearchController.php
rename to module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommand.php
index dafeb2889573e80f1bec8ba1b3bedba3ea9c408a..827575c20727b976942f98b6ee761fd3d742b999 100644
--- a/module/VuFindConsole/src/VuFindConsole/Controller/ScheduledSearchController.php
+++ b/module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommand.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * CLI Controller Module (scheduled search tools)
+ * Console command: notify users of scheduled searches.
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2019.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -20,35 +20,53 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
  *
  * @category VuFind
- * @package  Controller
- * @author   Samuli Sillanpää <samuli.sillanpaa@helsinki.fi>
- * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @package  Console
  * @author   Demian Katz <demian.katz@villanova.edu>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ * @link     https://vufind.org/wiki/development Wiki
  */
-namespace VuFindConsole\Controller;
+namespace VuFindConsole\Command\ScheduledSearch;
 
-use Laminas\Console\Console;
-use Laminas\ServiceManager\ServiceLocatorInterface;
+use Laminas\Config\Config;
+use Laminas\View\Renderer\PhpRenderer;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Crypt\HMAC;
+use VuFind\Db\Table\Search as SearchTable;
+use VuFind\Db\Table\User as UserTable;
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\Mailer\Mailer;
+use VuFind\Search\Results\PluginManager as ResultsManager;
 
 /**
- * CLI Controller Module (scheduled search tools)
+ * Console command: notify users of scheduled searches.
  *
  * @category VuFind
- * @package  Controller
- * @author   Samuli Sillanpää <samuli.sillanpaa@helsinki.fi>
- * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @package  Console
  * @author   Demian Katz <demian.katz@villanova.edu>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ * @link     https://vufind.org/wiki/development Wiki
  */
-class ScheduledSearchController extends AbstractBase
-    implements \VuFind\I18n\Translator\TranslatorAwareInterface
+class NotifyCommand extends Command implements TranslatorAwareInterface
 {
     use \VuFind\I18n\Translator\TranslatorAwareTrait;
     use \VuFind\I18n\Translator\LanguageInitializerTrait;
 
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'scheduledsearch/notify';
+
+    /**
+     * Output interface
+     *
+     * @var OutputInterface
+     */
+    protected $output = null;
+
     /**
      * Useful date format value
      *
@@ -59,14 +77,14 @@ class ScheduledSearchController extends AbstractBase
     /**
      * HMAC generator
      *
-     * @var \VuFind\Crypt\HMAC
+     * @var HMAC
      */
     protected $hmac;
 
     /**
      * View renderer
      *
-     * @var \Laminas\View\Renderer\PhpRenderer
+     * @var PhpRenderer
      */
     protected $renderer;
 
@@ -80,7 +98,7 @@ class ScheduledSearchController extends AbstractBase
     /**
      * Search results plugin manager
      *
-     * @var \VuFind\Search\Results\PluginManager
+     * @var ResultsManager
      */
     protected $resultsManager;
 
@@ -94,7 +112,7 @@ class ScheduledSearchController extends AbstractBase
     /**
      * Top-level VuFind configuration
      *
-     * @var \Laminas\Config\Config
+     * @var Config
      */
     protected $mainConfig;
 
@@ -108,42 +126,64 @@ class ScheduledSearchController extends AbstractBase
     /**
      * Mail service
      *
-     * @var \VuFind\Mailer\Mailer
+     * @var Mailer
      */
     protected $mailer;
 
     /**
-     * Constructor
+     * Search table
      *
-     * @param ServiceLocatorInterface $sm Service locator
+     * @var SearchTable
      */
-    public function __construct(ServiceLocatorInterface $sm)
-    {
-        parent::__construct($sm);
+    protected $searchTable;
 
-        $this->hmac = $sm->get(\VuFind\Crypt\HMAC::class);
-        $this->renderer = $sm->get('ViewRenderer');
-        $this->urlHelper = $this->renderer->plugin('url');
-        $this->resultsManager = $sm->get(
-            \VuFind\Search\Results\PluginManager::class
-        );
-        $this->scheduleOptions = $sm
-            ->get(\VuFind\Search\History::class)
-            ->getScheduleOptions();
-        $this->mainConfig = $sm->get(\VuFind\Config\PluginManager::class)
-            ->get('config');
-        $this->mailer = $sm->get(\VuFind\Mailer\Mailer::class);
+    /**
+     * User table
+     *
+     * @var UserTable
+     */
+    protected $userTable;
+
+    /**
+     * Constructor
+     *
+     * @param HMAC           $hmac            HMAC generator
+     * @param PhpRenderer    $renderer        View renderer
+     * @param ResultsManager $resultsManager  Search results plugin manager
+     * @param array          $scheduleOptions Configured schedule options
+     * @param Config         $mainConfig      Top-level VuFind configuration
+     * @param Mailer         $mailer          Mail service
+     * @param SearchTable    $searchTable     Search table
+     * @param UserTable      $userTable       User table
+     * @param string|null    $name            The name of the command; passing
+     * null means it must be set in configure()
+     */
+    public function __construct(HMAC $hmac, PhpRenderer $renderer,
+        ResultsManager $resultsManager, array $scheduleOptions, Config $mainConfig,
+        Mailer $mailer, SearchTable $searchTable, UserTable $userTable, $name = null
+    ) {
+        $this->hmac = $hmac;
+        $this->renderer = $renderer;
+        $this->urlHelper = $renderer->plugin('url');
+        $this->resultsManager = $resultsManager;
+        $this->scheduleOptions = $scheduleOptions;
+        $this->mainConfig = $mainConfig;
+        $this->mailer = $mailer;
+        $this->searchTable = $searchTable;
+        $this->userTable = $userTable;
+        parent::__construct($name);
     }
 
     /**
-     * Send notifications.
+     * Configure the command.
      *
-     * @return \Laminas\Console\Response
+     * @return void
      */
-    public function notifyAction()
+    protected function configure()
     {
-        $this->processViewAlerts();
-        return $this->getSuccessResponse();
+        $this
+            ->setDescription('Scheduled Search Notifier')
+            ->setHelp('Sends scheduled search email notifications.');
     }
 
     /**
@@ -155,7 +195,9 @@ class ScheduledSearchController extends AbstractBase
      */
     protected function msg($msg)
     {
-        Console::writeLine($msg);
+        if (null !== $this->output) {
+            $this->output->writeln($msg);
+        }
     }
 
     /**
@@ -167,7 +209,7 @@ class ScheduledSearchController extends AbstractBase
      */
     protected function warn($msg)
     {
-        Console::writeLine('WARNING: ' . $msg);
+        $this->msg('WARNING: ' . $msg);
     }
 
     /**
@@ -179,7 +221,22 @@ class ScheduledSearchController extends AbstractBase
      */
     protected function err($msg)
     {
-        Console::writeLine('ERROR: ' . $msg);
+        $this->msg('ERROR: ' . $msg);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $this->output = $output;
+        $this->processViewAlerts();
+        return 0;
     }
 
     /**
@@ -226,7 +283,8 @@ class ScheduledSearchController extends AbstractBase
         static $user = false;
 
         if ($user === false || $s->user_id != $user->id) {
-            if (!$user = $this->getTable('user')->getById($s->user_id)) {
+            if (!$user = $this->userTable->getById($s->user_id)) {
+                $user = false;  // make sure static variable is cleared
                 $this->warn(
                     'Search ' . $s->id . ': user ' . $s->user_id
                     . ' does not exist '
@@ -254,14 +312,12 @@ class ScheduledSearchController extends AbstractBase
     protected function setLanguage($userLang)
     {
         // Start with default language setting; override with user language
-        // preference if set and valid.
-        $language = $this->mainConfig->Site->language;
-        if ($userLang != ''
-            && in_array(
-                $userLang,
-                array_keys($this->mainConfig->Languages->toArray())
-            )
-        ) {
+        // preference if set and valid. Default to English if configuration
+        // is missing.
+        $language = $this->mainConfig->Site->language ?? 'en';
+        $allLanguages = isset($this->mainConfig->Languages)
+            ? array_keys($this->mainConfig->Languages->toArray()) : ['en'];
+        if ($userLang != '' && in_array($userLang, $allLanguages)) {
             $language = $userLang;
         }
         $this->translator->setLocale($language);
@@ -436,7 +492,7 @@ class ScheduledSearchController extends AbstractBase
     protected function processViewAlerts()
     {
         $todayTime = new \DateTime();
-        $scheduled = $this->getTable('search')->getScheduledSearches();
+        $scheduled = $this->searchTable->getScheduledSearches();
         $this->msg(sprintf('Processing %d searches', count($scheduled)));
         foreach ($scheduled as $s) {
             $lastTime = new \DateTime($s->last_notification_sent);
@@ -459,7 +515,7 @@ class ScheduledSearchController extends AbstractBase
             }
             $searchTime = date('Y-m-d H:i:s');
             if ($s->setLastExecuted($searchTime) === 0) {
-                $this->err("Error updating last_executed date for search $searchId");
+                $this->err("Error updating last_executed date for search {$s->id}");
             }
         }
         $this->msg('Done processing searches');
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..69dcbf5a17db465ec64c5287f8410c2b3cf63f54
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/ScheduledSearch/NotifyCommandFactory.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Factory for ScheduledSearch/Notify command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\ScheduledSearch;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for ScheduledSearch/Notify command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class NotifyCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $scheduleOptions = $container
+            ->get(\VuFind\Search\History::class)
+            ->getScheduleOptions();
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        $mainConfig = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+
+        // We need to initialize the theme so that the view renderer works:
+        $theme = new \VuFindTheme\Initializer($mainConfig->Site, $container);
+        $theme->init();
+
+        // Now build the object:
+        return new $requestedName(
+            $container->get(\VuFind\Crypt\HMAC::class),
+            $container->get('ViewRenderer'),
+            $container->get(\VuFind\Search\Results\PluginManager::class),
+            $scheduleOptions,
+            $mainConfig,
+            $container->get(\VuFind\Mailer\Mailer::class),
+            $tableManager->get(\VuFind\Db\Table\Search::class),
+            $tableManager->get(\VuFind\Db\Table\User::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractExpireCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractExpireCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..84331687975742cc8a828c58f47f2f4305c52543
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractExpireCommand.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * Generic base class for expiration commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Db\Table\Gateway;
+
+/**
+ * Generic base class for expiration commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractExpireCommand extends Command
+{
+    /**
+     * Help description for the command.
+     *
+     * @var string
+     */
+    protected $commandDescription = 'Expiration tool';
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'rows';
+
+    /**
+     * Minimum (and default) legal age of rows to delete.
+     *
+     * @var int
+     */
+    protected $minAge = 2;
+
+    /**
+     * Table on which to expire rows
+     *
+     * @var Gateway
+     */
+    protected $table;
+
+    /**
+     * Constructor
+     *
+     * @param Gateway     $table Table on which to expire rows
+     * @param string|null $name  The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Gateway $table, $name = null)
+    {
+        foreach (['getExpiredIdRange', 'deleteExpired'] as $method) {
+            if (!method_exists($table, $method)) {
+                $tableName = get_class($table);
+                throw new \Exception("$tableName does not support $method()");
+            }
+        }
+        $this->table = $table;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription($this->commandDescription)
+            ->setHelp("Expires old {$this->rowLabel} in the database.")
+            ->addOption(
+                'batch',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'number of records to delete in a single batch',
+                1000
+            )->addOption(
+                'sleep',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'milliseconds to sleep between batches',
+                100
+            )->addArgument(
+                'age',
+                InputArgument::OPTIONAL,
+                "the age (in days) of {$this->rowLabel} to expire",
+                $this->minAge
+            );
+    }
+
+    /**
+     * Add a time stamp to a message
+     *
+     * @param string $msg Message
+     *
+     * @return string
+     */
+    protected function getTimestampedMessage($msg)
+    {
+        return '[' . date('Y-m-d H:i:s') . '] ' . $msg;
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Collect arguments/options:
+        $daysOld = floatval($input->getArgument('age'));
+        $batchSize = $input->getOption('batch');
+        $sleepTime = $input->getOption('sleep');
+
+        // Abort if we have an invalid expiration age.
+        if ($daysOld < $this->minAge) {
+            $output->writeln(
+                str_replace(
+                    '%%age%%', $this->minAge,
+                    'Expiration age must be at least %%age%% days.'
+                )
+            );
+            return 1;
+        }
+
+        // Delete the expired rows--this cleans up any junk left in the database
+        // e.g. from old searches or sessions that were not caught by the session
+        // garbage collector.
+        $idRange = $this->table->getExpiredIdRange($daysOld);
+        if (false === $idRange) {
+            $output->writeln(
+                $this->getTimestampedMessage("No {$this->rowLabel} to delete.")
+            );
+            return 0;
+        }
+
+        // Delete records in batches
+        for ($batch = $idRange[0]; $batch <= $idRange[1]; $batch += $batchSize) {
+            $count = $this->table->deleteExpired(
+                $daysOld, $batch, $batch + $batchSize - 1
+            );
+            $output->writeln(
+                $this->getTimestampedMessage("{$count} {$this->rowLabel} deleted.")
+            );
+            // Be nice to others and wait between batches
+            if ($batch + $batchSize <= $idRange[1]) {
+                usleep($sleepTime * 1000);
+            }
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..1380538b6c798d794c51fb616b849ba98bad0bd6
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommand.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Generic base class for Solr + ILS commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use VuFind\ILS\Connection;
+use VuFind\Solr\Writer;
+
+/**
+ * Generic base class for Solr + ILS commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractSolrAndIlsCommand extends AbstractSolrCommand
+{
+    /**
+     * ILS connection
+     *
+     * @var Connection
+     */
+    protected $catalog;
+
+    /**
+     * Constructor
+     *
+     * @param Writer      $solr Solr writer
+     * @param Connection  $ils  ILS connection object
+     * @param string|null $name The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Writer $solr, Connection $ils, $name = null)
+    {
+        $this->catalog = $ils;
+        parent::__construct($solr, $name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe0f10102d10a6064b8e4f7360962d49f43a61bf
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrAndIlsCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Solr + ILS commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Solr + ILS commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractSolrAndIlsCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFind\Solr\Writer::class),
+            $container->get(\VuFind\ILS\Connection::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..5438a84d9c655276913cef11f75d6d2dc1a83678
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommand.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Generic base class for Solr commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use VuFind\Solr\Writer;
+use VuFindConsole\Command\RelativeFileAwareCommand;
+
+/**
+ * Generic base class for Solr commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractSolrCommand extends RelativeFileAwareCommand
+{
+    /**
+     * Solr writer
+     *
+     * @var Writer
+     */
+    protected $solr;
+
+    /**
+     * Constructor
+     *
+     * @param Writer      $solr Solr writer
+     * @param string|null $name The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Writer $solr, $name = null)
+    {
+        $this->solr = $solr;
+        parent::__construct($name);
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..e1eb134e13a06018cf15a79f05e5a2d673428113
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/AbstractSolrCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Factory for Solr commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Solr commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AbstractSolrCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFind\Solr\Writer::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..74a9bf05dcd2210b44ca26ec6c2c7ea88fe60097
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommand.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Console command: clean up record cache.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Db\Table\Record;
+
+/**
+ * Console command: clean up record cache.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CleanUpRecordCacheCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/cleanup_record_cache';
+
+    /**
+     * Record table object
+     *
+     * @var Record
+     */
+    protected $recordTable;
+
+    /**
+     * Constructor
+     *
+     * @param Record      $table Record table object
+     * @param string|null $name  The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Record $table, $name = null)
+    {
+        $this->recordTable = $table;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Record cache cleaner')
+            ->setHelp('Removes unused cached records from the database.')
+            ->setAliases(['util/cleanuprecordcache']);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $count = $this->recordTable->cleanup();
+        $output->writeln("$count records deleted.");
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..7037524741ff772ea96316123e4737ec4ce5d385
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CleanUpRecordCacheCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Factory for Util/CleanUpRecordCache command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/CleanUpRecordCache command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CleanUpRecordCacheCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFind\Db\Table\PluginManager::class)->get('Record'),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CommitCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CommitCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8601805e5aa07d18ec43968778e53acf43e3f278
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CommitCommand.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Console command: commit to Solr
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: commit to Solr
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CommitCommand extends AbstractSolrCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/commit';
+
+    /**
+     * The name of the Solr command, for use in help messages.
+     *
+     * @var string
+     */
+    protected $solrCommand = 'commit';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Solr ' . $this->solrCommand . ' tool')
+            ->setHelp('Sends a ' . $this->solrCommand . ' command to a Solr index.')
+            ->addArgument(
+                'core',
+                InputArgument::OPTIONAL,
+                'Name of Solr core to ' . $this->solrCommand,
+                'Solr'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Check time limit; increase if necessary:
+        if (ini_get('max_execution_time') < 3600) {
+            ini_set('max_execution_time', '3600');
+        }
+
+        // Setup Solr Connection -- Allow core to be specified from command line.
+        $core = $input->getArgument('core');
+
+        // Commit to the Solr Index
+        $this->solr->commit($core);
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..f8a863f3b82a1f2669d91f993f0e66038edf91c3
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommand.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * Generic base class for Solr commands.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Record\Loader;
+use VuFind\Search\Results\PluginManager;
+
+/**
+ * Generic base class for Solr commands.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CreateHierarchyTreesCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/createHierarchyTrees';
+
+    /**
+     * Record loader
+     *
+     * @var Loader
+     */
+    protected $recordLoader;
+
+    /**
+     * Search results manager
+     *
+     * @var PluginManager
+     */
+    protected $resultsManager;
+
+    /**
+     * Constructor
+     *
+     * @param Loader        $loader  Record loader
+     * @param PluginManager $results Search results manager
+     * @param string|null   $name    The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Loader $loader, PluginManager $results, $name = null)
+    {
+        $this->recordLoader = $loader;
+        $this->resultsManager = $results;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Cache populator for hierarchies')
+            ->setHelp('Populates the hierarchy tree cache.')
+            ->addArgument(
+                'backend',
+                InputArgument::OPTIONAL,
+                'Search backend, e.g. ' . DEFAULT_SEARCH_BACKEND
+                . ' (default) or Search2',
+                DEFAULT_SEARCH_BACKEND
+            )->addOption(
+                'skip',
+                's',
+                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+                'format(s) to skip caching (x = xml, j = json)'
+            )->addOption(
+                'skip-xml',
+                null,
+                InputOption::VALUE_NONE,
+                'skip the XML cache (synonymous with -sx)'
+            )->addOption(
+                'skip-json',
+                null,
+                InputOption::VALUE_NONE,
+                'skip the JSON cache (synonymous with -sj)'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $skips = $input->getOption('skip') ?? [];
+        $skipJson = $input->getOption('skip-json') || in_array('j', $skips);
+        $skipXml = $input->getOption('skip-xml') || in_array('x', $skips);
+        $backendId = $input->getArgument('backend');
+        $hierarchies = $this->resultsManager->get($backendId)
+            ->getFullFieldFacets(['hierarchy_top_id']);
+        $list = $hierarchies['hierarchy_top_id']['data']['list'] ?? [];
+        foreach ($list as $hierarchy) {
+            $recordid = $hierarchy['value'];
+            $count = $hierarchy['count'];
+            if (empty($recordid)) {
+                continue;
+            }
+            $output->writeln(
+                "\tBuilding tree for " . $recordid . '... '
+                . number_format($count) . ' records'
+            );
+            try {
+                $driver = $this->recordLoader->load($recordid, $backendId);
+                // Only do this if the record is actually a hierarchy type record
+                if ($driver->getHierarchyType()) {
+                    // JSON
+                    if (!$skipJson) {
+                        $output->writeln("\t\tJSON cache...");
+                        $driver->getHierarchyDriver()->getTreeSource()->getJSON(
+                            $recordid, ['refresh' => true]
+                        );
+                    } else {
+                        $output->writeln("\t\tJSON skipped.");
+                    }
+                    // XML
+                    if (!$skipXml) {
+                        $output->writeln("\t\tXML cache...");
+                        $driver->getHierarchyDriver()->getTreeSource()->getXML(
+                            $recordid, ['refresh' => true]
+                        );
+                    } else {
+                        $output->writeln("\t\tXML skipped.");
+                    }
+                }
+            } catch (\VuFind\Exception\RecordMissing $e) {
+                $output->writeln(
+                    'WARNING! - Caught exception: ' . $e->getMessage() . "\n"
+                );
+            }
+        }
+        $output->writeln(
+            count($hierarchies['hierarchy_top_id']['data']['list']) . ' files'
+        );
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..91f1c8d03c6a4af7f5c9b52a2d6d6031582e00f6
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CreateHierarchyTreesCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Util/CreateHierarchyTrees command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/CreateHierarchyTrees command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CreateHierarchyTreesCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFind\Record\Loader::class),
+            $container->get(\VuFind\Search\Results\PluginManager::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..28b650523bf833cd1e78ce12a67f34f6bc5c877b
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommand.php
@@ -0,0 +1,118 @@
+<?php
+/**
+ * Console command: build CSS.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindTheme\LessCompiler;
+
+/**
+ * Console command: build CSS.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CssBuilderCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/cssBuilder';
+
+    /**
+     * Cache directory for compiler
+     *
+     * @var string
+     */
+    protected $cacheDir;
+
+    /**
+     * Constructor
+     *
+     * @param string      $cacheDir Cache directory for compiler
+     * @param string|null $name     The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct($cacheDir, $name = null)
+    {
+        $this->cacheDir = $cacheDir;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('LESS compiler')
+            ->setHelp('Compiles CSS files from LESS.')
+            ->addArgument(
+                'themes',
+                InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
+                'Name of theme(s) to compile; omit to compile everything'
+            );
+    }
+
+    /**
+     * Build the LESS compiler.
+     *
+     * @param OutputInterface $output Output object
+     *
+     * @return LessCompiler
+     */
+    protected function getCompiler(OutputInterface $output)
+    {
+        return new LessCompiler($output);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $compiler = $this->getCompiler($output);
+        $compiler->setTempPath($this->cacheDir);
+        $compiler->compile(array_unique($input->getArgument('themes')));
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..f8664ed2ca4a7bad8bda1312fd8eeba328bdccdb
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/CssBuilderCommandFactory.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Factory for Util/CssBuilder command.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/CssBuilder command.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CssBuilderCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $cacheManager = $container->get(\VuFind\Cache\Manager::class);
+        $cacheDir = $cacheManager->getCacheDir() . 'less/';
+        return new $requestedName($cacheDir, ...($options ?? []));
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/DedupeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/DedupeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..17b6f40d0b99008f742e25e773ad420a700d2800
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/DedupeCommand.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * Console command: deduplicate lines in a sorted file.
+ *
+ * Needed for the Windows version of the alphabetical browse database generator,
+ * since Windows sort does not support deduplication. Assumes presorted input.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\Question;
+use VuFindConsole\Command\RelativeFileAwareCommand;
+
+/**
+ * Console command: deduplicate lines in a sorted file.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DedupeCommand extends RelativeFileAwareCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/dedupe';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Tool for deduplicating lines in a sorted file')
+            ->setHelp('Deduplicates lines in a sorted file.')
+            ->addArgument(
+                'input',
+                InputArgument::OPTIONAL,
+                'the file to deduplicate (omit for interactive prompt).'
+            )->addArgument(
+                'output',
+                InputArgument::OPTIONAL,
+                'the output file (omit for interactive prompt).'
+            );
+    }
+
+    /**
+     * Fetch a single line of input from the user.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     * @param string          $prompt Prompt to display to the user.
+     *
+     * @return string        User-entered response.
+     */
+    protected function getInput(InputInterface $input, OutputInterface $output,
+        string $prompt
+    ): string {
+        $question = new Question($prompt, '');
+        return $this->getHelper('question')->ask($input, $output, $question);
+    }
+
+    /**
+     * Open a file for writing.
+     *
+     * @param string $filename File to open
+     *
+     * @return resource
+     */
+    protected function openOutputFile($filename)
+    {
+        return @fopen($filename, 'w');
+    }
+
+    /**
+     * Write a line to an output file.
+     *
+     * @param resource $handle File handle
+     * @param string   $text   Text to write
+     *
+     * @return void
+     */
+    protected function writeToOutputFile($handle, $text)
+    {
+        fputs($handle, $text);
+    }
+
+    /**
+     * Close a file handle.
+     *
+     * @param resource $handle Handle from openOutputFile()
+     *
+     * @return void
+     */
+    protected function closeOutputFile($handle)
+    {
+        fclose($handle);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $infile = $input->getArgument('input');
+        if (empty($infile)) {
+            $inprompt = 'Please specify an input file: ';
+            $infile = $this->getInput($input, $output, $inprompt);
+        }
+        $inHandle = @fopen($infile, 'r');
+        if (!$inHandle) {
+            $output->writeln('Could not open input file: ' . $infile);
+            return 1;
+        }
+        $outfile = $input->getArgument('output');
+        if (empty($outfile)) {
+            $outprompt = 'Please specify an output file: ';
+            $outfile = $this->getInput($input, $output, $outprompt);
+        }
+        $outHandle = $this->openOutputFile($outfile);
+        if (!$outHandle) {
+            $output->writeln('Could not open output file: ' . $outfile);
+            return 1;
+        }
+
+        $last = '';
+        while ($tmp = fgets($inHandle)) {
+            if ($tmp != $last) {
+                $this->writeToOutputFile($outHandle, $tmp);
+            }
+            $last = $tmp;
+        }
+
+        fclose($inHandle);
+        $this->closeOutputFile($outHandle);
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/DeletesCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/DeletesCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a86d8f48d286eb3329a98b43552f7e16aeeabe8
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/DeletesCommand.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Console command: delete from Solr
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use File_MARC;
+use File_MARCXML;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: delete from Solr
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class DeletesCommand extends AbstractSolrCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/deletes';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Tool for deleting Solr records')
+            ->setHelp('Deletes a set of records from the Solr index.')
+            ->addArgument(
+                'filename',
+                InputArgument::REQUIRED,
+                'the file containing records to delete.'
+            )->addArgument(
+                'format',
+                InputArgument::OPTIONAL,
+                "the format of the file -- it may be one of the following:\n"
+                . "flat - flat text format "
+                . "(deletes all IDs in newline-delimited file)\n"
+                . "marc - binary MARC format (delete all record IDs from 001 "
+                . "fields)\n"
+                . "marcxml - MARC-XML format (delete all record IDs from 001 "
+                . "fields)\n",
+                'marc'
+            )->addArgument(
+                'index',
+                InputArgument::OPTIONAL,
+                'Name of Solr core/backend to update',
+                'Solr'
+            );
+    }
+
+    /**
+     * Load IDs from a flat file.
+     *
+     * @param string $filename Filename to load from
+     *
+     * @return array
+     */
+    protected function getIdsFromFlatFile(string $filename): array
+    {
+        $ids = [];
+        foreach (array_map('trim', file($filename)) as $id) {
+            if (strlen($id)) {
+                $ids[] = $id;
+            }
+        }
+        return $ids;
+    }
+
+    /**
+     * Load IDs from a MARC file
+     *
+     * @param string          $filename MARC file
+     * @param string          $mode     Type of file (marc or marcxml)
+     * @param OutputInterface $output   Output object
+     *
+     * @return array
+     */
+    protected function getIdsFromMarcFile(string $filename, string $mode,
+        OutputInterface $output
+    ): array {
+        $ids = [];
+        // MARC file mode...  We need to load the MARC record differently if it's
+        // XML or binary:
+        $collection = ($mode == 'marcxml')
+            ? new File_MARCXML($filename) : new File_MARC($filename);
+
+        // Once the records are loaded, the rest of the logic is always the same:
+        $missingIdCount = 0;
+        while ($record = $collection->next()) {
+            $idField = $record->getField('001');
+            if ($idField) {
+                $ids[] = (string)$idField->getData();
+            } else {
+                $missingIdCount++;
+            }
+        }
+        if ($output->isVerbose() && $missingIdCount) {
+            $output->writeln(
+                "Encountered $missingIdCount record(s) without IDs."
+            );
+        }
+        return $ids;
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $filename = $input->getArgument('filename');
+        $mode = $input->getArgument('format');
+        $index = $input->getArgument('index');
+
+        // File doesn't exist?
+        if (!file_exists($filename)) {
+            $output->writeln("Cannot find file: {$filename}");
+            return 1;
+        }
+
+        $output->writeln(
+            "Loading IDs in {$mode} mode.", OutputInterface::VERBOSITY_VERBOSE
+        );
+
+        // Build list of records to delete:
+        $ids = ($mode == 'flat')
+            ? $this->getIdsFromFlatFile($filename)
+            : $this->getIdsFromMarcFile($filename, $mode, $output);
+
+        // Delete, Commit and Optimize if necessary:
+        if (!empty($ids)) {
+            $output->writeln(
+                'Attempting to delete ' . count($ids) . ' record(s): '
+                . implode(', ', $ids), OutputInterface::VERBOSITY_VERBOSE
+            );
+            $this->solr->deleteRecords($index, $ids);
+            $output->writeln(
+                'Delete operation completed.', OutputInterface::VERBOSITY_VERBOSE
+            );
+        } elseif ($output->isVerbose()) {
+            $output->writeln('Nothing to delete.');
+        }
+
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..637199d09fdc3aac57e63632f5d8db7472080dbe
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommand.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Console command: expire authentication hashes.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+/**
+ * Console command: expire authentication hashes.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireAuthHashesCommand extends AbstractExpireCommand
+{
+    /**
+     * Help description for the command.
+     *
+     * @var string
+     */
+    protected $commandDescription = 'Database auth_hash table cleanup';
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'authentication hashes';
+
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/expire_auth_hashes';
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..70151fe7895c51194bd7f322a602a1b91fbcf6ef
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuthHashesCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Util/ExpireAuthHashesCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/ExpireAuthHashesCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireAuthHashesCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        return new $requestedName(
+            $tableManager->get(\VuFind\Db\Table\AuthHash::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..33086d75cb04a1af0ca823b00ec4c96b33ecbed9
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommand.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Console command: expire sessions.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+/**
+ * Console command: expire sessions.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireExternalSessionsCommand extends AbstractExpireCommand
+{
+    /**
+     * Help description for the command.
+     *
+     * @var string
+     */
+    protected $commandDescription = 'Database external_session table cleanup';
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'external sessions';
+
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/expire_external_sessions';
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..adf0d3c708bd33d1137c86e8d76c605bccde1312
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireExternalSessionsCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Util/ExpireExternalSessionsCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/ExpireExternalSessionsCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireExternalSessionsCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        return new $requestedName(
+            $tableManager->get(\VuFind\Db\Table\ExternalSession::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..3661c9ddef6618775ebd71b66b7190e9ffbea0c8
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommand.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Console command: expire searches.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+/**
+ * Console command: expire searches.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireSearchesCommand extends AbstractExpireCommand
+{
+    /**
+     * Help description for the command.
+     *
+     * @var string
+     */
+    protected $commandDescription = 'Database search table cleanup';
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'searches';
+
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/expire_searches';
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..732752aa34beee305438f26cf709001706d3ba88
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSearchesCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Util/ExpireSearchesCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/ExpireSearchesCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireSearchesCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        return new $requestedName(
+            $tableManager->get(\VuFind\Db\Table\Search::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..6cf94a974633797648f2d9f6418a579cdf0e37df
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommand.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * Console command: expire sessions.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+/**
+ * Console command: expire sessions.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireSessionsCommand extends AbstractExpireCommand
+{
+    /**
+     * Help description for the command.
+     *
+     * @var string
+     */
+    protected $commandDescription = 'Database session table cleanup';
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'sessions';
+
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/expire_sessions';
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..314e10090452326f188b2ec51e60b510b4969213
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireSessionsCommandFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Util/ExpireSessionsCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/ExpireSessionsCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ExpireSessionsCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        return new $requestedName(
+            $tableManager->get(\VuFind\Db\Table\Session::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/IndexReservesCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/IndexReservesCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..12a474369aec0bbdf0d4ca4ef37f16c54754bcf4
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/IndexReservesCommand.php
@@ -0,0 +1,259 @@
+<?php
+/**
+ * Console command: index course reserves into Solr.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Reserves\CsvReader;
+use VuFindSearch\Backend\Solr\Document\UpdateDocument;
+use VuFindSearch\Backend\Solr\Record\SerializableRecord;
+
+/**
+ * Console command: index course reserves into Solr.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class IndexReservesCommand extends AbstractSolrAndIlsCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/index_reserves';
+
+    /**
+     * Default delimiter for reading files
+     *
+     * @var string
+     */
+    protected $defaultDelimiter = ',';
+
+    /**
+     * Default template for reading files
+     *
+     * @var string
+     */
+    protected $defaultTemplate = 'BIB_ID,COURSE,INSTRUCTOR,DEPARTMENT';
+
+    /**
+     * Keys required in the data to create a valid reserves index.
+     *
+     * @var string[]
+     */
+    protected $requiredKeys = ['INSTRUCTOR_ID', 'COURSE_ID', 'DEPARTMENT_ID'];
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Course reserves index builder')
+            ->setHelp(
+                'This tool populates your course reserves Solr index. If run with'
+                . ' no options, it will attempt to load data from your ILS.'
+                . ' Switches may be used to index from delimited files instead.'
+            )->addOption(
+                'filename',
+                'f',
+                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+                'file(s) containing delimited values'
+            )->addOption(
+                'delimiter',
+                'd',
+                InputOption::VALUE_REQUIRED,
+                'specifies the delimiter used in file(s)',
+                $this->defaultDelimiter
+            )->addOption(
+                'template',
+                't',
+                InputOption::VALUE_REQUIRED,
+                'provides a template showing where important values can be found '
+                . "within the file.\nThe template is a comma-separated list of "
+                . "values.  Choose from:\n"
+                . "BIB_ID     - bibliographic ID\n"
+                . "COURSE     - course name\n"
+                . "DEPARTMENT - department name\n"
+                . "INSTRUCTOR - instructor name\n"
+                . "SKIP       - ignore data in this position\n",
+                $this->defaultTemplate
+            );
+    }
+
+    /**
+     * Build the reserves index from date returned by the ILS driver,
+     * specifically: getInstructors, getDepartments, getCourses, findReserves
+     *
+     * @param array $instructors Array of instructors $instructor_id => $instructor
+     * @param array $courses     Array of courses     $course_id => $course
+     * @param array $departments Array of department  $dept_id => $department
+     * @param array $reserves    Array of reserves records from driver's
+     * findReserves.
+     *
+     * @return UpdateDocument
+     */
+    protected function buildReservesIndex($instructors, $courses, $departments,
+        $reserves
+    ) {
+        foreach ($reserves as $record) {
+            $requiredKeysFound
+                = count(array_intersect(array_keys($record), $this->requiredKeys));
+            if ($requiredKeysFound < count($this->requiredKeys)) {
+                throw new \Exception(
+                    implode(' and/or ', $this->requiredKeys) . ' fields ' .
+                    'not present in reserve records. Please update ILS driver.'
+                );
+            }
+            $instructorId = $record['INSTRUCTOR_ID'];
+            $courseId = $record['COURSE_ID'];
+            $departmentId = $record['DEPARTMENT_ID'];
+            $id = $courseId . '|' . $instructorId . '|' . $departmentId;
+
+            if (!isset($index[$id])) {
+                $index[$id] = [
+                    'id' => $id,
+                    'bib_id' => [],
+                    'instructor_id' => $instructorId,
+                    'instructor' => $instructors[$instructorId] ?? '',
+                    'course_id' => $courseId,
+                    'course' => $courses[$courseId] ?? '',
+                    'department_id' => $departmentId,
+                    'department' => $departments[$departmentId] ?? ''
+                ];
+            }
+            $index[$id]['bib_id'][] = $record['BIB_ID'];
+        }
+
+        $updates = new UpdateDocument();
+        foreach ($index as $id => $data) {
+            if (!empty($data['bib_id'])) {
+                $updates->addRecord(new SerializableRecord($data));
+            }
+        }
+        return $updates;
+    }
+
+    /**
+     * Construct a CSV reader.
+     *
+     * @param array|string $files     Array of files to load (or single filename).
+     * @param string       $delimiter Delimiter used by file(s).
+     * @param string       $template  Template showing field positions within
+     * file(s).  Comma-separated list containing BIB_ID, INSTRUCTOR, COURSE,
+     * DEPARTMENT and/or SKIP.  Default = BIB_ID,COURSE,INSTRUCTOR,DEPARTMENT
+     *
+     * @return CsvReader
+     */
+    protected function getCsvReader($files, string $delimiter,
+        string $template
+    ): CsvReader {
+        return new CsvReader($files, $delimiter, $template);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Check time limit; increase if necessary:
+        if (ini_get('max_execution_time') < 3600) {
+            ini_set('max_execution_time', '3600');
+        }
+
+        $delimiter = $input->getOption('delimiter');
+        $template = $input->getOption('template');
+
+        if ($file = $input->getOption('filename')) {
+            try {
+                $reader = $this->getCsvReader($file, $delimiter, $template);
+                $instructors = $reader->getInstructors();
+                $courses = $reader->getCourses();
+                $departments = $reader->getDepartments();
+                $reserves = $reader->getReserves();
+            } catch (\Exception $e) {
+                $output->writeln($e->getMessage());
+                return 1;
+            }
+        } elseif ($delimiter !== $this->defaultDelimiter) {
+            $output->writeln('-d (delimiter) is meaningless without -f (filename)');
+            return 1;
+        } elseif ($template !== $this->defaultTemplate) {
+            $output->writeln('-t (template) is meaningless without -f (filename)');
+            return 1;
+        } else {
+            try {
+                // Connect to ILS and load data:
+                $instructors = $this->catalog->getInstructors();
+                $courses = $this->catalog->getCourses();
+                $departments = $this->catalog->getDepartments();
+                $reserves = $this->catalog->findReserves('', '', '');
+            } catch (\Exception $e) {
+                $output->writeln($e->getMessage());
+                return 1;
+            }
+        }
+
+        // Make sure we have reserves and at least one of: instructors, courses,
+        // departments:
+        if ((!empty($instructors) || !empty($courses) || !empty($departments))
+            && !empty($reserves)
+        ) {
+            // Delete existing records
+            $this->solr->deleteAll('SolrReserves');
+
+            // Build and Save the index
+            $index = $this->buildReservesIndex(
+                $instructors, $courses, $departments, $reserves
+            );
+            $this->solr->save('SolrReserves', $index);
+
+            // Commit and Optimize the Solr Index
+            $this->solr->commit('SolrReserves');
+            $this->solr->optimize('SolrReserves');
+
+            $output->writeln('Successfully loaded ' . count($reserves) . ' rows.');
+            return 0;
+        }
+        $output->writeln('Unable to load data.');
+        return 1;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/LintMarcCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/LintMarcCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..8ec5a11f3cdd0c5ce77bb1f87e242b49cba7040f
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/LintMarcCommand.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Console command: Lint MARC records.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFindConsole\Command\RelativeFileAwareCommand;
+
+/**
+ * Console command: Lint MARC records.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class LintMarcCommand extends RelativeFileAwareCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/lint_marc';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('MARC validator')
+            ->setHelp('This command lets you validate MARC file contents.')
+            ->addArgument('filename', InputArgument::REQUIRED, 'MARC filename');
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $filename = $input->getArgument('filename');
+        $marc = substr($filename, -3) !== 'xml'
+            ? new \File_MARC($filename) : new \File_MARCXML($filename);
+        $linter = new \File_MARC_Lint();
+        $i = 0;
+        while ($record = $marc->next()) {
+            $i++;
+            $field001 = $record->getField('001');
+            $field001 = $field001 ? (string)$field001->getData() : 'undefined';
+            $output->writeln("Checking record $i (001 = $field001)...");
+            $warnings = $linter->checkRecord($record);
+            if (count($warnings) > 0) {
+                $output->writeln('Warnings: ' . implode("\n", $warnings));
+            }
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/OptimizeCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/OptimizeCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..d246b4ad4bdc832c6c8e426b549bc43c07d9d9ba
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/OptimizeCommand.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Console command: optimize Solr index
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: optimize Solr index
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class OptimizeCommand extends CommitCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/optimize';
+
+    /**
+     * The name of the Solr command, for use in help messages.
+     *
+     * @var string
+     */
+    protected $solrCommand = 'optimize';
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Optimize is the same as commit (parent class) but with an extra step:
+        $result = parent::execute($input, $output);
+        $core = $input->getArgument('core');
+        $this->solr->optimize($core);
+        return $result;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..9698204583f97a29cde00538f90fbe2b5e3f8757
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommand.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Console command: generate sitemaps
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Sitemap\Generator;
+
+/**
+ * Console command: generate sitemaps
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SitemapCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/sitemap';
+
+    /**
+     * Sitemap generator
+     *
+     * @var Generator
+     */
+    protected $generator;
+
+    /**
+     * Constructor
+     *
+     * @param Generator   $generator Sitemap generator
+     * @param string|null $name      The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Generator $generator, $name = null)
+    {
+        $this->generator = $generator;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('XML sitemap generator')
+            ->setHelp('Generates XML sitemap files.')
+            ->addOption(
+                'baseurl',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'base URL (overrides the url setting in Site section of config.ini)'
+            )->addOption(
+                'basesitemapurl',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'base sitemap URL (overrides the url setting in Site section of '
+                . 'config.ini, or baseSitemapUrl in sitemap.ini)'
+            );
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        if ($input->hasOption('verbose') && $input->getOption('verbose')) {
+            $this->generator->setVerbose([$output, 'writeln']);
+        }
+        if ($url = $input->getOption('baseurl')) {
+            $this->generator->setBaseUrl($url);
+        }
+        if ($sitemapUrl = $input->getOption('basesitemapurl')) {
+            $this->generator->setBaseSitemapUrl($sitemapUrl);
+        }
+        $this->generator->generate();
+        foreach ($this->generator->getWarnings() as $warning) {
+            $output->writeln("$warning");
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..23dece2d9d1942ed9ac8ab6bb7631b2b75aa5ad9
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/SitemapCommandFactory.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Factory for Util/SitemapCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/SitemapCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SitemapCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        return new $requestedName(
+            $container->get(\VuFind\Sitemap\Generator::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/SuppressedCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/SuppressedCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..432d8ff01da4c6afae7b94f5878fbd0ae01dff7b
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/SuppressedCommand.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Console command: remove suppressed records from index
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console command: remove suppressed records from index
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SuppressedCommand extends AbstractSolrAndIlsCommand
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/suppressed';
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Remove ILS-suppressed records from Solr')
+            ->setHelp(
+                'This tool removes ILS-suppressed records from Solr.'
+            )->addOption(
+                'authorities',
+                null,
+                InputOption::VALUE_NONE,
+                'Delete authority records instead of bibliographic records'
+            )->addOption(
+                'outfile',
+                null,
+                InputOption::VALUE_REQUIRED,
+                'Write the ID list to the specified file instead of updating Solr'
+            );
+    }
+
+    /**
+     * Write content to disk.
+     *
+     * @param string $filename Target filename
+     * @param string $content  Content to write
+     *
+     * @return bool
+     */
+    protected function writeToDisk($filename, $content)
+    {
+        return file_put_contents($filename, $content);
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Setup Solr Connection
+        $backend = $input->getOption('authorities') ? 'SolrAuth' : 'Solr';
+
+        // Make ILS Connection
+        try {
+            $result = ($backend == 'SolrAuth')
+                ? $this->catalog->getSuppressedAuthorityRecords()
+                : $this->catalog->getSuppressedRecords();
+        } catch (\Exception $e) {
+            $output->writeln('ILS error -- ' . $e->getMessage());
+            return 1;
+        }
+
+        // Validate result:
+        if (!is_array($result)) {
+            $output->writeln('Could not obtain suppressed record list from ILS.');
+            return 1;
+        } elseif (empty($result)) {
+            $output->writeln('No suppressed records to delete.');
+            return 0;
+        }
+
+        // If 'outfile' set, write the list
+        if ($file = $input->getOption('outfile')) {
+            if (!$this->writeToDisk($file, implode("\n", $result))) {
+                $output->writeln("Problem writing to $file");
+                return 1;
+            }
+        } else {
+            // Default behavior: Delete from Solr index
+            $this->solr->deleteRecords($backend, $result);
+            $this->solr->commit($backend);
+            $this->solr->optimize($backend);
+        }
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..797d63a0711ced4bd5975f0c3d6221c07c97772f
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommand.php
@@ -0,0 +1,220 @@
+<?php
+/**
+ * Console command: switch database encryption algorithm.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Laminas\Config\Config;
+use Laminas\Crypt\BlockCipher;
+use Laminas\Crypt\Symmetric\Openssl;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use VuFind\Config\Locator as ConfigLocator;
+use VuFind\Config\Writer as ConfigWriter;
+use VuFind\Db\Table\User as UserTable;
+
+/**
+ * Console command: switch database encryption algorithm.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SwitchDbHashCommand extends Command
+{
+    /**
+     * The name of the command (the part after "public/index.php")
+     *
+     * @var string
+     */
+    protected static $defaultName = 'util/switch_db_hash';
+
+    /**
+     * VuFind configuration.
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * User table gateway
+     *
+     * @var UserTable
+     */
+    protected $userTable;
+
+    /**
+     * Constructor
+     *
+     * @param Config      $config    VuFind configuration
+     * @param UserTable   $userTable User table gateway
+     * @param string|null $name      The name of the command; passing null means it
+     * must be set in configure()
+     */
+    public function __construct(Config $config, UserTable $userTable, $name = null)
+    {
+        $this->config = $config;
+        $this->userTable = $userTable;
+        parent::__construct($name);
+    }
+
+    /**
+     * Configure the command.
+     *
+     * @return void
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Encryption algorithm switcher')
+            ->setHelp(
+                'Switches the encryption algorithm in the database '
+                . 'and config. Expects new algorithm and (optional) new key as'
+                . ' parameters.'
+            )->addArgument('newmethod', InputArgument::REQUIRED, 'Encryption method')
+            ->addArgument('newkey', InputArgument::OPTIONAL, 'Encryption key');
+    }
+
+    /**
+     * Get a config writer
+     *
+     * @param string $path Path of file to write
+     *
+     * @return ConfigWriter
+     */
+    protected function getConfigWriter($path)
+    {
+        return new ConfigWriter($path);
+    }
+
+    /**
+     * Get an OpenSsl object for the specified algorithm (or return null if the
+     * algorithm is 'none').
+     *
+     * @param string $algorithm Encryption algorithm
+     *
+     * @return Openssl
+     */
+    protected function getOpenSsl($algorithm)
+    {
+        return ($algorithm == 'none') ? null : new Openssl(compact('algorithm'));
+    }
+
+    /**
+     * Run the command.
+     *
+     * @param InputInterface  $input  Input object
+     * @param OutputInterface $output Output object
+     *
+     * @return int 0 for success
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        // Validate command line arguments:
+        $newhash = $input->getArgument('newmethod');
+
+        // Pull existing encryption settings from the configuration:
+        if (!isset($this->config->Authentication->ils_encryption_key)
+            || !($this->config->Authentication->encrypt_ils_password ?? false)
+        ) {
+            $oldhash = 'none';
+            $oldkey = null;
+        } else {
+            $oldhash = $this->config->Authentication->ils_encryption_algo
+                ?? 'blowfish';
+            $oldkey = $this->config->Authentication->ils_encryption_key;
+        }
+
+        // Pull new encryption settings from argument or config:
+        $newkey = $input->getArgument('newkey') ?? $oldkey;
+
+        // No key specified AND no key on file = fatal error:
+        if ($newkey === null) {
+            $output->writeln('Please specify a key as the second parameter.');
+            return 1;
+        }
+
+        // If no changes were requested, abort early:
+        if ($oldkey == $newkey && $oldhash == $newhash) {
+            $output->writeln('No changes requested -- no action needed.');
+            return 0;
+        }
+
+        // Initialize Openssl first, so we can catch any illegal algorithms before
+        // making any changes:
+        try {
+            $oldCrypt = $this->getOpenSsl($oldhash);
+            $newCrypt = $this->getOpenSsl($newhash);
+        } catch (\Exception $e) {
+            $output->writeln($e->getMessage());
+            return 1;
+        }
+
+        // Next update the config file, so if we are unable to write the file,
+        // we don't go ahead and make unwanted changes to the database:
+        $configPath = ConfigLocator::getLocalConfigPath('config.ini', null, true);
+        $output->writeln("\tUpdating $configPath...");
+        $writer = $this->getConfigWriter($configPath);
+        $writer->set('Authentication', 'encrypt_ils_password', true);
+        $writer->set('Authentication', 'ils_encryption_algo', $newhash);
+        $writer->set('Authentication', 'ils_encryption_key', $newkey);
+        if (!$writer->save()) {
+            $output->writeln("\tWrite failed!");
+            return 1;
+        }
+
+        // Now do the database rewrite:
+        $users = $this->userTable->select(
+            function ($select) {
+                $select->where->isNotNull('cat_username');
+            }
+        );
+        $output->writeln("\tConverting hashes for " . count($users) . ' user(s).');
+        foreach ($users as $row) {
+            $pass = null;
+            if ($oldhash != 'none' && isset($row['cat_pass_enc'])) {
+                $oldcipher = new BlockCipher($oldCrypt);
+                $oldcipher->setKey($oldkey);
+                $pass = $oldcipher->decrypt($row['cat_pass_enc']);
+            } else {
+                $pass = $row['cat_password'];
+            }
+            $newcipher = new BlockCipher($newCrypt);
+            $newcipher->setKey($newkey);
+            $row['cat_password'] = null;
+            $row['cat_pass_enc'] = $newcipher->encrypt($pass);
+            $row->save();
+        }
+
+        // If we got this far, all went well!
+        $output->writeln("\tFinished.");
+        return 0;
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommandFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..5d3eca6ee0ffde3ba3e03976d8f309826a9a11ea
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/SwitchDbHashCommandFactory.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Factory for Util/SwitchDbHashCommand.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole\Command\Util;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Factory for Util/SwitchDbHashCommand.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class SwitchDbHashCommandFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+        $tableManager = $container->get(\VuFind\Db\Table\PluginManager::class);
+        return new $requestedName(
+            $config,
+            $tableManager->get(\VuFind\Db\Table\User::class),
+            ...($options ?? [])
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/ConsoleOutputTrait.php b/module/VuFindConsole/src/VuFindConsole/ConsoleOutputTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..880458c305a20d41ba66751ad186d06ca783331b
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/ConsoleOutputTrait.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Console output trait (used to add output support to other classes).
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole;
+
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console output trait (used to add output support to other classes).
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+trait ConsoleOutputTrait
+{
+    /**
+     * Output interface.
+     *
+     * @var OutputInterface
+     */
+    protected $outputInterface = null;
+
+    /**
+     * Set the output interface.
+     *
+     * @param OutputInterface $output Output interface
+     *
+     * @return void
+     */
+    public function setOutputInterface(OutputInterface $output): void
+    {
+        $this->outputInterface = $output;
+    }
+
+    /**
+     * Write a line to the output (if available).
+     *
+     * @param string $output Line to output.
+     *
+     * @return void
+     */
+    public function writeln(string $output): void
+    {
+        if ($this->outputInterface) {
+            $this->outputInterface->writeln($output);
+        }
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/ConsoleRunner.php b/module/VuFindConsole/src/VuFindConsole/ConsoleRunner.php
new file mode 100644
index 0000000000000000000000000000000000000000..dc20c96c04fe2283ceb789684653179fc7f104c3
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/ConsoleRunner.php
@@ -0,0 +1,112 @@
+<?php
+/**
+ * Console runner.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole;
+
+use Laminas\ServiceManager\ServiceManager;
+use Symfony\Component\Console\Application;
+
+/**
+ * Console runner.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ConsoleRunner
+{
+    /**
+     * List of commands
+     *
+     * @var array
+     */
+    protected $commands;
+
+    /**
+     * Plugin manager (to retrieve commands)
+     *
+     * @var ServiceManager
+     */
+    protected $pluginManager;
+
+    /**
+     * Constructor
+     *
+     * @param ServiceManager $pm Plugin manager (to retrieve commands)
+     */
+    public function __construct(ServiceManager $pm)
+    {
+        $this->pluginManager = $pm;
+    }
+
+    /**
+     * Get the command or list of commands to run.
+     *
+     * @return array
+     */
+    protected function getCommandList()
+    {
+        // Does the first argument match a command alias? If so, load only that:
+        if ($this->pluginManager->has($_SERVER['argv'][1] ?? '')) {
+            return [$_SERVER['argv'][1]];
+        }
+
+        // Do the first two arguments match a command alias? If so, manipulate
+        // the arguments (converting legacy format to Symfony format) and return
+        // the resulting command:
+        $command = ($_SERVER['argv'][1] ?? '') . '/' . ($_SERVER['argv'][2] ?? '');
+        if ($this->pluginManager->has($command)) {
+            $_SERVER['argc']--;
+            array_splice($_SERVER['argv'], 1, 2, [$command]);
+            return [$command];
+        }
+
+        // Default behavior: return all values
+        return $this->pluginManager->getCommandList();
+    }
+
+    /**
+     * Run the console action
+     *
+     * @return mixed
+     */
+    public function run()
+    {
+        // Get command list before initializing Application, since we may need
+        // to manipulate $_SERVER for backward compatibility.
+        $commands = $this->getCommandList();
+
+        // Launch Symfony:
+        $consoleApp = new Application();
+        foreach ($commands as $command) {
+            $consoleApp->add($this->pluginManager->get($command));
+        }
+        return $consoleApp->run();
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/ConsoleRunnerFactory.php b/module/VuFindConsole/src/VuFindConsole/ConsoleRunnerFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..3020bf05fdbdb51eb84786a10855a2c7a9389650
--- /dev/null
+++ b/module/VuFindConsole/src/VuFindConsole/ConsoleRunnerFactory.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Console runner factory.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFindConsole;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Console runner factory.
+ *
+ * @category VuFind
+ * @package  Console
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ConsoleRunnerFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+        return new $requestedName(
+            $container->get(\VuFindConsole\Command\PluginManager::class)
+        );
+    }
+}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/AbstractBase.php b/module/VuFindConsole/src/VuFindConsole/Controller/AbstractBase.php
deleted file mode 100644
index f0ae99bb4428b4e76937be1c0f35427e0fa2b9e5..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/AbstractBase.php
+++ /dev/null
@@ -1,146 +0,0 @@
-<?php
-/**
- * VuFind controller base class (defines some methods that can be shared by other
- * controllers).
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Demian Katz <demian.katz@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-use Laminas\Mvc\Controller\AbstractActionController;
-use Laminas\ServiceManager\ServiceLocatorInterface;
-
-/**
- * VuFind controller base class (defines some methods that can be shared by other
- * controllers).
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class AbstractBase extends AbstractActionController
-{
-    /**
-     * Constructor
-     *
-     * @param ServiceLocatorInterface $sm Service locator
-     */
-    public function __construct(ServiceLocatorInterface $sm)
-    {
-        // This controller should only be accessed from the command line!
-        if (PHP_SAPI != 'cli') {
-            throw new \Exception('Access denied to command line tools.');
-        }
-
-        $this->serviceLocator = $sm;
-
-        // Switch the context back to the original working directory so that
-        // relative paths work as expected. (This constant is set in
-        // public/index.php)
-        if (defined('ORIGINAL_WORKING_DIRECTORY')) {
-            chdir(ORIGINAL_WORKING_DIRECTORY);
-        }
-    }
-
-    /**
-     * Warn the user if VUFIND_LOCAL_DIR is not set.
-     *
-     * @return void
-     */
-    protected function checkLocalSetting()
-    {
-        if (!getenv('VUFIND_LOCAL_DIR')) {
-            Console::writeLine(
-                "WARNING: The VUFIND_LOCAL_DIR environment variable is not set."
-            );
-            Console::writeLine(
-                "This should point to your local configuration directory (i.e."
-            );
-            Console::writeLine(realpath(APPLICATION_PATH . '/local') . ").");
-            Console::writeLine(
-                "Without it, inappropriate default settings may be loaded."
-            );
-            Console::writeLine("");
-        }
-    }
-
-    /**
-     * Indicate failure.
-     *
-     * @return \Laminas\Console\Response
-     */
-    protected function getFailureResponse()
-    {
-        return $this->getResponse()->setErrorLevel(1);
-    }
-
-    /**
-     * Indicate success.
-     *
-     * @return \Laminas\Console\Response
-     */
-    protected function getSuccessResponse()
-    {
-        return $this->getResponse()->setErrorLevel(0);
-    }
-
-    /**
-     * Get a VuFind configuration.
-     *
-     * @param string $id Configuration identifier (default = main VuFind config)
-     *
-     * @return \Laminas\Config\Config
-     */
-    public function getConfig($id = 'config')
-    {
-        return $this->serviceLocator
-            ->get(\VuFind\Config\PluginManager::class)->get($id);
-    }
-
-    /**
-     * Get the ILS connection.
-     *
-     * @return \VuFind\ILS\Connection
-     */
-    public function getILS()
-    {
-        return $this->serviceLocator->get(\VuFind\ILS\Connection::class);
-    }
-
-    /**
-     * Get a database table object.
-     *
-     * @param string $table Name of table to retrieve
-     *
-     * @return \VuFind\Db\Table\Gateway
-     */
-    public function getTable($table)
-    {
-        return $this->serviceLocator->get(\VuFind\Db\Table\PluginManager::class)
-            ->get($table);
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php b/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php
deleted file mode 100644
index 5834527ed414491cd91ff5df6e569ae17a00fa7f..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/CompileController.php
+++ /dev/null
@@ -1,79 +0,0 @@
-<?php
-/**
- * Compile Controller Module
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2017.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Demian Katz <demian.katz@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-
-/**
- * This controller handles the command-line tool for compiling themes.
- *
- * @category VuFind
- * @package  Controller
- * @author   Demian Katz <demian.katz@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class CompileController extends AbstractBase
-{
-    /**
-     * Compile theme action.
-     *
-     * @return mixed
-     */
-    public function themeAction()
-    {
-        $request = $this->getRequest();
-        $source = $request->getParam('source');
-        if (empty($source)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName()
-                . ' compile theme [--force] SOURCE [TARGET]'
-            );
-            Console::writeLine("\tSOURCE - the source theme to compile (required)");
-            Console::writeLine(
-                "\tTARGET - the target name for the compiled theme "
-                . '(optional; defaults to SOURCE_compiled)'
-            );
-            Console::writeLine(
-                "(If TARGET exists, it will only be overwritten when --force is set)"
-            );
-            return $this->getFailureResponse();
-        }
-        $target = $request->getParam('target');
-        if (empty($target)) {
-            $target = "{$source}_compiled";
-        }
-        $compiler = $this->serviceLocator->get(\VuFindTheme\ThemeCompiler::class);
-        if (!$compiler->compile($source, $target, $request->getParam('force'))) {
-            Console::writeLine($compiler->getLastError());
-            return $this->getFailureResponse();
-        }
-        Console::writeLine('Success.');
-        return $this->getSuccessResponse();
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/GenerateController.php b/module/VuFindConsole/src/VuFindConsole/Controller/GenerateController.php
deleted file mode 100644
index db4b3b97772631705471a277bdae21027fa536b8..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/GenerateController.php
+++ /dev/null
@@ -1,409 +0,0 @@
-<?php
-/**
- * CLI Controller Module (language tools)
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-
-/**
- * This controller handles various command-line tools for dealing with language files
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class GenerateController extends AbstractBase
-{
-    /**
-     * Add a new dynamic route definition
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function dynamicrouteAction()
-    {
-        $request = $this->getRequest();
-        $route = $request->getParam('name');
-        $controller = $request->getParam('newController');
-        $action = $request->getParam('newAction');
-        $module = $request->getParam('module');
-        if (empty($module)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate dynamicroute'
-                . ' [route] [controller] [action] [target_module]'
-            );
-            Console::writeLine(
-                "\troute - the route name (used by router), e.g. customList"
-            );
-            Console::writeLine(
-                "\tcontroller - the controller name (used in URL), e.g. MyResearch"
-            );
-            Console::writeLine(
-                "\taction - the action and segment params, e.g. CustomList/[:id]"
-            );
-            Console::writeLine(
-                "\ttarget_module - the module where the new route will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Create backup of configuration
-        $generator = $this->getGeneratorTools();
-        $configPath = $generator->getModuleConfigPath($module);
-        $generator->backUpFile($configPath);
-
-        // Append the route
-        $config = include $configPath;
-        $routeGenerator = new \VuFind\Route\RouteGenerator();
-        $routeGenerator->addDynamicRoute($config, $route, $controller, $action);
-
-        // Write updated configuration
-        $generator->writeModuleConfig($configPath, $config);
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Extend an existing class
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function extendclassAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $class = $request->getParam('class');
-        $target = $request->getParam('target');
-        $extendFactory = $request->getParam('extendfactory');
-
-        if (empty($class) || empty($target)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate extendclass'
-                . ' [--extendfactory] [class_name] [target_module]'
-            );
-            Console::writeLine(
-                "\t--extendfactory - optional switch; when set, subclass "
-                . 'the factory; otherwise, use existing factory'
-            );
-            Console::writeLine(
-                "\tclass_name - the name of the class you wish to extend"
-            );
-            Console::writeLine(
-                "\ttarget_module - the module where the new class will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        try {
-            $this->getGeneratorTools()->extendClass(
-                $this->serviceLocator, $class, $target, $extendFactory
-            );
-        } catch (\Exception $e) {
-            Console::writeLine($e->getMessage());
-            return $this->getFailureResponse();
-        }
-
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Extend an existing service
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function extendserviceAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $source = $request->getParam('source');
-        $target = $request->getParam('target');
-        if (empty($source) || empty($target)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate extendservice'
-                . ' [config_path] [target_module]'
-            );
-            Console::writeLine(
-                "\tconfig_path - the path to the service in the framework config"
-            );
-            Console::writeLine("\t\te.g. controllers/invokables/generate");
-            Console::writeLine(
-                "\ttarget_module - the module where the new class will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        try {
-            $this->getGeneratorTools()->extendService($source, $target);
-        } catch (\Exception $e) {
-            Console::writeLine($e->getMessage());
-            return $this->getFailureResponse();
-        }
-
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Add a new non-tab record action to all existing record routes
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function nontabrecordactionAction()
-    {
-        $request = $this->getRequest();
-        $action = $request->getParam('newAction');
-        $module = $request->getParam('module');
-        if (empty($action) || empty($module)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName()
-                . ' generate nontabrecordaction [action] [target_module]'
-            );
-            Console::writeLine(
-                "\taction - new action to add"
-            );
-            Console::writeLine(
-                "\ttarget_module - the module where the new routes will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Create backup of configuration
-        $generator = $this->getGeneratorTools();
-        $configPath = $generator->getModuleConfigPath($module);
-        $generator->backUpFile($configPath);
-
-        // Load the route config
-        $config = include $configPath;
-
-        // Append the route
-        $mainConfig = $this->serviceLocator->get('Config');
-        foreach ($mainConfig['router']['routes'] as $key => $val) {
-            if (isset($val['options']['route'])
-                && substr($val['options']['route'], -14) == '[:id[/[:tab]]]'
-            ) {
-                $newRoute = $key . '-' . strtolower($action);
-                if (isset($mainConfig['router']['routes'][$newRoute])) {
-                    Console::writeLine($newRoute . ' already exists; skipping.');
-                } else {
-                    $val['options']['route'] = str_replace(
-                        '[:id[/[:tab]]]', "[:id]/$action", $val['options']['route']
-                    );
-                    $val['options']['defaults']['action'] = $action;
-                    $config['router']['routes'][$newRoute] = $val;
-                }
-            }
-        }
-
-        // Write updated configuration
-        $generator->writeModuleConfig($configPath, $config);
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Create a new plugin class
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function pluginAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $class = $request->getParam('class');
-        $factory = $request->getParam('factory');
-
-        if (empty($class)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate plugin'
-                . ' [class_name] [factory]'
-            );
-            Console::writeLine(
-                "\tclass_name - the name of the class you wish to create"
-            );
-            Console::writeLine(
-                "\tfactory - an existing factory to use (omit to generate a new one)"
-            );
-            return $this->getFailureResponse();
-        }
-
-        try {
-            $this->getGeneratorTools()
-                ->createPlugin($this->serviceLocator, $class, $factory);
-        } catch (\Exception $e) {
-            Console::writeLine($e->getMessage());
-            return $this->getFailureResponse();
-        }
-
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Add a new record route definition
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function recordrouteAction()
-    {
-        $request = $this->getRequest();
-        $base = $request->getParam('base');
-        $controller = $request->getParam('newController');
-        $module = $request->getParam('module');
-        if (empty($module)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate recordroute'
-                . ' [base] [controller] [target_module]'
-            );
-            Console::writeLine(
-                "\tbase - the base route name (used by router), e.g. record"
-            );
-            Console::writeLine(
-                "\tcontroller - the controller name (used in URL), e.g. Record"
-            );
-            Console::writeLine(
-                "\ttarget_module - the module where the new route will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Create backup of configuration
-        $generator = $this->getGeneratorTools();
-        $configPath = $generator->getModuleConfigPath($module);
-        $generator->backUpFile($configPath);
-
-        // Append the route
-        $config = include $configPath;
-        $routeGenerator = new \VuFind\Route\RouteGenerator();
-        $routeGenerator->addRecordRoute($config, $base, $controller);
-
-        // Write updated configuration
-        $generator->writeModuleConfig($configPath, $config);
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Add a new static route definition
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function staticrouteAction()
-    {
-        $request = $this->getRequest();
-        $route = $request->getParam('name');
-        $module = $request->getParam('module');
-        if (empty($module)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' generate staticroute'
-                . ' [route_definition] [target_module]'
-            );
-            Console::writeLine(
-                "\troute_definition - a Controller/Action string, e.g. Search/Home"
-            );
-            Console::writeLine(
-                "\ttarget_module - the module where the new route will be generated"
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Create backup of configuration
-        $generator = $this->getGeneratorTools();
-        $configPath = $generator->getModuleConfigPath($module);
-        $generator->backUpFile($configPath);
-
-        // Append the route
-        $config = include $configPath;
-        $routeGenerator = new \VuFind\Route\RouteGenerator();
-        $routeGenerator->addStaticRoute($config, $route);
-
-        // Write updated configuration
-        $generator->writeModuleConfig($configPath, $config);
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Create a custom theme from the template, configure.
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function themeAction()
-    {
-        // Validate command line argument:
-        $request = $this->getRequest();
-        $name = $request->getParam('themename');
-        if (empty($name)) {
-            Console::writeLine("\tNo themename found, using \"custom\"");
-            $name = 'custom';
-        }
-
-        // Use the theme generator to create and configure the theme:
-        $generator = $this->serviceLocator->get(\VuFindTheme\ThemeGenerator::class);
-        if (!$generator->generate($name)
-            || !$generator->configure($this->getConfig(), $name)
-        ) {
-            Console::writeLine($generator->getLastError());
-            return $this->getFailureResponse();
-        }
-        Console::writeLine("\tFinished.");
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Create a custom theme from the template.
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function thememixinAction()
-    {
-        // Validate command line argument:
-        $request = $this->getRequest();
-        $name = $request->getParam('name');
-        if (empty($name)) {
-            Console::writeLine("\tNo mixin name found, using \"custom\"");
-            $name = 'custom';
-        }
-
-        // Use the theme generator to create and configure the theme:
-        $generator = $this->serviceLocator->get(\VuFindTheme\MixinGenerator::class);
-        if (!$generator->generate($name)) {
-            Console::writeLine($generator->getLastError());
-            return $this->getFailureResponse();
-        }
-        Console::writeLine(
-            "\tFinished. Add to your theme.config.php 'mixins' setting to activate."
-        );
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Get generator tools
-     *
-     * @return \VuFindConsole\Generator\GeneratorTools
-     */
-    protected function getGeneratorTools()
-    {
-        return $this->serviceLocator->get(
-            \VuFindConsole\Generator\GeneratorTools::class
-        );
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/HarvestController.php b/module/VuFindConsole/src/VuFindConsole/Controller/HarvestController.php
deleted file mode 100644
index ed0f0d657a2779d79c646aad89bbfbc9a369290a..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/HarvestController.php
+++ /dev/null
@@ -1,144 +0,0 @@
-<?php
-/**
- * CLI Controller Module
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-use VuFindHarvest\OaiPmh\HarvesterConsoleRunner;
-
-/**
- * This controller handles various command-line tools
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class HarvestController extends AbstractBase
-{
-    /**
-     * Get the base directory for harvesting OAI-PMH data.
-     *
-     * @return string
-     */
-    protected function getHarvestRoot()
-    {
-        // Get the base VuFind path:
-        if (strlen(LOCAL_OVERRIDE_DIR) > 0) {
-            $home = LOCAL_OVERRIDE_DIR;
-        } else {
-            $home = realpath(APPLICATION_PATH . '/..');
-        }
-
-        // Build the full harvest path:
-        $dir = $home . '/harvest/';
-
-        // Create the directory if it does not already exist:
-        if (!is_dir($dir) && !mkdir($dir)) {
-            throw new \Exception("Problem creating directory {$dir}.");
-        }
-
-        return $dir;
-    }
-
-    /**
-     * Harvest OAI-PMH records.
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function harvestoaiAction()
-    {
-        $this->checkLocalSetting();
-
-        // Get default options, add the default --ini setting if missing:
-        $opts = HarvesterConsoleRunner::getDefaultOptions();
-        $opts->setArguments($this->getRequest()->getParam('params'));
-        if (!$opts->getOption('ini')) {
-            $ini = \VuFind\Config\Locator::getConfigPath('oai.ini', 'harvest');
-            $opts->addArguments(['--ini=' . $ini]);
-        }
-
-        // Get the default VuFind HTTP client:
-        $client = $this->serviceLocator->get(\VuFindHttp\HttpService::class)
-            ->createClient();
-
-        // Run the job!
-        $runner = new HarvesterConsoleRunner(
-            $opts, $client, $this->getHarvestRoot()
-        );
-        return $runner->run()
-            ? $this->getSuccessResponse() : $this->getFailureResponse();
-    }
-
-    /**
-     * Merge harvested MARC records into a single <collection>
-     *
-     * @return \Laminas\Console\Response
-     * @author Thomas Schwaerzler <thomas.schwaerzler@uibk.ac.at>
-     */
-    public function mergemarcAction()
-    {
-        $this->checkLocalSetting();
-
-        $dir = rtrim($this->getRequest()->getParam('dir', ''), '/');
-        if (empty($dir)) {
-            $scriptName = $this->getRequest()->getScriptName();
-            if (substr($scriptName, -9) === 'index.php') {
-                $scriptName .= ' harvest merge-marc';
-            }
-            Console::writeLine('Merge MARC XML files into a single <collection>;');
-            Console::writeLine('writes to stdout.');
-            Console::writeLine('');
-            Console::writeLine('Usage: ' . $scriptName . ' <path_to_directory>');
-            Console::writeLine(
-                '<path_to_directory>: a directory containing MARC XML files to merge'
-            );
-            return $this->getFailureResponse();
-        }
-
-        if (!($handle = opendir($dir))) {
-            Console::writeLine("Cannot open directory: {$dir}");
-            return $this->getFailureResponse();
-        }
-
-        Console::writeLine('<collection>');
-        while (false !== ($file = readdir($handle))) {
-            // Only operate on XML files:
-            if (pathinfo($file, PATHINFO_EXTENSION) === "xml") {
-                // get file content
-                $filePath = $dir . '/' . $file;
-                $fileContent = file_get_contents($filePath);
-
-                // output content:
-                Console::writeLine("<!-- $filePath -->");
-                Console::write($fileContent);
-            }
-        }
-        Console::writeLine('</collection>');
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/ImportController.php b/module/VuFindConsole/src/VuFindConsole/Controller/ImportController.php
deleted file mode 100644
index 098279b7c25755dc8a9d58580cbe613f09e17dd9..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/ImportController.php
+++ /dev/null
@@ -1,235 +0,0 @@
-<?php
-/**
- * CLI Controller Module
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-use VuFind\XSLT\Importer;
-
-/**
- * This controller handles various command-line tools
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class ImportController extends AbstractBase
-{
-    /**
-     * XSLT Import Tool
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function importXslAction()
-    {
-        $request = $this->getRequest();
-        $testMode = $request->getParam('test-only') ? true : false;
-        $index = $request->getParam('index', 'Solr');
-        $xml = $request->getParam('xml');
-        $properties = $request->getParam('properties');
-        if (empty($properties)) {
-            $scriptName = $this->getRequest()->getScriptName();
-            if (substr($scriptName, -9) === 'index.php') {
-                $scriptName .= ' import import-xsl';
-            }
-            Console::writeLine(
-                "Usage: $scriptName [--test-only] [--index <type>] "
-                . 'XML_file properties_file'
-            );
-            Console::writeLine("\tXML_file - source file to index");
-            Console::writeLine("\tproperties_file - import configuration file");
-            Console::writeLine(
-                "If the optional --test-only flag is set, "
-                . "transformed XML will be displayed"
-            );
-            Console::writeLine(
-                "on screen for debugging purposes, "
-                . "but it will not be indexed into VuFind."
-            );
-            Console::writeLine("");
-            Console::writeLine(
-                "If the optional --index parameter is set, "
-                . "it must be followed by the name of"
-            );
-            Console::writeLine(
-                "a class for accessing Solr; it defaults to the "
-                . "standard Solr class, but could"
-            );
-            Console::writeLine(
-                "be overridden with, for example, SolrAuth to "
-                . "load authority records."
-            );
-            Console::writeLine("");
-            Console::writeLine(
-                "Note: See ojs.properties for configuration examples."
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Try to import the document if successful:
-        try {
-            $this->performImport($xml, $properties, $index, $testMode);
-        } catch (\Exception $e) {
-            Console::writeLine("Fatal error: " . $e->getMessage());
-            if (is_callable([$e, 'getPrevious']) && $e = $e->getPrevious()) {
-                while ($e) {
-                    Console::writeLine("Previous exception: " . $e->getMessage());
-                    $e = $e->getPrevious();
-                }
-            }
-            return $this->getFailureResponse();
-        }
-        if (!$testMode) {
-            Console::writeLine("Successfully imported $xml...");
-        }
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Support method -- perform an XML import.
-     *
-     * @param string $xml        XML file to load
-     * @param string $properties Configuration file to load
-     * @param string $index      Name of backend to write to
-     * @param bool   $testMode   Use test mode?
-     *
-     * @return void
-     */
-    protected function performImport($xml, $properties, $index = 'Solr',
-        $testMode = false
-    ) {
-        $importer = new Importer($this->serviceLocator);
-        $importer->save($xml, $properties, $index, $testMode);
-    }
-
-    /**
-     * Tool to crawl website for special index.
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function webcrawlAction()
-    {
-        // Get command line parameters:
-        $request = $this->getRequest();
-        $testMode = $request->getParam('test-only') ? true : false;
-        $index = $request->getParam('index', 'SolrWeb');
-
-        $configLoader = $this->serviceLocator
-            ->get(\VuFind\Config\PluginManager::class);
-        $crawlConfig = $configLoader->get('webcrawl');
-
-        // Get the time we started indexing -- we'll delete records older than this
-        // date after everything is finished.  Note that we subtract a few seconds
-        // for safety.
-        $startTime = date('Y-m-d\TH:i:s\Z', time() - 5);
-
-        // Are we in verbose mode?
-        $verbose = isset($crawlConfig->General->verbose)
-            && $crawlConfig->General->verbose;
-
-        // Loop through sitemap URLs in the config file.
-        foreach ($crawlConfig->Sitemaps->url as $current) {
-            $this->harvestSitemap($current, $verbose, $index, $testMode);
-        }
-
-        // Skip Solr operations if we're in test mode.
-        if (!$testMode) {
-            $solr = $this->serviceLocator->get(\VuFind\Solr\Writer::class);
-            if ($verbose) {
-                Console::writeLine("Deleting old records (prior to $startTime)...");
-            }
-            // Perform the delete of outdated records:
-            $solr->deleteByQuery($index, 'last_indexed:[* TO ' . $startTime . ']');
-            if ($verbose) {
-                Console::writeLine('Committing...');
-            }
-            $solr->commit($index);
-            if ($verbose) {
-                Console::writeLine('Optimizing...');
-            }
-            $solr->optimize($index);
-        }
-    }
-
-    /**
-     * Support method for webcrawlAction().
-     *
-     * Process a sitemap URL, either harvesting its contents directly or recursively
-     * reading in child sitemaps.
-     *
-     * @param string $url      URL of sitemap to read.
-     * @param bool   $verbose  Are we in verbose mode?
-     * @param string $index    Solr index to update
-     * @param bool   $testMode Are we in test mode?
-     *
-     * @return bool       True on success, false on error.
-     */
-    protected function harvestSitemap($url, $verbose = false, $index = 'SolrWeb',
-        $testMode = false
-    ) {
-        if ($verbose) {
-            Console::writeLine("Harvesting $url...");
-        }
-
-        $retVal = true;
-
-        $file = tempnam('/tmp', 'sitemap');
-        file_put_contents($file, file_get_contents($url));
-        $xml = simplexml_load_file($file);
-        if ($xml) {
-            // Are there any child sitemaps?  If so, pull them in:
-            $results = isset($xml->sitemap) ? $xml->sitemap : [];
-            foreach ($results as $current) {
-                if (isset($current->loc)) {
-                    $success = $this->harvestSitemap(
-                        (string)$current->loc, $verbose, $index, $testMode
-                    );
-                    if (!$success) {
-                        $retVal = false;
-                    }
-                }
-            }
-            // Only import the current sitemap if it contains URLs!
-            if (isset($xml->url)) {
-                try {
-                    $this->performImport(
-                        $file, 'sitemap.properties', $index, $testMode
-                    );
-                } catch (\Exception $e) {
-                    if ($verbose) {
-                        Console::writeLine(get_class($e) . ': ' . $e->getMessage());
-                    }
-                    $retVal = false;
-                }
-            }
-        }
-        unlink($file);
-        return $retVal;
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/LanguageController.php b/module/VuFindConsole/src/VuFindConsole/Controller/LanguageController.php
deleted file mode 100644
index e44bbaaef67dff9469364b1bf2975e858c4ed455..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/LanguageController.php
+++ /dev/null
@@ -1,366 +0,0 @@
-<?php
-/**
- * CLI Controller Module (language tools)
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-use VuFind\I18n\ExtendedIniNormalizer;
-use VuFind\I18n\Translator\Loader\ExtendedIniReader;
-
-/**
- * This controller handles various command-line tools for dealing with language files
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class LanguageController extends AbstractBase
-{
-    /**
-     * Copy one language string to another
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function copystringAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $source = $request->getParam('source');
-        $target = $request->getParam('target');
-        if (empty($source) || empty($target)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName()
-                . ' language copystring [source] [target]'
-            );
-            Console::writeLine("\tsource - the source key to read");
-            Console::writeLine("\ttarget - the target key to write");
-            Console::writeLine(
-                "(source and target may include 'textdomain::' prefix)"
-            );
-            return $this->getFailureResponse();
-        }
-
-        $reader = new ExtendedIniReader();
-        $normalizer = new ExtendedIniNormalizer();
-        list($sourceDomain, $sourceKey) = $this->extractTextDomain($source);
-        list($targetDomain, $targetKey) = $this->extractTextDomain($target);
-
-        if (!($sourceDir = $this->getLangDir($sourceDomain))
-            || !($targetDir = $this->getLangDir($targetDomain, true))
-        ) {
-            return $this->getFailureResponse();
-        }
-
-        // First, collect the source values from the source text domain:
-        $sources = [];
-        $sourceCallback = function ($full) use ($sourceKey, $reader, & $sources) {
-            $strings = $reader->getTextDomain($full, false);
-            if (!isset($strings[$sourceKey])) {
-                Console::writeLine("Source key not found.");
-            } else {
-                $sources[basename($full)] = $strings[$sourceKey];
-            }
-        };
-        $this->processDirectory($sourceDir, $sourceCallback);
-
-        // Make sure that all target files exist:
-        $this->createMissingFiles($targetDir->path, array_keys($sources));
-
-        // Now copy the values to their destination:
-        $targetCallback = function ($full) use ($targetKey, $normalizer, $sources) {
-            if (isset($sources[basename($full)])) {
-                $fHandle = fopen($full, "a");
-                fputs(
-                    $fHandle,
-                    "\n$targetKey = \"" . $sources[basename($full)] . "\"\n"
-                );
-                fclose($fHandle);
-                $normalizer->normalizeFile($full);
-            }
-        };
-        $this->processDirectory($targetDir, $targetCallback);
-
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Assemble a new language string by combining existing ones using a
-     * template.
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function addusingtemplateAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $target = $request->getParam('target');
-        $template = $request->getParam('template');
-        if (empty($template)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName()
-                . ' language addusingtemplate [target] [template]'
-            );
-            Console::writeLine(
-                "\ttarget - the target key to add "
-                . "(may include 'textdomain::' prefix)\n"
-                . "\ttemplate - the template to build the string, using ||string||"
-                . " to import existing strings"
-            );
-            return $this->getFailureResponse();
-        }
-
-        // Make sure a valid target has been specified:
-        list($targetDomain, $targetKey) = $this->extractTextDomain($target);
-        if (!($targetDir = $this->getLangDir($targetDomain, true))) {
-            return $this->getFailureResponse();
-        }
-
-        // Extract required source values from template:
-        preg_match_all('/\|\|[^|]+\|\|/', $template, $matches);
-        $lookups = [];
-        foreach ($matches[0] as $current) {
-            $key = trim($current, '|');
-            list($sourceDomain, $sourceKey) = $this->extractTextDomain($key);
-            $lookups[$sourceDomain][$current] = [
-                'key' => $sourceKey,
-                'translations' => []
-            ];
-        }
-
-        // Look up translations of all references in template:
-        $reader = new ExtendedIniReader();
-        foreach ($lookups as $domain => & $tokens) {
-            $sourceDir = $this->getLangDir($domain, false);
-            if (!$sourceDir) {
-                return $this->getFailureResponse();
-            }
-            $sourceCallback = function ($full) use (
-                $domain, & $tokens, $reader
-            ) {
-                $strings = $reader->getTextDomain($full, false);
-                foreach ($tokens as & $current) {
-                    $sourceKey = $current['key'];
-                    if (isset($strings[$sourceKey])) {
-                        $current['translations'][basename($full)]
-                            = $strings[$sourceKey];
-                    }
-                }
-            };
-            $this->processDirectory($sourceDir, $sourceCallback, false);
-        }
-
-        // Fill in template, write results:
-        $normalizer = new ExtendedIniNormalizer();
-        $targetCallback = function ($full) use (
-            $template, $targetKey, $normalizer, $lookups
-        ) {
-            $lang = basename($full);
-            $in = $out = [];
-            foreach ($lookups as $domain => $tokens) {
-                foreach ($tokens as $token => $details) {
-                    if (isset($details['translations'][$lang])) {
-                        $in[] = $token;
-                        $out[] = $details['translations'][$lang];
-                    } else {
-                        Console::writeLine(
-                            'Skipping; no match for token: ' . $token
-                        );
-                        return;
-                    }
-                }
-            }
-            $fHandle = fopen($full, "a");
-            fputs(
-                $fHandle,
-                "\n$targetKey = \"" . str_replace($in, $out, $template) . "\"\n"
-            );
-            fclose($fHandle);
-            $normalizer->normalizeFile($full);
-        };
-        $this->processDirectory($targetDir, $targetCallback);
-    }
-
-    /**
-     * Delete a language string to another
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function deleteAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $target = $request->getParam('target');
-        if (empty($target)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName() . ' language delete [target]'
-            );
-            Console::writeLine(
-                "\ttarget - the target key to remove "
-                . "(may include 'textdomain::' prefix)"
-            );
-            return $this->getFailureResponse();
-        }
-
-        $normalizer = new ExtendedIniNormalizer();
-        list($domain, $key) = $this->extractTextDomain($target);
-        $target = $key . ' = "';
-
-        if (!($dir = $this->getLangDir($domain))) {
-            return $this->getFailureResponse();
-        }
-        $callback = function ($full) use ($target, $normalizer) {
-            $lines = file($full);
-            $out = '';
-            $found = false;
-            foreach ($lines as $line) {
-                if (substr($line, 0, strlen($target)) !== $target) {
-                    $out .= $line;
-                } else {
-                    $found = true;
-                }
-            }
-            if ($found) {
-                file_put_contents($full, $out);
-                $normalizer->normalizeFile($full);
-            } else {
-                Console::writeLine("Source key not found.");
-            }
-        };
-        $this->processDirectory($dir, $callback);
-
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Normalizer
-     *
-     * @return \Laminas\Console\Response
-     */
-    public function normalizeAction()
-    {
-        // Display help message if parameters missing:
-        $request = $this->getRequest();
-        $target = $request->getParam('target');
-        if (empty($target)) {
-            Console::writeLine(
-                'Usage: ' . $request->getScriptName()
-                . ' language normalize [target]'
-            );
-            Console::writeLine("\ttarget - a file or directory to normalize");
-            return $this->getFailureResponse();
-        }
-
-        $normalizer = new ExtendedIniNormalizer();
-        if (is_dir($target)) {
-            $normalizer->normalizeDirectory($target);
-        } elseif (is_file($target)) {
-            $normalizer->normalizeFile($target);
-        } else {
-            Console::writeLine("{$target} does not exist.");
-            return $this->getFailureResponse();
-        }
-        return $this->getSuccessResponse();
-    }
-
-    /**
-     * Extract a text domain and key from a raw language key.
-     *
-     * @param string $raw Raw language key
-     *
-     * @return array [textdomain, key]
-     */
-    protected function extractTextDomain($raw)
-    {
-        $parts = explode('::', $raw, 2);
-        return count($parts) > 1 ? $parts : ['default', $raw];
-    }
-
-    /**
-     * Open the language directory as an object using dir(). Return false on
-     * failure.
-     *
-     * @param string $domain          Text domain to retrieve.
-     * @param bool   $createIfMissing Should we create a missing directory?
-     *
-     * @return object|bool
-     */
-    protected function getLangDir($domain = 'default', $createIfMissing = false)
-    {
-        $subDir = $domain == 'default' ? '' : ('/' . $domain);
-        $langDir = __DIR__ . '/../../../../../languages' . $subDir;
-        if ($createIfMissing && !is_dir($langDir)) {
-            mkdir($langDir);
-        }
-        $dir = dir(realpath($langDir));
-        if (!$dir) {
-            Console::writeLine("Could not open directory $langDir");
-            return false;
-        }
-        return $dir;
-    }
-
-    /**
-     * Create empty files if they do not already exist.
-     *
-     * @param string $path  Directory path
-     * @param array  $files Filenames to create in directory
-     *
-     * @return void
-     */
-    protected function createMissingFiles($path, $files)
-    {
-        foreach ($files as $file) {
-            if (!file_exists($path . '/' . $file)) {
-                file_put_contents($path . '/' . $file, '');
-            }
-        }
-    }
-
-    /**
-     * Process a language directory.
-     *
-     * @param object   $dir        Directory object from dir() to process
-     * @param Callable $callback   Function to run on all .ini files in $dir
-     * @param bool     $showStatus Should we display status messages?
-     *
-     * @return void
-     */
-    protected function processDirectory($dir, $callback, $showStatus = true)
-    {
-        while ($file = $dir->read()) {
-            // Only process .ini files, and ignore native.ini special case file:
-            if (substr($file, -4) == '.ini' && $file !== 'native.ini') {
-                if ($showStatus) {
-                    Console::writeLine("Processing $file...");
-                }
-                $callback($dir->path . '/' . $file);
-            }
-        }
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/RedirectController.php b/module/VuFindConsole/src/VuFindConsole/Controller/RedirectController.php
deleted file mode 100644
index 9e3622f632ec4aa258de70a991369af5a02c59c6..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/src/VuFindConsole/Controller/RedirectController.php
+++ /dev/null
@@ -1,101 +0,0 @@
-<?php
-/**
- * Redirect Controller
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2016.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-namespace VuFindConsole\Controller;
-
-use Laminas\Console\Console;
-use Laminas\Mvc\Application;
-
-/**
- * This controller handles various command-line tools
- *
- * @category VuFind
- * @package  Controller
- * @author   Chris Hallberg <challber@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
- */
-class RedirectController extends AbstractBase
-{
-    /**
-     * Get a usage message with the help of the RouteNotFoundStrategy.
-     *
-     * @return mixed
-     */
-    protected function getUsage()
-    {
-        $strategy = $this->serviceLocator->get('ConsoleRouteNotFoundStrategy');
-        $event = $this->getEvent();
-        $event->setError(Application::ERROR_ROUTER_NO_MATCH);
-        $strategy->handleRouteNotFoundError($event);
-        return $event->getResult();
-    }
-
-    /**
-     * Use the first two command line parameters to redirect the user to an
-     * appropriate controller.
-     *
-     * @return mixed
-     */
-    public function consoledefaultAction()
-    {
-        // We need to modify the $_SERVER superglobals so that
-        // \Laminas\Console\GetOpt will behave correctly after we've manipulated the
-        // CLI parameters. Let's use references for convenience.
-        $argv = & $_SERVER['argv'];
-        $argc = & $_SERVER['argc'];
-
-        // Pull the script name off the front of the argument array:
-        $script = array_shift($argv);
-
-        // Fail if we don't have at least two arguments (controller/action):
-        if ($argc < 2) {
-            return $this->getUsage();
-        }
-
-        // Pull off the controller and action.
-        $controller = array_shift($argv);
-        $action = array_shift($argv);
-
-        // In case later scripts are displaying $argv[0] for the script name,
-        // let's push the full invocation into that position when index.php is
-        // used. We want to eliminate the $controller and $action values as separate
-        // parts of the array since they'll confuse subsequent parameter processing.
-        if (substr($script, -9) === 'index.php') {
-            $script .= " $controller $action";
-        }
-        array_unshift($argv, $script);
-        $argc -= 2;
-
-        try {
-            return $this->forward()->dispatch($controller, compact('action'));
-        } catch (\Exception $e) {
-            Console::writeLine('ERROR: ' . $e->getMessage());
-            return $this->getUsage();
-        }
-    }
-}
diff --git a/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php b/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
index 691c01e74dd108446a8d160ed4aa4d2ea3e56f50..0138401b8836a6ad2be2e5c58cb11482215621ad 100644
--- a/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
+++ b/module/VuFindConsole/src/VuFindConsole/Generator/GeneratorTools.php
@@ -32,7 +32,6 @@ use Laminas\Code\Generator\ClassGenerator;
 use Laminas\Code\Generator\FileGenerator;
 use Laminas\Code\Generator\MethodGenerator;
 use Laminas\Code\Reflection\ClassReflection;
-use Laminas\Console\Console;
 
 /**
  * Generator tools.
@@ -45,6 +44,8 @@ use Laminas\Console\Console;
  */
 class GeneratorTools
 {
+    use \VuFindConsole\ConsoleOutputTrait;
+
     /**
      * Laminas configuration
      *
@@ -441,8 +442,8 @@ class GeneratorTools
             // __callStatic and ignore the error. Any other exception should be
             // treated as a fatal error.
             if (method_exists($factoryClass, '__callStatic')) {
-                Console::writeLine('Error: ' . $e->getMessage());
-                Console::writeLine(
+                $this->writeln('Error: ' . $e->getMessage());
+                $this->writeln(
                     '__callStatic in parent factory; skipping method generation.'
                 );
             } else {
@@ -575,7 +576,7 @@ class GeneratorTools
         if (!file_put_contents($fullPath, $code)) {
             throw new \Exception("Problem writing to $fullPath.");
         }
-        Console::writeLine("Saved file: $fullPath");
+        $this->writeln("Saved file: $fullPath");
     }
 
     /**
@@ -637,7 +638,7 @@ class GeneratorTools
         if (!copy($filename, $backup)) {
             throw new \Exception("Problem generating backup file: $backup");
         }
-        Console::writeLine("Created backup: $backup");
+        $this->writeln("Created backup: $backup");
     }
 
     /**
@@ -677,7 +678,7 @@ class GeneratorTools
         if (!file_put_contents($configPath, $generator->generate())) {
             throw new \Exception("Cannot write to $configPath");
         }
-        Console::writeLine("Successfully updated $configPath");
+        $this->writeln("Successfully updated $configPath");
     }
 
     /**
diff --git a/module/VuFindConsole/tests/fixtures/deletes b/module/VuFindConsole/tests/fixtures/deletes
new file mode 100644
index 0000000000000000000000000000000000000000..55eb55adeb87b3c763d707d4dcef1616aa6133fb
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/deletes
@@ -0,0 +1,6 @@
+rec1
+
+
+
+  rec2
+rec3
diff --git a/module/VuFindConsole/tests/fixtures/empty.config.php b/module/VuFindConsole/tests/fixtures/empty.config.php
new file mode 100644
index 0000000000000000000000000000000000000000..881ab67d036d4900e0c6aef94f47961fe0c03b5a
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/empty.config.php
@@ -0,0 +1,2 @@
+<?php
+return [];
diff --git a/module/VuFindConsole/tests/fixtures/fileWithDuplicateLines b/module/VuFindConsole/tests/fixtures/fileWithDuplicateLines
new file mode 100644
index 0000000000000000000000000000000000000000..35074e022c2a731fff366ae6706d1835d9b4bd77
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/fileWithDuplicateLines
@@ -0,0 +1,6 @@
+foo
+foo
+foo
+bar
+baz
+baz
diff --git a/module/VuFindConsole/tests/fixtures/language/foo/en.ini b/module/VuFindConsole/tests/fixtures/language/foo/en.ini
new file mode 100644
index 0000000000000000000000000000000000000000..ca31e16d8853e46acdc477b2a936631bb7447a0d
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/language/foo/en.ini
@@ -0,0 +1 @@
+bar = "baz"
\ No newline at end of file
diff --git a/module/VuFindConsole/tests/fixtures/reserves/fixture1 b/module/VuFindConsole/tests/fixtures/reserves/fixture1
new file mode 100644
index 0000000000000000000000000000000000000000..fc8216d90eff18358fffd857f02475ef38d9e5da
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/reserves/fixture1
@@ -0,0 +1 @@
+1|junk|course1|dept1|inst1
\ No newline at end of file
diff --git a/module/VuFindConsole/tests/fixtures/reserves/fixture2 b/module/VuFindConsole/tests/fixtures/reserves/fixture2
new file mode 100644
index 0000000000000000000000000000000000000000..9e825c052d0e1fe11ee964f223db8ea16ab35145
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/reserves/fixture2
@@ -0,0 +1,2 @@
+2|junk|course2|dept2|inst2
+3|junk|course3|dept3|inst3
\ No newline at end of file
diff --git a/module/VuFindConsole/tests/fixtures/sitemap/index.xml b/module/VuFindConsole/tests/fixtures/sitemap/index.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5f1934b3265a7b0830863f6c75b0e7b6da59e293
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/sitemap/index.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<sitemapindex
+   xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
+   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+   xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
+   http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
+
+  <sitemap>
+    <loc>http://bar</loc>
+    <lastmod>2020-03-06</lastmod>
+  </sitemap>
+
+</sitemapindex>
\ No newline at end of file
diff --git a/module/VuFindConsole/tests/fixtures/sitemap/map.xml b/module/VuFindConsole/tests/fixtures/sitemap/map.xml
new file mode 100644
index 0000000000000000000000000000000000000000..78ea1c540436cf789877bea37c66a8f7f04837a4
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/sitemap/map.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url>
+    <loc>http://xyzzy/</loc>
+    <lastmod>2020-03-17T16:03:30-04:00</lastmod>
+    <changefreq>weekly</changefreq>
+    <priority>0.5</priority>
+  </url>
+</urlset>
diff --git a/module/VuFindConsole/tests/fixtures/xml/a.xml b/module/VuFindConsole/tests/fixtures/xml/a.xml
new file mode 100644
index 0000000000000000000000000000000000000000..afbdf72cbcf7f08686ac4c1a155483aa09b36cf6
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/xml/a.xml
@@ -0,0 +1 @@
+<record id="a" />
diff --git a/module/VuFindConsole/tests/fixtures/xml/b.xml b/module/VuFindConsole/tests/fixtures/xml/b.xml
new file mode 100644
index 0000000000000000000000000000000000000000..366a7839d3da7a43c7ed46b3fd6eb52c87a25f14
--- /dev/null
+++ b/module/VuFindConsole/tests/fixtures/xml/b.xml
@@ -0,0 +1 @@
+<record id="b" />
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Compile/ThemeCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Compile/ThemeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..76d2a55efb221f842756e0c6fabc85485bf85adb
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Compile/ThemeCommandTest.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * Compile/Theme command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Compile;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Compile\ThemeCommand;
+use VuFindTheme\ThemeCompiler;
+
+/**
+ * Compile/Theme command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ThemeCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "source").'
+        );
+        $command = new ThemeCommand($this->getMockCompiler());
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $compiler = $this->getMockCompiler(['compile']);
+        $compiler->expects($this->once())->method('compile')
+            ->with(
+                $this->equalTo('theme'),
+                $this->equalTo('theme_compiled'),
+                $this->equalTo(false)
+            )->will($this->returnValue(true));
+        $command = new ThemeCommand($compiler);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['source' => 'theme']);
+        $this->assertEquals(
+            "Success.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Simulate failure caused by existing theme and no '--force' option.
+     *
+     * @return void
+     */
+    public function testFailureWithMissingForce()
+    {
+        $compiler = $this->getMockCompiler(['compile', 'getLastError']);
+        $compiler->expects($this->once())->method('compile')
+            ->with(
+                $this->equalTo('theme'),
+                $this->equalTo('compiled_theme'),
+                $this->equalTo(false)
+            )->will($this->returnValue(false));
+        $compiler->expects($this->once())->method('getLastError')
+            ->will($this->returnValue('Error!'));
+        $command = new ThemeCommand($compiler);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'source' => 'theme',
+                'target' => 'compiled_theme',
+            ]
+        );
+        $this->assertEquals(
+            "Error!\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Simulate success with '--force' option.
+     *
+     * @return void
+     */
+    public function testSuccessWithForceOption()
+    {
+        $compiler = $this->getMockCompiler(['compile']);
+        $compiler->expects($this->once())->method('compile')
+            ->with(
+                $this->equalTo('theme'),
+                $this->equalTo('compiled_theme'),
+                $this->equalTo(true)
+            )->will($this->returnValue(true));
+        $command = new ThemeCommand($compiler);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'source' => 'theme',
+                'target' => 'compiled_theme',
+                '--force' => true,
+            ]
+        );
+        $this->assertEquals(
+            "Success.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock compiler object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ThemeCompiler
+     */
+    protected function getMockCompiler($methods = [])
+    {
+        return $this->getMockBuilder(ThemeCompiler::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/DynamicRouteCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/DynamicRouteCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3ebd1bb36d030ec27cfa65a3d6426c6938689c48
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/DynamicRouteCommandTest.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Generate/DynamicRoute command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Route\RouteGenerator;
+use VuFindConsole\Command\Generate\DynamicRouteCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/DynamicRoute command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class DynamicRouteCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments '
+            . '(missing: "route, controller, action, target_module").'
+        );
+        $command = new DynamicRouteCommand(
+            $this->getMockGeneratorTools(),
+            $this->getMockRouteGenerator()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $configFixturePath = __DIR__ . '/../../../../../fixtures/empty.config.php';
+        $expectedConfig = include $configFixturePath;
+        $tools = $this->getMockGeneratorTools(
+            ['getModuleConfigPath', 'backUpFile', 'writeModuleConfig']
+        );
+        $tools->expects($this->once())->method('getModuleConfigPath')
+            ->with($this->equalTo('xyzzy'))
+            ->will($this->returnValue($configFixturePath));
+        $tools->expects($this->once())->method('backUpFile')
+            ->with($this->equalTo($configFixturePath));
+        $tools->expects($this->once())->method('writeModuleConfig')
+            ->with(
+                $this->equalTo($configFixturePath),
+                $this->equalTo($expectedConfig)
+            );
+        $generator = $this->getMockRouteGenerator(['addDynamicRoute']);
+        $generator->expects($this->once())->method('addDynamicRoute')
+            ->with(
+                $this->equalTo($expectedConfig),
+                $this->equalTo('foo'),
+                $this->equalTo('bar'),
+                $this->equalTo('baz')
+            );
+        $command = new DynamicRouteCommand($tools, $generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'route' => 'foo',
+                'controller' => 'bar',
+                'action' => 'baz',
+                'target_module' => 'xyzzy',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock container object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ContainerInterface
+     */
+    protected function getMockRouteGenerator($methods = [])
+    {
+        return $this->getMockBuilder(RouteGenerator::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendClassCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendClassCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..30f9f2963d6ae43bcb8e6267e53c5e2e338507b0
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendClassCommandTest.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * Generate/ExtendClass command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\ExtendClassCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/ExtendClass command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExtendClassCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "class_name, target_module").'
+        );
+        $command = new ExtendClassCommand(
+            $this->getMockGeneratorTools(),
+            $this->getMockContainer()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['extendClass', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('extendClass')
+            ->with(
+                $this->equalTo($container),
+                $this->equalTo('Foo'),
+                $this->equalTo('Bar'),
+                $this->equalTo(null)
+            );
+        $command = new ExtendClassCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'class_name' => 'Foo',
+                'target_module' => 'Bar'
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test the extendfactory option.
+     *
+     * @return void
+     */
+    public function testSuccessWithFactoryOption()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['extendClass', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('extendClass')
+            ->with(
+                $this->equalTo($container),
+                $this->equalTo('Foo'),
+                $this->equalTo('Bar'),
+                $this->equalTo(true)
+            );
+        $command = new ExtendClassCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'class_name' => 'Foo',
+                'target_module' => 'Bar',
+                '--extendfactory' => true,
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test exception handling.
+     *
+     * @return void
+     */
+    public function testError()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['extendClass', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('extendClass')
+            ->will($this->throwException(new \Exception('Foo!')));
+        $command = new ExtendClassCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'class_name' => 'Foo',
+                'target_module' => 'Bar'
+            ]
+        );
+        $this->assertEquals("Foo!\n", $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock container object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ContainerInterface
+     */
+    protected function getMockContainer($methods = [])
+    {
+        return $this->getMockBuilder(ContainerInterface::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendServiceCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendServiceCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..09d553b7272a2b296b941b8336bc58633610401b
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ExtendServiceCommandTest.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * Generate/ExtendService command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\ExtendServiceCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/ExtendService command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExtendServiceCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "config_path, target_module").'
+        );
+        $command = new ExtendServiceCommand(
+            $this->getMockGeneratorTools()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $tools = $this->getMockGeneratorTools(
+            ['extendService', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('extendService')
+            ->with(
+                $this->equalTo('Foo'),
+                $this->equalTo('Bar')
+            );
+        $command = new ExtendServiceCommand($tools);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'config_path' => 'Foo',
+                'target_module' => 'Bar'
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test exception handling.
+     *
+     * @return void
+     */
+    public function testError()
+    {
+        $tools = $this->getMockGeneratorTools(
+            ['extendService', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('extendService')
+            ->will($this->throwException(new \Exception('Foo!')));
+        $command = new ExtendServiceCommand($tools);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'config_path' => 'Foo',
+                'target_module' => 'Bar'
+            ]
+        );
+        $this->assertEquals("Foo!\n", $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/NonTabRecordActionCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/NonTabRecordActionCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8ff33dd71faba76b8e8447f9e477893c3741eb26
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/NonTabRecordActionCommandTest.php
@@ -0,0 +1,153 @@
+<?php
+/**
+ * Generate/NonTabRecordAction command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\NonTabRecordActionCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/NonTabRecordAction command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class NonTabRecordActionCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments '
+            . '(missing: "action, target_module").'
+        );
+        $command = new NonTabRecordActionCommand(
+            $this->getMockGeneratorTools(),
+            []
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $configFixturePath = __DIR__ . '/../../../../../fixtures/empty.config.php';
+        $expectedConfig = [
+            'router' => [
+                'routes' => [
+                    'example-foo' => [
+                        'type'    => \Laminas\Router\Http\Segment::class,
+                        'options' => [
+                            'route'    => '/Example/[:id]/Foo',
+                            'constraints' => [
+                                'controller' => '[a-zA-Z][a-zA-Z0-9_-]*',
+                                'action'     => '[a-zA-Z][a-zA-Z0-9_-]*',
+                            ],
+                            'defaults' => [
+                                'controller' => 'Example',
+                                'action'     => 'Foo',
+                            ]
+                        ]
+                    ]
+                ]
+            ]
+        ];
+        $tools = $this->getMockGeneratorTools(
+            ['getModuleConfigPath', 'backUpFile', 'writeModuleConfig']
+        );
+        $tools->expects($this->once())->method('getModuleConfigPath')
+            ->with($this->equalTo('xyzzy'))
+            ->will($this->returnValue($configFixturePath));
+        $tools->expects($this->once())->method('backUpFile')
+            ->with($this->equalTo($configFixturePath));
+        $tools->expects($this->once())->method('writeModuleConfig')
+            ->with(
+                $this->equalTo($configFixturePath),
+                $this->equalTo($expectedConfig)
+            );
+        $config = [
+            'router' => [
+                'routes' => [
+                    'example' => [
+                        'type'    => \Laminas\Router\Http\Segment::class,
+                        'options' => [
+                            'route'    => '/Example/[:id[/[:tab]]]',
+                            'constraints' => [
+                                'controller' => '[a-zA-Z][a-zA-Z0-9_-]*',
+                                'action'     => '[a-zA-Z][a-zA-Z0-9_-]*',
+                            ],
+                            'defaults' => [
+                                'controller' => 'Example',
+                                'action'     => 'Home',
+                            ]
+                        ]
+                    ]
+                ]
+            ]
+        ];
+        $command = new NonTabRecordActionCommand($tools, $config);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'action' => 'Foo',
+                'target_module' => 'xyzzy',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..27f54b4571eba0aa60e09347ab8879c483b6c743
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/PluginCommandTest.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Generate/Plugin command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\PluginCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/Plugin command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class PluginCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "class_name").'
+        );
+        $command = new PluginCommand(
+            $this->getMockGeneratorTools(),
+            $this->getMockContainer()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['setOutputInterface', 'createPlugin']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('createPlugin')
+            ->with(
+                $this->equalTo($container),
+                $this->equalTo('Foo'),
+                $this->equalTo(null)
+            );
+        $command = new PluginCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['class_name' => 'Foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test the factory parameter.
+     *
+     * @return void
+     */
+    public function testSuccessWithFactoryParameter()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['setOutputInterface', 'createPlugin']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('createPlugin')
+            ->with(
+                $this->equalTo($container),
+                $this->equalTo('Foo'),
+                $this->equalTo('Factory')
+            );
+        $command = new PluginCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['class_name' => 'Foo', 'factory' => 'Factory']
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test exception handling.
+     *
+     * @return void
+     */
+    public function testError()
+    {
+        $container = $this->getMockContainer();
+        $tools = $this->getMockGeneratorTools(
+            ['createPlugin', 'setOutputInterface']
+        );
+        $tools->expects($this->once())->method('setOutputInterface');
+        $tools->expects($this->once())->method('createPlugin')
+            ->will($this->throwException(new \Exception('Foo!')));
+        $command = new PluginCommand($tools, $container);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['class_name' => 'Foo', 'factory' => 'Factory']
+        );
+        $this->assertEquals("Foo!\n", $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock container object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ContainerInterface
+     */
+    protected function getMockContainer($methods = [])
+    {
+        return $this->getMockBuilder(ContainerInterface::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/RecordRouteCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/RecordRouteCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e58e1fb7cc044c7c8c172596570a98645dd34ab6
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/RecordRouteCommandTest.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Generate/RecordRoute command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Route\RouteGenerator;
+use VuFindConsole\Command\Generate\RecordRouteCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/RecordRoute command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class RecordRouteCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments '
+            . '(missing: "base, controller, target_module").'
+        );
+        $command = new RecordRouteCommand(
+            $this->getMockGeneratorTools(),
+            $this->getMockRouteGenerator()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $configFixturePath = __DIR__ . '/../../../../../fixtures/empty.config.php';
+        $expectedConfig = include $configFixturePath;
+        $tools = $this->getMockGeneratorTools(
+            ['getModuleConfigPath', 'backUpFile', 'writeModuleConfig']
+        );
+        $tools->expects($this->once())->method('getModuleConfigPath')
+            ->with($this->equalTo('xyzzy'))
+            ->will($this->returnValue($configFixturePath));
+        $tools->expects($this->once())->method('backUpFile')
+            ->with($this->equalTo($configFixturePath));
+        $tools->expects($this->once())->method('writeModuleConfig')
+            ->with(
+                $this->equalTo($configFixturePath),
+                $this->equalTo($expectedConfig)
+            );
+        $generator = $this->getMockRouteGenerator(['addRecordRoute']);
+        $generator->expects($this->once())->method('addRecordRoute')
+            ->with(
+                $this->equalTo($expectedConfig),
+                $this->equalTo('foo'),
+                $this->equalTo('bar')
+            );
+        $command = new RecordRouteCommand($tools, $generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'base' => 'foo',
+                'controller' => 'bar',
+                'target_module' => 'xyzzy',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock container object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ContainerInterface
+     */
+    protected function getMockRouteGenerator($methods = [])
+    {
+        return $this->getMockBuilder(RouteGenerator::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/StaticRouteCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/StaticRouteCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e8fa0dfd0de07a7ddd83cd85e356af2081cf1cd4
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/StaticRouteCommandTest.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Generate/StaticRoute command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Interop\Container\ContainerInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Route\RouteGenerator;
+use VuFindConsole\Command\Generate\StaticRouteCommand;
+use VuFindConsole\Generator\GeneratorTools;
+
+/**
+ * Generate/StaticRoute command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class StaticRouteCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments '
+            . '(missing: "route_definition, target_module").'
+        );
+        $command = new StaticRouteCommand(
+            $this->getMockGeneratorTools(),
+            $this->getMockRouteGenerator()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $configFixturePath = __DIR__ . '/../../../../../fixtures/empty.config.php';
+        $expectedConfig = include $configFixturePath;
+        $tools = $this->getMockGeneratorTools(
+            ['getModuleConfigPath', 'backUpFile', 'writeModuleConfig']
+        );
+        $tools->expects($this->once())->method('getModuleConfigPath')
+            ->with($this->equalTo('xyzzy'))
+            ->will($this->returnValue($configFixturePath));
+        $tools->expects($this->once())->method('backUpFile')
+            ->with($this->equalTo($configFixturePath));
+        $tools->expects($this->once())->method('writeModuleConfig')
+            ->with(
+                $this->equalTo($configFixturePath),
+                $this->equalTo($expectedConfig)
+            );
+        $generator = $this->getMockRouteGenerator(['addStaticRoute']);
+        $generator->expects($this->once())->method('addStaticRoute')
+            ->with(
+                $this->equalTo($expectedConfig),
+                $this->equalTo('foo')
+            );
+        $command = new StaticRouteCommand($tools, $generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'route_definition' => 'foo',
+                'target_module' => 'xyzzy',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock generator tools object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return GeneratorTools
+     */
+    protected function getMockGeneratorTools($methods = [])
+    {
+        return $this->getMockBuilder(GeneratorTools::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock container object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ContainerInterface
+     */
+    protected function getMockRouteGenerator($methods = [])
+    {
+        return $this->getMockBuilder(RouteGenerator::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac582d6848c3b148e5a099e42fae8ad6ac7c4e07
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeCommandTest.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Generate/Theme command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\ThemeCommand;
+use VuFindTheme\ThemeGenerator;
+
+/**
+ * Generate/Theme command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ThemeCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $config = new \Laminas\Config\Config([]);
+        $generator = $this->getMockGenerator();
+        $generator->expects($this->once())
+            ->method('generate')
+            ->with($this->equalTo('custom'))
+            ->will($this->returnValue(true));
+        $generator->expects($this->once())
+            ->method('configure')
+            ->with($this->equalTo($config), $this->equalTo('custom'))
+            ->will($this->returnValue(true));
+        $command = new ThemeCommand($generator, $config);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(
+            "\tNo theme name provided, using \"custom\"\n\tFinished.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test a failure scenario.
+     *
+     * @return void
+     */
+    public function testFailure()
+    {
+        $config = new \Laminas\Config\Config([]);
+        $generator = $this->getMockGenerator();
+        $generator->expects($this->once())
+            ->method('generate')
+            ->with($this->equalTo('foo'))
+            ->will($this->returnValue(true));
+        $generator->expects($this->once())
+            ->method('configure')
+            ->with($this->equalTo($config), $this->equalTo('foo'))
+            ->will($this->returnValue(false));
+        $generator->expects($this->once())
+            ->method('getLastError')
+            ->will($this->returnValue('fake error'));
+        $command = new ThemeCommand($generator, $config);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['name' => 'foo']);
+        $this->assertEquals("fake error\n", $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Create a mock generator object.
+     *
+     * @return ThemeGenerator
+     */
+    protected function getMockGenerator()
+    {
+        return $this->getMockBuilder(ThemeGenerator::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeMixinCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeMixinCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7036c087ecc23caed79d7b1aca8e6d38df2e43dd
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Generate/ThemeMixinCommandTest.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Generate/ThemeMixin command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Generate;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Generate\ThemeMixinCommand;
+use VuFindTheme\MixinGenerator;
+
+/**
+ * Generate/ThemeMixin command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ThemeMixinCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $generator = $this->getMockGenerator();
+        $generator->expects($this->once())
+            ->method('generate')
+            ->with($this->equalTo('custom'))
+            ->will($this->returnValue(true));
+        $command = new ThemeMixinCommand($generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(
+            "\tNo theme mixin name provided, using \"custom\"\n"
+            . "\tFinished. Add to your theme.config.php 'mixins' setting "
+            . "to activate.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test a failure scenario.
+     *
+     * @return void
+     */
+    public function testFailure()
+    {
+        $generator = $this->getMockGenerator();
+        $generator->expects($this->once())
+            ->method('generate')
+            ->with($this->equalTo('foo'))
+            ->will($this->returnValue(false));
+        $generator->expects($this->once())
+            ->method('getLastError')
+            ->will($this->returnValue('fake error'));
+        $command = new ThemeMixinCommand($generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['name' => 'foo']);
+        $this->assertEquals("fake error\n", $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Create a mock generator object.
+     *
+     * @return ThemeGenerator
+     */
+    protected function getMockGenerator()
+    {
+        return $this->getMockBuilder(MixinGenerator::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/HarvestOaiCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/HarvestOaiCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0592b78a755180c37872677da0947a4dee260d78
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/HarvestOaiCommandTest.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * HarvestOai command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Harvest;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Harvest\HarvestOaiCommand;
+
+/**
+ * HarvestOai command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class HarvestOaiCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that the --ini setting is overridden automatically.
+     *
+     * @return void
+     */
+    public function testIniOverride()
+    {
+        $command = new HarvestOaiCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expectedIni = \VuFind\Config\Locator::getConfigPath('oai.ini', 'harvest');
+        $this->assertEquals(
+            "Please add OAI-PMH settings to $expectedIni.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/MergeMarcCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/MergeMarcCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..284892bedc4fb4b30e35f11df344c2867bc9667a
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Harvest/MergeMarcCommandTest.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * MergeMarc command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Harvest;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Harvest\MergeMarcCommand;
+
+/**
+ * MergeMarc command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class MergeMarcCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "directory").'
+        );
+        $command = new MergeMarcCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test that merging a directory yields valid results.
+     *
+     * @return void
+     */
+    public function testMergingDirectory()
+    {
+        $command = new MergeMarcCommand();
+        $commandTester = new CommandTester($command);
+        $directory = __DIR__ . '/../../../../../fixtures/xml';
+        $commandTester->execute(compact('directory'));
+        $expected = <<<EXPECTED
+<collection>
+<!-- $directory/a.xml -->
+<record id="a" />
+<!-- $directory/b.xml -->
+<record id="b" />
+</collection>
+
+EXPECTED;
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test that merging a non-existent directory yields an error message.
+     *
+     * @return void
+     */
+    public function testMissingDirectory()
+    {
+        $command = new MergeMarcCommand();
+        $commandTester = new CommandTester($command);
+        $directory = __DIR__ . '/../../../../../fixtures/does-not-exist';
+        $commandTester->execute(compact('directory'));
+        $expected = "Cannot open directory: $directory\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/ImportXslCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/ImportXslCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9a87d57d39d9af64c54da9ffcfe9da787e6d3d2e
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/ImportXslCommandTest.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Import/ImportXsl command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Import;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\XSLT\Importer;
+use VuFindConsole\Command\Import\ImportXslCommand;
+
+/**
+ * Import/ImportXsl command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ImportXslCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "XML_file, properties_file").'
+        );
+        $command = new ImportXslCommand(
+            $this->getMockImporter()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $importer = $this->getMockImporter();
+        $importer->expects($this->once())->method('save')
+            ->with(
+                $this->equalTo('foo.xml'),
+                $this->equalTo('bar.properties'),
+                $this->equalTo('Solr'),
+                $this->equalTo(false)
+            );
+        $command = new ImportXslCommand($importer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'XML_file' => 'foo.xml',
+                'properties_file' => 'bar.properties'
+            ]
+        );
+        $this->assertEquals(
+            "Successfully imported foo.xml...\n", $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test a failure scenario
+     *
+     * @return void
+     */
+    public function testFailure()
+    {
+        $e = new \Exception('foo', 0, new \Exception('bar'));
+        $importer = $this->getMockImporter();
+        $importer->expects($this->once())->method('save')
+            ->with(
+                $this->equalTo('foo.xml'),
+                $this->equalTo('bar.properties'),
+                $this->equalTo('SolrTest'),
+                $this->equalTo(true)
+            )->will($this->throwException($e));
+        $command = new ImportXslCommand($importer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            [
+                'XML_file' => 'foo.xml',
+                'properties_file' => 'bar.properties',
+                '--index' => 'SolrTest',
+                '--test-only' => true,
+            ]
+        );
+        $this->assertEquals(
+            "Fatal error: foo\nPrevious exception: bar\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock importer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return Importer
+     */
+    protected function getMockImporter($methods = [])
+    {
+        return $this->getMockBuilder(Importer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/WebCrawlCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/WebCrawlCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..165cf6a7010908304779f7f9adc5b3277eae70b4
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Import/WebCrawlCommandTest.php
@@ -0,0 +1,149 @@
+<?php
+/**
+ * Import/WebCrawl command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Import;
+
+use Laminas\Config\Config;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Solr\Writer;
+use VuFind\XSLT\Importer;
+use VuFindConsole\Command\Import\WebCrawlCommand;
+
+/**
+ * Import/WebCrawl command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class WebCrawlCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $fixture1 = __DIR__ . '/../../../../../fixtures/sitemap/index.xml';
+        $fixture2 = __DIR__ . '/../../../../../fixtures/sitemap/map.xml';
+        $importer = $this->getMockImporter();
+        $importer->expects($this->once())->method('save')
+            ->with(
+                $this->equalTo($fixture2),
+                $this->equalTo('sitemap.properties'),
+                $this->equalTo('SolrWeb'),
+                $this->equalTo(false)
+            );
+        $solr = $this->getMockSolrWriter();
+        $solr->expects($this->once())->method('deleteByQuery')
+            ->with($this->equalTo('SolrWeb'));
+        $solr->expects($this->once())->method('commit')
+            ->with($this->equalTo('SolrWeb'));
+        $solr->expects($this->once())->method('optimize')
+            ->with($this->equalTo('SolrWeb'));
+        $config = new Config(
+            [
+                'Sitemaps' => ['url' => ['http://foo']]
+            ]
+        );
+        $command = $this->getMockCommand($importer, $solr, $config);
+        $command->expects($this->at(0))->method('downloadFile')
+            ->with($this->equalTo('http://foo'))
+            ->will($this->returnValue($fixture1));
+        $command->expects($this->at(1))->method('downloadFile')
+            ->with($this->equalTo('http://bar'))
+            ->will($this->returnValue($fixture2));
+        $command->expects($this->at(2))->method('removeTempFile')
+            ->with($this->equalTo($fixture2));
+        $command->expects($this->at(3))->method('removeTempFile')
+            ->with($this->equalTo($fixture1));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(
+            '', $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param Importer $importer Importer object
+     * @param Writer   $solr     Solr writer object
+     * @param Config   $config   Configuration
+     * @param array    $methods  Methods to mock
+     *
+     * @return WebCrawlCommand
+     */
+    protected function getMockCommand(Importer $importer = null,
+        Writer $solr = null, Config $config = null,
+        array $methods = ['downloadFile', 'removeTempFile']
+    ) {
+        return $this->getMockBuilder(WebCrawlCommand::class)
+            ->setConstructorArgs(
+                [
+                    $importer ?? $this->getMockImporter(),
+                    $solr ?? $this->getMockSolrWriter(),
+                    $config ?? new Config([]),
+                ]
+            )->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock importer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return Importer
+     */
+    protected function getMockImporter($methods = [])
+    {
+        return $this->getMockBuilder(Importer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock solr writer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return Writer
+     */
+    protected function getMockSolrWriter($methods = [])
+    {
+        return $this->getMockBuilder(Writer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Install/InstallCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Install/InstallCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..82828bbcec1bbc30fe042c71e1592ff4b5579b96
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Install/InstallCommandTest.php
@@ -0,0 +1,217 @@
+<?php
+/**
+ * Install command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Import;
+
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Install\InstallCommand;
+
+/**
+ * Install command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class InstallCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test the interactive installation process.
+     *
+     * @return void
+     */
+    public function testInteractiveInstallation()
+    {
+        $expectedBaseDir = realpath(__DIR__ . '/../../../../../../../../');
+        $localFixtures = $expectedBaseDir . '/module/VuFindConsole/tests/fixtures';
+        $command = $this->getMockCommand(
+            ['buildDirs', 'getApacheLocation', 'getInput', 'writeFileToDisk']
+        );
+        $command->expects($this->at(0))->method('getInput')
+            ->with(
+                $this->isInstanceOf(InputInterface::class),
+                $this->isInstanceOf(OutputInterface::class),
+                $this->equalTo(
+                    'Where would you like to store your local settings? '
+                    . "[$expectedBaseDir/local] "
+                )
+            )->will($this->returnValue($localFixtures));
+        $expectedDirs = [
+            $localFixtures,
+            $localFixtures . '/cache',
+            $localFixtures . '/config',
+            $localFixtures . '/harvest',
+            $localFixtures . '/import',
+        ];
+        $command->expects($this->at(1))->method('buildDirs')
+            ->with($this->equalTo($expectedDirs))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(2))->method('getInput')
+            ->with(
+                $this->isInstanceOf(InputInterface::class),
+                $this->isInstanceOf(OutputInterface::class),
+                $this->equalTo(
+                    "\nWhat module name would you like to use? [blank for none] "
+                )
+            )->will($this->returnValue(''));
+        $command->expects($this->at(3))->method('getInput')
+            ->with(
+                $this->isInstanceOf(InputInterface::class),
+                $this->isInstanceOf(OutputInterface::class),
+                $this->equalTo(
+                    'What base path should be used in VuFind\'s URL? [/vufind] '
+                )
+            )->will($this->returnValue('/bar'));
+        $command->expects($this->at(4))->method('buildDirs')
+            ->with($this->equalTo($expectedDirs))
+            ->will($this->returnValue(true));
+        $expectedEnvBat = "@set VUFIND_HOME=$expectedBaseDir\n"
+            . "@set VUFIND_LOCAL_DIR=$localFixtures\n";
+        $command->expects($this->at(5))->method('writeFileToDisk')
+            ->with(
+                $this->equalTo("$expectedBaseDir/env.bat"),
+                $this->equalTo($expectedEnvBat)
+            )->will($this->returnValue(true));
+        $command->expects($this->at(6))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/import/import.properties"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(7))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/import/import_auth.properties"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(8))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/httpd-vufind.conf"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(9))->method('getApacheLocation')
+            ->with($this->isInstanceOf(OutputInterface::class));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expectedOutput = <<<TEXT
+VuFind has been found in $expectedBaseDir.
+
+VuFind supports use of a custom module for storing local code changes.
+If you do not plan to customize the code, you can skip this step.
+If you decide to use a custom module, the name you choose will be used for
+the module's directory name and its PHP namespace.
+Apache configuration written to $localFixtures/httpd-vufind.conf.
+
+You now need to load this configuration into Apache.
+Once the configuration is linked, restart Apache.  You should now be able
+to access VuFind at http://localhost/bar
+
+For proper use of command line tools, you should also ensure that your
+
+VUFIND_HOME and VUFIND_LOCAL_DIR environment variables are set to
+$expectedBaseDir and $localFixtures respectively.
+TEXT;
+        $this->assertEquals(
+            $expectedOutput, trim($commandTester->getDisplay())
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test the non-interactive installation process.
+     *
+     * @return void
+     */
+    public function testNonInteractiveInstallation()
+    {
+        $expectedBaseDir = realpath(__DIR__ . '/../../../../../../../../');
+        $localFixtures = $expectedBaseDir . '/module/VuFindConsole/tests/fixtures';
+        $command = $this->getMockCommand(
+            ['buildDirs', 'getApacheLocation', 'getInput', 'writeFileToDisk']
+        );
+        $expectedDirs = [
+            $localFixtures,
+            $localFixtures . '/cache',
+            $localFixtures . '/config',
+            $localFixtures . '/harvest',
+            $localFixtures . '/import',
+        ];
+        $command->expects($this->at(0))->method('buildDirs')
+            ->with($this->equalTo($expectedDirs))
+            ->will($this->returnValue(true));
+        $expectedEnvBat = "@set VUFIND_HOME=$expectedBaseDir\n"
+            . "@set VUFIND_LOCAL_DIR=$localFixtures\n";
+        $command->expects($this->at(1))->method('writeFileToDisk')
+            ->with(
+                $this->equalTo("$expectedBaseDir/env.bat"),
+                $this->equalTo($expectedEnvBat)
+            )->will($this->returnValue(true));
+        $command->expects($this->at(2))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/import/import.properties"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(3))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/import/import_auth.properties"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(4))->method('writeFileToDisk')
+            ->with($this->equalTo("$localFixtures/httpd-vufind.conf"))
+            ->will($this->returnValue(true));
+        $command->expects($this->at(5))->method('getApacheLocation')
+            ->with($this->isInstanceOf(OutputInterface::class));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['--non-interactive' => true, '--overridedir' => $localFixtures]
+        );
+        $expectedOutput = <<<EXPECTED
+VuFind has been found in $expectedBaseDir.
+Apache configuration written to $localFixtures/httpd-vufind.conf.
+
+You now need to load this configuration into Apache.
+Once the configuration is linked, restart Apache.  You should now be able
+to access VuFind at http://localhost/vufind
+
+For proper use of command line tools, you should also ensure that your
+
+VUFIND_HOME and VUFIND_LOCAL_DIR environment variables are set to
+$expectedBaseDir and $localFixtures respectively.
+EXPECTED;
+        $this->assertEquals(
+            $expectedOutput, trim($commandTester->getDisplay())
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return InstallCommand
+     */
+    protected function getMockCommand(
+        array $methods = ['buildDirs', 'getInput', 'writeFileToDisk']
+    ) {
+        return $this->getMockBuilder(InstallCommand::class)
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/AddUsingTemplateCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/AddUsingTemplateCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5a6edb549d450cb7959d03f8005c5de9a671cf6c
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/AddUsingTemplateCommandTest.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Language/AddUsingTemplate command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Language;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+use VuFindConsole\Command\Language\AddUsingTemplateCommand;
+
+/**
+ * Language/AddUsingTemplate command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class AddUsingTemplateCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Language fixture directory
+     *
+     * @var string
+     */
+    protected $languageFixtureDir = __DIR__ . '/../../../../../fixtures/language';
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "target, template").'
+        );
+        $command = new AddUsingTemplateCommand(
+            $this->getMockNormalizer(),
+            $this->getMockReader()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $expectedPath = realpath($this->languageFixtureDir) . '/foo/en.ini';
+        $normalizer = $this->getMockNormalizer();
+        $normalizer->expects($this->once())->method('normalizeFile')
+            ->with($this->equalTo($expectedPath));
+        $reader = $this->getMockReader();
+        $reader->expects($this->once())->method('getTextDomain')
+            ->with($this->equalTo($expectedPath), $this->equalTo(false))
+            ->will($this->returnValue(['bar' => 'baz']));
+        $command = $this->getMockCommand($normalizer, $reader);
+        $command->expects($this->once())->method('addLineToFile')
+            ->with(
+                $this->equalTo($expectedPath),
+                $this->equalTo('xyzzy'),
+                $this->equalTo('baz-baz')
+            );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['template' => '||foo::bar||-||foo::bar||', 'target' => 'foo::xyzzy']
+        );
+        $this->assertEquals("Processing en.ini...\n", $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param ExtendedIniNormalizer $normalizer  Normalizer for .ini files
+     * @param ExtendedIniReader     $reader      Reader for .ini files
+     * @param string                $languageDir Base language file directory
+     * @param array                 $methods     Methods to mock
+     *
+     * @return AddUsingTemplateCommand
+     */
+    protected function getMockCommand(ExtendedIniNormalizer $normalizer = null,
+        ExtendedIniReader $reader = null, $languageDir = null,
+        array $methods = ['addLineToFile']
+    ) {
+        return $this->getMockBuilder(AddUsingTemplateCommand::class)
+            ->setConstructorArgs(
+                [
+                    $normalizer ?? $this->getMockNormalizer(),
+                    $reader ?? $this->getMockReader(),
+                    $languageDir ?? $this->languageFixtureDir,
+                ]
+            )->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock normalizer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniNormalizer
+     */
+    protected function getMockNormalizer($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniNormalizer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock reader object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniReader
+     */
+    protected function getMockReader($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniReader::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/CopyStringCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/CopyStringCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7abec14fb09e186d8216428f0130da0c5c80a3d6
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/CopyStringCommandTest.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * Language/CopyString command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Language;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+use VuFindConsole\Command\Language\CopyStringCommand;
+
+/**
+ * Language/CopyString command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class CopyStringCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Language fixture directory
+     *
+     * @var string
+     */
+    protected $languageFixtureDir = __DIR__ . '/../../../../../fixtures/language';
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "source, target").'
+        );
+        $command = new CopyStringCommand(
+            $this->getMockNormalizer(),
+            $this->getMockReader()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $expectedPath = realpath($this->languageFixtureDir) . '/foo/en.ini';
+        $normalizer = $this->getMockNormalizer();
+        $normalizer->expects($this->once())->method('normalizeFile')
+            ->with($this->equalTo($expectedPath));
+        $reader = $this->getMockReader();
+        $reader->expects($this->once())->method('getTextDomain')
+            ->with($this->equalTo($expectedPath), $this->equalTo(false))
+            ->will($this->returnValue(['bar' => 'baz']));
+        $command = $this->getMockCommand($normalizer, $reader);
+        $command->expects($this->once())->method('addLineToFile')
+            ->with(
+                $this->equalTo($expectedPath),
+                $this->equalTo('xyzzy'),
+                $this->equalTo('baz')
+            );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['source' => 'foo::bar', 'target' => 'foo::xyzzy']);
+        $this->assertEquals(
+            "Processing en.ini...\nProcessing en.ini...\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test failure due to missing text domain.
+     *
+     * @return void
+     */
+    public function testFailureWithMissingTextDomain()
+    {
+        $command = $this->getMockCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['source' => 'doesnotexist::bar', 'target' => 'foo::xyzzy']
+        );
+        $this->assertEquals(
+            "Could not open directory {$this->languageFixtureDir}/doesnotexist\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param ExtendedIniNormalizer $normalizer  Normalizer for .ini files
+     * @param ExtendedIniReader     $reader      Reader for .ini files
+     * @param string                $languageDir Base language file directory
+     * @param array                 $methods     Methods to mock
+     *
+     * @return CopyStringCommand
+     */
+    protected function getMockCommand(ExtendedIniNormalizer $normalizer = null,
+        ExtendedIniReader $reader = null, $languageDir = null,
+        array $methods = ['addLineToFile']
+    ) {
+        return $this->getMockBuilder(CopyStringCommand::class)
+            ->setConstructorArgs(
+                [
+                    $normalizer ?? $this->getMockNormalizer(),
+                    $reader ?? $this->getMockReader(),
+                    $languageDir ?? $this->languageFixtureDir,
+                ]
+            )->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock normalizer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniNormalizer
+     */
+    protected function getMockNormalizer($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniNormalizer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock reader object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniReader
+     */
+    protected function getMockReader($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniReader::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/DeleteCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/DeleteCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..24594ad32aa9dc7688b2c962e776656670e05f49
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/DeleteCommandTest.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Language/Delete command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Language;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+use VuFindConsole\Command\Language\DeleteCommand;
+
+/**
+ * Language/Delete command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class DeleteCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Language fixture directory
+     *
+     * @var string
+     */
+    protected $languageFixtureDir = __DIR__ . '/../../../../../fixtures/language';
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "target").'
+        );
+        $command = new DeleteCommand(
+            $this->getMockNormalizer(),
+            $this->getMockReader()
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test the simplest possible success case.
+     *
+     * @return void
+     */
+    public function testSuccessWithMinimalParameters()
+    {
+        $expectedPath = realpath($this->languageFixtureDir) . '/foo/en.ini';
+        $normalizer = $this->getMockNormalizer();
+        $normalizer->expects($this->once())->method('normalizeFile')
+            ->with($this->equalTo($expectedPath));
+        $command = $this->getMockCommand($normalizer);
+        $command->expects($this->once())->method('writeFileToDisk')
+            ->with(
+                $this->equalTo($expectedPath),
+                $this->equalTo('')
+            );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['target' => 'foo::bar']);
+        $this->assertEquals("Processing en.ini...\n", $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test an attempt to delete a string that does not exist.
+     *
+     * @return void
+     */
+    public function testDeletingNonExistentString()
+    {
+        $expectedPath = realpath($this->languageFixtureDir) . '/foo/en.ini';
+        $command = $this->getMockCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['target' => 'foo::barzap']);
+        $this->assertEquals(
+            "Processing en.ini...\nSource key not found.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param ExtendedIniNormalizer $normalizer  Normalizer for .ini files
+     * @param ExtendedIniReader     $reader      Reader for .ini files
+     * @param string                $languageDir Base language file directory
+     * @param array                 $methods     Methods to mock
+     *
+     * @return AddUsingTemplateCommand
+     */
+    protected function getMockCommand(ExtendedIniNormalizer $normalizer = null,
+        ExtendedIniReader $reader = null, $languageDir = null,
+        array $methods = ['writeFileToDisk']
+    ) {
+        return $this->getMockBuilder(DeleteCommand::class)
+            ->setConstructorArgs(
+                [
+                    $normalizer ?? $this->getMockNormalizer(),
+                    $reader ?? $this->getMockReader(),
+                    $languageDir ?? $this->languageFixtureDir,
+                ]
+            )->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock normalizer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniNormalizer
+     */
+    protected function getMockNormalizer($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniNormalizer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock reader object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniReader
+     */
+    protected function getMockReader($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniReader::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/NormalizeCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/NormalizeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7350f0082b73b2aadffc29936613b0524aebfe4
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Language/NormalizeCommandTest.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * Language/Normalize command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Language;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\I18n\ExtendedIniNormalizer;
+use VuFind\I18n\Translator\Loader\ExtendedIniReader;
+use VuFindConsole\Command\Language\NormalizeCommand;
+
+/**
+ * Language/Normalize command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class NormalizeCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Language fixture directory
+     *
+     * @var string
+     */
+    protected $languageFixtureDir = __DIR__ . '/../../../../../fixtures/language';
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "target").'
+        );
+        $command = new NormalizeCommand($this->getMockNormalizer());
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test normalizing a directory.
+     *
+     * @return void
+     */
+    public function testNormalizingDirectory()
+    {
+        $target = realpath($this->languageFixtureDir) . '/foo/';
+        $normalizer = $this->getMockNormalizer();
+        $normalizer->expects($this->once())->method('normalizeDirectory')
+            ->with($this->equalTo($target));
+        $command = new NormalizeCommand($normalizer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(compact('target'));
+        $this->assertEquals("", $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test normalizing a file.
+     *
+     * @return void
+     */
+    public function testNormalizingFile()
+    {
+        $target = realpath($this->languageFixtureDir) . '/foo/en.ini';
+        $normalizer = $this->getMockNormalizer();
+        $normalizer->expects($this->once())->method('normalizeFile')
+            ->with($this->equalTo($target));
+        $command = new NormalizeCommand($normalizer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(compact('target'));
+        $this->assertEquals("", $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test an attempt to normalize a file that does not exist.
+     *
+     * @return void
+     */
+    public function testNormalizingNonExistentFile()
+    {
+        $target = realpath($this->languageFixtureDir) . '/foo/noexist.ini';
+        $command = new NormalizeCommand($this->getMockNormalizer());
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(compact('target'));
+        $this->assertEquals(
+            "{$target} does not exist.\n", $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get a mock command object
+     *
+     * @param ExtendedIniNormalizer $normalizer  Normalizer for .ini files
+     * @param ExtendedIniReader     $reader      Reader for .ini files
+     * @param string                $languageDir Base language file directory
+     * @param array                 $methods     Methods to mock
+     *
+     * @return AddUsingTemplateCommand
+     */
+    protected function getMockCommand(ExtendedIniNormalizer $normalizer = null,
+        ExtendedIniReader $reader = null, $languageDir = null,
+        array $methods = ['writeFileToDisk']
+    ) {
+        return $this->getMockBuilder(DeleteCommand::class)
+            ->setConstructorArgs(
+                [
+                    $normalizer ?? $this->getMockNormalizer(),
+                    $reader ?? $this->getMockReader(),
+                    $languageDir ?? $this->languageFixtureDir,
+                ]
+            )->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock normalizer object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniNormalizer
+     */
+    protected function getMockNormalizer($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniNormalizer::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+
+    /**
+     * Get a mock reader object
+     *
+     * @param array $methods Methods to mock
+     *
+     * @return ExtendedIniReader
+     */
+    protected function getMockReader($methods = [])
+    {
+        return $this->getMockBuilder(ExtendedIniReader::class)
+            ->disableOriginalConstructor()
+            ->setMethods($methods)
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/ScheduledSearch/NotifyCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/ScheduledSearch/NotifyCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..befa6bb4b9999c4ab2a8c5e6b6247fa39f3ead87
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/ScheduledSearch/NotifyCommandTest.php
@@ -0,0 +1,572 @@
+<?php
+/**
+ * ScheduledSearch/Notify command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\ScheduledSearch;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\ScheduledSearch\NotifyCommand;
+
+/**
+ * ScheduledSearch/Notify command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class NotifyCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test behavior when no notifications are waiting to be sent.
+     *
+     * @return void
+     */
+    public function testNoNotifications()
+    {
+        $searchTable = $this->prepareMock(\VuFind\Db\Table\Search::class);
+        $searchTable->expects($this->once())->method('getScheduledSearches')
+            ->will($this->returnValue([]));
+        $command = $this->getCommand(
+            [
+                'searchTable' => $searchTable,
+                'scheduleOptions' => []
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 0 searches\nDone processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent but there is no
+     * matching frequency configuration.
+     *
+     * @return void
+     */
+    public function testNotificationWithIllegalFrequency()
+    {
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable(
+                    [
+                        'search_object' => null,
+                    ]
+                ),
+                'scheduleOptions' => [1 => 'Daily']
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "ERROR: Search 1: unknown schedule: 7\nDone processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications have already been sent recently.
+     *
+     * @return void
+     */
+    public function testNotificationWithRecentExecution()
+    {
+        $lastDate = date('Y-m-d h:i:s');
+        $overrides = [
+            'last_notification_sent' => $lastDate,
+            'search_object' => null,
+        ];
+        $lastDate = str_replace(' ', 'T', $lastDate) . 'Z';
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable($overrides),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "  Bypassing search 1: previous execution too recent (Weekly, $lastDate)\n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent but user does not
+     * exist.
+     *
+     * @return void
+     */
+    public function testNotificationsWithMissingUser()
+    {
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable(
+                    [
+                        'search_object' => null,
+                    ]
+                ),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "WARNING: Search 1: user 2 does not exist \n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent but an illegal backend
+     * is involved.
+     *
+     * @return void
+     */
+    public function testNotificationsWithUnsupportedBackend()
+    {
+        $resultsCallback = function ($results) {
+            $results->expects($this->any())->method('getBackendId')
+                ->will($this->returnValue('unsupported'));
+            $results->expects($this->any())->method('getSearchId')
+                ->will($this->returnValue(1));
+        };
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable(
+                    [], null, null, $resultsCallback
+                ),
+                'userTable' => $this->getMockUserTable(),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "ERROR: Unsupported search backend unsupported for search 1\n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent but no search
+     * results exist.
+     *
+     * @return void
+     */
+    public function testNotificationsWithNoSearchResults()
+    {
+        $optionsCallback = function ($options) {
+            $options->expects($this->any())->method('supportsScheduledSearch')
+                ->will($this->returnValue(true));
+        };
+        $resultsCallback = function ($results) {
+            $results->expects($this->any())->method('getSearchId')
+                ->will($this->returnValue(1));
+        };
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable(
+                    [], $optionsCallback, null, $resultsCallback
+                ),
+                'userTable' => $this->getMockUserTable(),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "  No results found for search 1\n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent but no search
+     * results exist.
+     *
+     * @return void
+     */
+    public function testNotificationsWithNoNewSearchResults()
+    {
+        $optionsCallback = function ($options) {
+            $options->expects($this->any())->method('supportsScheduledSearch')
+                ->will($this->returnValue(true));
+        };
+        $resultsCallback = function ($results) {
+            $results->expects($this->any())->method('getSearchId')
+                ->will($this->returnValue(1));
+            $results->expects($this->any())->method('getResults')
+                ->will($this->returnValue($this->getMockSearchResultsSet()));
+        };
+        $command = $this->getCommand(
+            [
+                'searchTable' => $this->getMockSearchTable(
+                    [], $optionsCallback, null, $resultsCallback
+                ),
+                'userTable' => $this->getMockUserTable(),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "  No new results for search (1): 1970-01-01T00:00:00Z < 2000-01-01T00:00:00Z\n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test behavior when notifications are waiting to be sent and new search
+     * results exist.
+     *
+     * @return void
+     */
+    public function testNotificationsWithNewSearchResults()
+    {
+        $optionsCallback = function ($options) {
+            $options->expects($this->any())->method('supportsScheduledSearch')
+                ->will($this->returnValue(true));
+        };
+        $paramsCallback = function ($params) {
+            $params->expects($this->any())->method('getCheckboxFacets')
+                ->will($this->returnValue([]));
+        };
+        $now = str_replace(' ', 'T', date('Y-m-d h:i:s')) . 'Z';
+        $record = new \VuFindTest\RecordDriver\TestHarness();
+        $record->setRawData(
+            [
+                'FirstIndexed' => $now,
+            ]
+        );
+        $resultsCallback = function ($results) use ($now, $record) {
+            $results->expects($this->any())->method('getSearchId')
+                ->will($this->returnValue(1));
+            $results->expects($this->any())->method('getResults')
+                ->will($this->returnValue($this->getMockSearchResultsSet($record)));
+        };
+        $message = 'sample message';
+        $expectedViewParams = [
+            'records' => [$record],
+            'info' => [
+                'baseUrl' => 'http://foo',
+                'description' => null,
+                'recordCount' => 1,
+                'url' => 'http://foo',
+                'unsubscribeUrl' => 'http://foo?id=1&key=',
+                'checkboxFilters' => [],
+                'filters' => null,
+                'userInstitution' => 'My Institution',
+            ],
+        ];
+        $renderer = $this->prepareMock(\Laminas\View\Renderer\PhpRenderer::class);
+        $renderer->expects($this->once())->method('render')
+            ->with(
+                $this->equalTo('Email/scheduled-alert.phtml'),
+                $this->equalTo($expectedViewParams)
+            )->will($this->returnValue($message));
+        $mailer = $this->prepareMock(\VuFind\Mailer\Mailer::class);
+        $mailer->expects($this->once())->method('send')
+            ->with(
+                $this->equalTo('fake@myuniversity.edu'),
+                $this->equalTo('admin@myuniversity.edu'),
+                $this->equalTo('My Site: translated text'),
+                $this->equalTo($message)
+            );
+        $translator = $this->prepareMock(\Laminas\I18n\Translator\Translator::class);
+        $translator->expects($this->once())->method('translate')
+            ->with($this->equalTo('Scheduled Alert Results'))
+            ->will($this->returnValue('translated text'));
+        $command = $this->getCommand(
+            [
+                'mailer' => $mailer,
+                'renderer' => $renderer,
+                'translator' => $translator,
+                'searchTable' => $this->getMockSearchTable(
+                    [], $optionsCallback, $paramsCallback, $resultsCallback
+                ),
+                'userTable' => $this->getMockUserTable(),
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "Processing 1 searches\n"
+            . "  New results for search (1): $now >= 2000-01-01T00:00:00Z\n"
+            . "Done processing searches\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Get mock search results.
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record to return
+     *
+     * @return array
+     */
+    protected function getMockSearchResultsSet($record = null)
+    {
+        return [
+            $record ?? $this->prepareMock(\VuFind\RecordDriver\SolrDefault::class)
+        ];
+    }
+
+    /**
+     * Create a list of fake notification objects.
+     *
+     * @param array     $overrides       Fields to override in the notification row.
+     * @param \Callable $optionsCallback Callback to set expectations on options object
+     * @param \Callable $paramsCallback  Callback to set expectations on params object
+     * @param \Callable $resultsCallback Callback to set expectations on results object
+     *
+     * @return array
+     */
+    protected function getMockNotifications($overrides = [], $optionsCallback = null,
+        $paramsCallback = null, $resultsCallback = null
+    ) {
+        $defaults = [
+            'id' => 1,
+            'user_id' => 2,
+            'session_id' => null,
+            'folder_id' => null,
+            'created' => '2000-01-01 00:00:00',
+            'title' => null,
+            'saved' => 1,
+            'checksum' => null,
+            'notification_frequency' => 7,
+            'last_notification_sent' => '2000-01-01 00:00:00',
+            'notification_base_url' => 'http://foo',
+        ];
+        // Don't create the mock search (and thus set up assertions) unless
+        // we actually need to. We use array_key_exists() instead of isset()
+        // because the key may be explicitly set to a value of null.
+        if (!array_key_exists('search_object', $overrides)) {
+            $defaults['search_object'] = serialize(
+                $this->getMockSearch(
+                    $optionsCallback, $paramsCallback, $resultsCallback
+                )
+            );
+        }
+        $adapter = $this->prepareMock(\Laminas\Db\Adapter\Adapter::class);
+        $row1 = $this->getMockBuilder(\VuFind\Db\Row\Search::class)
+            ->setConstructorArgs([$adapter])
+            ->setMethods(['save'])
+            ->getMock();
+        $row1->populate($overrides + $defaults, true);
+        return [$row1];
+    }
+
+    /**
+     * Get mock search results.
+     *
+     * @param \Callable $optionsCallback Callback to set expectations on options object
+     * @param \Callable $paramsCallback  Callback to set expectations on params object
+     * @param \Callable $resultsCallback Callback to set expectations on results object
+     *
+     * @return \VuFind\Search\Solr\Results
+     */
+    protected function getMockSearchResults($optionsCallback = null,
+        $paramsCallback = null, $resultsCallback = null
+    ) {
+        $options = $this->prepareMock(\VuFind\Search\Solr\Options::class);
+        if ($optionsCallback) {
+            $optionsCallback($options);
+        }
+        $urlQuery = $this->prepareMock(\VuFind\Search\UrlQueryHelper::class);
+        $params = $this->prepareMock(\VuFind\Search\Solr\Params::class);
+        if ($paramsCallback) {
+            $paramsCallback($params);
+        }
+        $results = $this->prepareMock(\VuFind\Search\Solr\Results::class);
+        $results->expects($this->any())->method('getOptions')
+            ->will($this->returnValue($options));
+        $results->expects($this->any())->method('getUrlQuery')
+            ->will($this->returnValue($urlQuery));
+        $results->expects($this->any())->method('getParams')
+            ->will($this->returnValue($params));
+        if ($resultsCallback) {
+            $resultsCallback($results);
+        }
+        return $results;
+    }
+
+    /**
+     * Get a minified search object
+     *
+     * @param \Callable $optionsCallback Callback to set expectations on options object
+     * @param \Callable $paramsCallback  Callback to set expectations on params object
+     * @param \Callable $resultsCallback Callback to set expectations on results object
+     *
+     * @return \VuFind\Search\Minified
+     */
+    protected function getMockSearch($optionsCallback = null, $paramsCallback = null,
+        $resultsCallback = null
+    ) {
+        $search = $this->prepareMock(\VuFind\Search\Minified::class);
+        $search->expects($this->any())->method('deminify')
+            ->with($this->equalTo($this->getMockResultsManager()))
+            ->will(
+                $this->returnValue(
+                    $this->getMockSearchResults(
+                        $optionsCallback, $paramsCallback, $resultsCallback
+                    )
+                )
+            );
+        return $search;
+    }
+
+    /**
+     * Get a mock row representing a user.
+     *
+     * @return \VuFind\Db\Row\Search
+     */
+    protected function getMockUserObject()
+    {
+        $data = [
+            'id' => 2,
+            'username' => 'foo',
+            'email' => 'fake@myuniversity.edu',
+            'created' => '2000-01-01 00:00:00',
+            'last_language' => 'en',
+        ];
+        $adapter = $this->prepareMock(\Laminas\Db\Adapter\Adapter::class);
+        $user = new \VuFind\Db\Row\User($adapter);
+        $user->populate($data, true);
+        return $user;
+    }
+
+    /**
+     * Get a notify command for testing.
+     *
+     * @param array $options Options to override
+     *
+     * @return NotifyCommand
+     */
+    protected function getCommand($options = [])
+    {
+        $renderer = $options['renderer']
+            ?? $this->prepareMock(\Laminas\View\Renderer\PhpRenderer::class);
+        $renderer->expects($this->any())->method('plugin')
+            ->with($this->equalTo('url'))
+            ->will($this->returnValue($this->prepareMock(\Laminas\View\Helper\Url::class)));
+        $command = new NotifyCommand(
+            $this->prepareMock(\VuFind\Crypt\HMAC::class),
+            $renderer,
+            $this->getMockResultsManager(),
+            $options['scheduleOptions'] ?? [1 => 'Daily', 7 => 'Weekly'],
+            new \Laminas\Config\Config(
+                $options['configArray'] ?? [
+                    'Site' => [
+                        'institution' => 'My Institution',
+                        'title' => 'My Site',
+                        'email' => 'admin@myuniversity.edu',
+                    ]
+                ]
+            ),
+            $options['mailer'] ?? $this->prepareMock(\VuFind\Mailer\Mailer::class),
+            $options['searchTable'] ?? $this->prepareMock(\VuFind\Db\Table\Search::class),
+            $options['userTable'] ?? $this->prepareMock(\VuFind\Db\Table\User::class)
+        );
+        $command->setTranslator(
+            $options['translator'] ?? $this->prepareMock(\Laminas\I18n\Translator\Translator::class)
+        );
+        return $command;
+    }
+
+    /**
+     * Create a mock results manager.
+     *
+     * @return \VuFind\Search\Results\PluginManager
+     */
+    protected function getMockResultsManager()
+    {
+        // Use a static variable to ensure we only create a single shared instance
+        // of the results manager.
+        static $manager = false;
+        if (!$manager) {
+            $manager = $this
+                ->prepareMock(\VuFind\Search\Results\PluginManager::class);
+        }
+        return $manager;
+    }
+
+    /**
+     * Create a mock search table that returns a list of fake notification objects.
+     *
+     * @param array     $overrides       Fields to override in the notification row.
+     * @param \Callable $optionsCallback Callback to set expectations on options object
+     * @param \Callable $paramsCallback  Callback to set expectations on params object
+     * @param \Callable $resultsCallback Callback to set expectations on results object
+     *
+     * @return array
+     */
+    protected function getMockSearchTable($overrides = [], $optionsCallback = null,
+        $paramsCallback = null, $resultsCallback = null)
+    {
+        $searchTable = $this->prepareMock(\VuFind\Db\Table\Search::class);
+        $searchTable->expects($this->once())->method('getScheduledSearches')
+            ->will(
+                $this->returnValue(
+                    $this->getMockNotifications(
+                        $overrides, $optionsCallback, $paramsCallback,
+                        $resultsCallback
+                    )
+                )
+            );
+        return $searchTable;
+    }
+
+    /**
+     * Create a mock user table that returns a fake user object.
+     *
+     * @return array
+     */
+    protected function getMockUserTable()
+    {
+        $user = $this->getMockUserObject();
+        $userTable = $this->prepareMock(\VuFind\Db\Table\User::class);
+        $userTable->expects($this->any())->method('getById')
+            ->with($this->equalTo(2))->will($this->returnValue($user));
+        return $userTable;
+    }
+
+    /**
+     * Prepare a mock object
+     *
+     * @param string $class Class to mock
+     *
+     * @return mixed
+     */
+    protected function prepareMock($class)
+    {
+        return $this->getMockBuilder($class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..576d3c04382b3687e6af49637e1e941e669a8576
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * AbstractExpireCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\AbstractExpireCommand;
+
+/**
+ * AbstractExpireCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class AbstractExpireCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Name of class being tested
+     *
+     * @var string
+     */
+    protected $targetClass = AbstractExpireCommand::class;
+
+    /**
+     * Name of a valid table class to test with
+     *
+     * @var string
+     */
+    protected $validTableClass = \VuFind\Db\Table\AuthHash::class;
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'rows';
+
+    /**
+     * Test an unsupported table class.
+     *
+     * @return void
+     */
+    public function testUnsupportedTableClass()
+    {
+        $table = $this->getMockBuilder(\VuFind\Db\Table\User::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage(
+            get_class($table) . ' does not support getExpiredIdRange()'
+        );
+        $command = new $this->targetClass($table, 'foo');
+    }
+
+    /**
+     * Test an illegal age parameter.
+     *
+     * @return void
+     */
+    public function testIllegalAgeInput()
+    {
+        $table = $this->getMockBuilder($this->validTableClass)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $command = new $this->targetClass($table, 'foo');
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['age' => 1]);
+        $this->assertEquals(
+            "Expiration age must be at least 2 days.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(1, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test that the command expires rows correctly.
+     *
+     * @return void
+     */
+    public function testSuccessfulExpiration()
+    {
+        $table = $this->getMockBuilder($this->validTableClass)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $table->expects($this->at(0))->method('getExpiredIdRange')
+            ->with($this->equalTo(2))
+            ->will($this->returnValue([0, 1500]));
+        $table->expects($this->at(1))->method('deleteExpired')
+            ->with($this->equalTo(2), $this->equalTo(0), $this->equalTo(999))
+            ->will($this->returnValue(50));
+        $table->expects($this->at(2))->method('deleteExpired')
+            ->with($this->equalTo(2), $this->equalTo(1000), $this->equalTo(1999))
+            ->will($this->returnValue(7));
+        $command = new $this->targetClass($table, 'foo');
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--sleep' => 1]);
+        $response = $commandTester->getDisplay();
+        // The response contains date stamps that will vary every time the test
+        // runs, so let's split things apart to work around that...
+        $parts = explode("\n", trim($response));
+        $this->assertEquals(2, count($parts));
+        $this->assertEquals(
+            "50 {$this->rowLabel} deleted.",
+            explode('] ', $parts[0])[1]
+        );
+        $this->assertEquals(
+            "7 {$this->rowLabel} deleted.",
+            explode('] ', $parts[1])[1]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+
+    /**
+     * Test correct behavior when no rows need to be expired.
+     *
+     * @return void
+     */
+    public function testSuccessfulNonExpiration()
+    {
+        $table = $this->getMockBuilder($this->validTableClass)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $table->expects($this->once())->method('getExpiredIdRange')
+            ->with($this->equalTo(2))
+            ->will($this->returnValue(false));
+        $command = new $this->targetClass($table, 'foo');
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $response = $commandTester->getDisplay();
+        // The response contains date stamps that will vary every time the test
+        // runs, so let's split things apart to work around that...
+        $parts = explode("\n", trim($response));
+        $this->assertEquals(1, count($parts));
+        $this->assertEquals(
+            "No {$this->rowLabel} to delete.", explode('] ', $parts[0])[1]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CleanUpRecordCacheCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CleanUpRecordCacheCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c77f04130e5edff9c65a6fbaea8d3b2851c07da4
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CleanUpRecordCacheCommandTest.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * CleanUpRecordCacheCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\CleanUpRecordCacheCommand;
+
+/**
+ * CleanUpRecordCacheCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class CleanUpRecordCacheCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that the cache clear action is delegated properly.
+     *
+     * @return void
+     */
+    public function testBasicOperation()
+    {
+        $table = $this->getMockBuilder(\VuFind\Db\Table\Record::class)
+            ->disableOriginalConstructor()->getMock();
+        $table->expects($this->once())->method('cleanup')
+            ->will($this->returnValue(5));
+        $command = new CleanUpRecordCacheCommand($table);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $expected = "5 records deleted.\n";
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CommitCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CommitCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1d0519be3119dac558f7c573fce9babed65526f
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CommitCommandTest.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * CommitCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\CommitCommand;
+
+/**
+ * CommitCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class CommitCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test success with all options set.
+     *
+     * @return void
+     */
+    public function testSuccessWithOptions()
+    {
+        $writer = $this->getMockBuilder(\VuFind\Solr\Writer::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $writer->expects($this->once())->method('commit')
+            ->with($this->equalTo('foo'));
+        $command = new CommitCommand($writer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['core' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CreateHierarchyTreesCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CreateHierarchyTreesCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..af357e7e1fb1ac09db2e4f24e4db95e6fede3da6
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CreateHierarchyTreesCommandTest.php
@@ -0,0 +1,215 @@
+<?php
+/**
+ * CreateHierarchyTreesCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Hierarchy\Driver\ConfigurationBased as HierarchyDriver;
+use VuFind\Hierarchy\TreeDataSource\Solr as TreeSource;
+use VuFind\Record\Loader;
+use VuFind\Search\Results\PluginManager;
+use VuFind\Search\Solr\Results;
+use VuFindConsole\Command\Util\CreateHierarchyTreesCommand;
+
+/**
+ * CreateHierarchyTreesCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class CreateHierarchyTreesCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Get mock hierarchy driver
+     *
+     * @return HierarchyDriver
+     */
+    protected function getMockHierarchyDriver()
+    {
+        return $this->getMockBuilder(HierarchyDriver::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get mock tree source
+     *
+     * @return TreeSource
+     */
+    protected function getMockTreeSource()
+    {
+        return $this->getMockBuilder(TreeSource::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get mock record.
+     *
+     * @param HierarchyDriver $driver Hierarchy driver
+     *
+     * @return \VuFind\RecordDriver\AbstractBase
+     */
+    protected function getMockRecord($driver = null)
+    {
+        $record = new \VuFindTest\RecordDriver\TestHarness();
+        $record->setRawData(
+            [
+                'HierarchyType' => 'foo',
+                'HierarchyDriver' => $driver ?? $this->getMockHierarchyDriver()
+            ]
+        );
+        return $record;
+    }
+
+    /**
+     * Get mock record loader.
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return Loader
+     */
+    protected function getMockRecordLoader($record = null)
+    {
+        $loader = $this->getMockBuilder(Loader::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $loader->expects($this->once())->method('load')
+            ->with($this->equalTo('recordid'), $this->equalTo('foo'))
+            ->will($this->returnValue($record ?? $this->getMockRecord()));
+        return $loader;
+    }
+
+    /**
+     * Get mock results.
+     *
+     * @return Results
+     */
+    protected function getMockResults()
+    {
+        $results = $this->getMockBuilder(Results::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $output = [
+            'hierarchy_top_id' => [
+                'data' => [
+                    'list' => [
+                        [
+                            'value' => 'recordid',
+                            'count' => 5,
+                        ]
+                    ]
+                ]
+            ]
+        ];
+        $results->expects($this->once())->method('getFullFieldFacets')
+            ->with($this->equalTo(['hierarchy_top_id']))
+            ->will($this->returnValue($output));
+        return $results;
+    }
+
+    /**
+     * Get mock results manager.
+     *
+     * @param Results $results Results object
+     *
+     * @return PluginManager
+     */
+    protected function getMockResultsManager($results = null)
+    {
+        $manager = $this->getMockBuilder(PluginManager::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $manager->expects($this->once())->method('get')
+            ->with($this->equalTo('foo'))
+            ->will($this->returnValue($results ?? $this->getMockResults()));
+        return $manager;
+    }
+
+    /**
+     * Get command to test.
+     *
+     * @param Loader        $loader  Record loader
+     * @param PluginManager $results Search results plugin manager
+     *
+     * @return SuppressedCommand
+     */
+    protected function getCommand(Loader $loader = null,
+        PluginManager $results = null
+    ) {
+        return new CreateHierarchyTreesCommand(
+            $loader ?? $this->getMockRecordLoader(),
+            $results ?? $this->getMockResultsManager()
+        );
+    }
+
+    /**
+     * Test skipping everything.
+     *
+     * @return void
+     */
+    public function testSkippingEverything()
+    {
+        $command = $this->getCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['--skip-xml' => true, '--skip-json' => true, 'backend' => 'foo']
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $expectedText = "\tBuilding tree for recordid... 5 records\n"
+            . "\t\tJSON skipped.\n\t\tXML skipped.\n1 files\n";
+        $this->assertEquals($expectedText, $commandTester->getDisplay());
+    }
+
+    /**
+     * Test populating everything.
+     *
+     * @return void
+     */
+    public function testPopulatingEverything()
+    {
+        $tree = $this->getMockTreeSource();
+        $tree->expects($this->once())->method('getJSON')
+            ->with($this->equalTo('recordid'), $this->equalTo(['refresh' => true]));
+        $tree->expects($this->once())->method('getXML')
+            ->with($this->equalTo('recordid'), $this->equalTo(['refresh' => true]));
+        $driver = $this->getMockHierarchyDriver();
+        $driver->expects($this->any())->method('getTreeSource')
+            ->will($this->returnValue($tree));
+        $loader = $this->getMockRecordLoader($this->getMockRecord($driver));
+        $command = $this->getCommand($loader);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['backend' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $expectedText = "\tBuilding tree for recordid... 5 records\n"
+            . "\t\tJSON cache...\n\t\tXML cache...\n1 files\n";
+        $this->assertEquals($expectedText, $commandTester->getDisplay());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CssBuilderCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CssBuilderCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..123c78b426dbface4ce48b679a34114418810e60
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/CssBuilderCommandTest.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * CssBuilderCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\CssBuilderCommand;
+
+/**
+ * CssBuilderCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class CssBuilderCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that the command delegates proper behavior.
+     *
+     * @return void
+     */
+    public function testBasicOperation()
+    {
+        $cacheDir = '/foo';
+        $compiler = $this->getMockBuilder(\VuFindTheme\LessCompiler::class)
+            ->disableOriginalConstructor()->getMock();
+        $compiler->expects($this->once())->method('setTempPath')
+            ->with($this->equalTo($cacheDir));
+        $compiler->expects($this->once())->method('compile')
+            ->with($this->equalTo(['foo', 'bar']));
+        $command = $this->getMockBuilder(CssBuilderCommand::class)
+            ->setMethods(['getCompiler'])
+            ->setConstructorArgs([$cacheDir])
+            ->getMock();
+        $command->expects($this->once())->method('getCompiler')
+            ->will($this->returnValue($compiler));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['themes' => ['foo', 'bar', 'foo']]);
+        $this->assertEquals('', $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DedupeCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DedupeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f5f6aa4c0c72627b775b64099f297cd9fdd6002b
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DedupeCommandTest.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * DedupeCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\DedupeCommand;
+
+/**
+ * DedupeCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class DedupeCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Get a mocked-out command object.
+     *
+     * @return DedupeCommand
+     */
+    protected function getMockCommand()
+    {
+        $mockMethods = [
+            'getInput',
+            'openOutputFile',
+            'writeToOutputFile',
+            'closeOutputFile',
+        ];
+        return $this->getMockBuilder(DedupeCommand::class)
+            ->setMethods($mockMethods)
+            ->getMock();
+    }
+
+    /**
+     * Set up basic expectations on a command.
+     *
+     * @param DedupeCommand $command  Mock command
+     * @param string        $output   Output filename
+     * @param int           $sequence Expectation sequence number
+     *
+     * @return void
+     */
+    protected function setSuccessfulExpectations($command, $output, $sequence = 0)
+    {
+        $fakeHandle = 7;    // arbitrary number for test purposes
+        $command->expects($this->at($sequence++))->method('openOutputFile')
+            ->with($this->equalTo($output))
+            ->will($this->returnValue($fakeHandle));
+        $command->expects($this->at($sequence++))->method('writeToOutputFile')
+            ->with($this->equalTo($fakeHandle), $this->equalTo("foo\n"));
+        $command->expects($this->at($sequence++))->method('writeToOutputFile')
+            ->with($this->equalTo($fakeHandle), $this->equalTo("bar\n"));
+        $command->expects($this->at($sequence++))->method('writeToOutputFile')
+            ->with($this->equalTo($fakeHandle), $this->equalTo("baz\n"));
+        $command->expects($this->at($sequence++))->method('closeOutputFile')
+            ->with($this->equalTo($fakeHandle));
+    }
+
+    /**
+     * Test that missing file yields an error message.
+     *
+     * @return void
+     */
+    public function testWithMissingFile()
+    {
+        $command = new DedupeCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['input' => '/does/not/exist']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Could not open input file: /does/not/exist\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test success with command line arguments.
+     *
+     * @return void
+     */
+    public function testSuccessWithArguments()
+    {
+        $outputFilename = '/fake/outfile';
+        $command = $this->getMockCommand();
+        $this->setSuccessfulExpectations($command, $outputFilename);
+        $commandTester = new CommandTester($command);
+        $fixture = __DIR__ . '/../../../../../fixtures/fileWithDuplicateLines';
+        $commandTester->execute(
+            [
+                'input' => $fixture,
+                'output' => $outputFilename,
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+
+    /**
+     * Test success with interactive input.
+     *
+     * @return void
+     */
+    public function testSuccessWithoutArguments()
+    {
+        $fixture = __DIR__ . '/../../../../../fixtures/fileWithDuplicateLines';
+        $outputFilename = '/fake/outfile';
+        $command = $this->getMockCommand();
+        $command->expects($this->at(0))->method('getInput')
+            ->with(
+                $this->isInstanceOf(InputInterface::class),
+                $this->isInstanceOf(OutputInterface::class),
+                $this->equalTo(
+                    'Please specify an input file: '
+                )
+            )->will($this->returnValue($fixture));
+        $command->expects($this->at(1))->method('getInput')
+            ->with(
+                $this->isInstanceOf(InputInterface::class),
+                $this->isInstanceOf(OutputInterface::class),
+                $this->equalTo(
+                    'Please specify an output file: '
+                )
+            )->will($this->returnValue($outputFilename));
+        $this->setSuccessfulExpectations($command, $outputFilename, 2);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DeletesCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DeletesCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6044aec354b54d87203759fb655c4d937fcda1ba
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/DeletesCommandTest.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * DeletesCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\DeletesCommand;
+
+/**
+ * DeletesCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class DeletesCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Get mock Solr writer.
+     *
+     * @return \VuFind\Solr\Writer
+     */
+    protected function getMockWriter()
+    {
+        return $this->getMockBuilder(\VuFind\Solr\Writer::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "filename").'
+        );
+        $writer = $this->getMockWriter();
+        $command = new DeletesCommand($writer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test that missing file yields an error message.
+     *
+     * @return void
+     */
+    public function testWithMissingFile()
+    {
+        $writer = $this->getMockWriter();
+        $command = new DeletesCommand($writer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['filename' => '/does/not/exist']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Cannot find file: /does/not/exist\n", $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test success with a flat file and default index.
+     *
+     * @return void
+     */
+    public function testSuccessWithFlatFileAndDefaultIndex()
+    {
+        $writer = $this->getMockWriter();
+        $writer->expects($this->once())->method('deleteRecords')
+            ->with($this->equalTo('Solr'), $this->equalTo(['rec1', 'rec2', 'rec3']));
+        $command = new DeletesCommand($writer);
+        $commandTester = new CommandTester($command);
+        $fixture = __DIR__ . '/../../../../../fixtures/deletes';
+        $commandTester->execute(
+            [
+                'filename' => $fixture,
+                'format' => 'flat',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+
+    /**
+     * Test success with a MARC file and non-default index.
+     *
+     * @return void
+     */
+    public function testSuccessWithMarcFileAndNonDefaultIndex()
+    {
+        $writer = $this->getMockWriter();
+        $writer->expects($this->once())->method('deleteRecords')
+            ->with($this->equalTo('foo'), $this->equalTo(['testbug2']));
+        $command = new DeletesCommand($writer);
+        $commandTester = new CommandTester($command);
+        $fixture = __DIR__ . '/../../../../../../../../tests/data/testbug2.mrc';
+        $commandTester->execute(
+            [
+                'filename' => $fixture,
+                'index' => 'foo',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuthHashesCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuthHashesCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0c3758c77ddb37c26197d6ee29de1aff96e01b7b
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuthHashesCommandTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * ExpireAuthHashesCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use VuFindConsole\Command\Util\ExpireAuthHashesCommand;
+
+/**
+ * ExpireAuthHashesCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExpireAuthHashesCommandTest extends AbstractExpireCommandTest
+{
+    /**
+     * Name of class being tested
+     *
+     * @var string
+     */
+    protected $targetClass = ExpireAuthHashesCommand::class;
+
+    /**
+     * Name of a valid table class to test with
+     *
+     * @var string
+     */
+    protected $validTableClass = \VuFind\Db\Table\AuthHash::class;
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'authentication hashes';
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireExternalSessionsCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireExternalSessionsCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2b5601cf4fb54f58c66400773d9fbc23854f109
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireExternalSessionsCommandTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * ExpireExternalSessionsCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use VuFindConsole\Command\Util\ExpireExternalSessionsCommand;
+
+/**
+ * ExpireExternalSessionsCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExpireExternalSessionsCommandTest extends AbstractExpireCommandTest
+{
+    /**
+     * Name of class being tested
+     *
+     * @var string
+     */
+    protected $targetClass = ExpireExternalSessionsCommand::class;
+
+    /**
+     * Name of a valid table class to test with
+     *
+     * @var string
+     */
+    protected $validTableClass = \VuFind\Db\Table\ExternalSession::class;
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'external sessions';
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSearchesCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSearchesCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..331128f640f237b3ae9fecd460eb7498003949f9
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSearchesCommandTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * ExpireSearchesCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use VuFindConsole\Command\Util\ExpireSearchesCommand;
+
+/**
+ * ExpireSearchesCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExpireSearchesCommandTest extends AbstractExpireCommandTest
+{
+    /**
+     * Name of class being tested
+     *
+     * @var string
+     */
+    protected $targetClass = ExpireSearchesCommand::class;
+
+    /**
+     * Name of a valid table class to test with
+     *
+     * @var string
+     */
+    protected $validTableClass = \VuFind\Db\Table\Search::class;
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'searches';
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..314daf365369dd55a807d0fc595af5a968551a68
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * ExpireSessionsCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use VuFindConsole\Command\Util\ExpireSessionsCommand;
+
+/**
+ * ExpireSessionsCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class ExpireSessionsCommandTest extends AbstractExpireCommandTest
+{
+    /**
+     * Name of class being tested
+     *
+     * @var string
+     */
+    protected $targetClass = ExpireSessionsCommand::class;
+
+    /**
+     * Name of a valid table class to test with
+     *
+     * @var string
+     */
+    protected $validTableClass = \VuFind\Db\Table\Session::class;
+
+    /**
+     * Label to use for rows in help messages.
+     *
+     * @var string
+     */
+    protected $rowLabel = 'sessions';
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/IndexReservesCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/IndexReservesCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..26bcff0c3082a67d002c58def563b063fb121ae3
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/IndexReservesCommandTest.php
@@ -0,0 +1,299 @@
+<?php
+/**
+ * IndexReservesCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\ILS\Connection;
+use VuFind\Solr\Writer;
+use VuFindConsole\Command\Util\IndexReservesCommand;
+
+/**
+ * IndexReservesCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class IndexReservesCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Get mock ILS connection.
+     *
+     * @return Connection
+     */
+    protected function getMockIlsConnection()
+    {
+        return $this->getMockBuilder(Connection::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get mock Solr writer.
+     *
+     * @return Writer
+     */
+    protected function getMockSolrWriter()
+    {
+        return $this->getMockBuilder(Writer::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get command to test.
+     *
+     * @param Writer     $solr Solr writer
+     * @param Connection $ils  ILS connection
+     *
+     * @return IndexReservesCommand
+     */
+    protected function getCommand(Writer $solr = null, Connection $ils = null)
+    {
+        return new IndexReservesCommand(
+            $solr ?? $this->getMockSolrWriter(),
+            $ils ?? $this->getMockIlsConnection()
+        );
+    }
+
+    /**
+     * Test bad parameter combination.
+     *
+     * @return void
+     */
+    public function testBadParameterCombination()
+    {
+        $command = $this->getCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--delimiter' => '|']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "-d (delimiter) is meaningless without -f (filename)\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test missing file.
+     *
+     * @return void
+     */
+    public function testBadFilename()
+    {
+        $command = $this->getCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--filename' => '/does/not/exist']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Could not open /does/not/exist!\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test successful file loading.
+     *
+     * @return void
+     */
+    public function testSuccessWithMultipleFiles()
+    {
+        $writer = $this->getMockSolrWriter();
+        $writer->expects($this->once())->method('deleteAll')
+            ->with($this->equalTo('SolrReserves'));
+        $that = $this;
+        $updateValidator = function ($update) use ($that) {
+            $expectedXml = "<?xml version=\"1.0\"?>\n"
+                . '<add>'
+                . '<doc>'
+                . '<field name="id">course1|inst1|dept1</field>'
+                . '<field name="bib_id">1</field>'
+                . '<field name="instructor_id">inst1</field>'
+                . '<field name="instructor">inst1</field>'
+                . '<field name="course_id">course1</field>'
+                . '<field name="course">course1</field>'
+                . '<field name="department_id">dept1</field>'
+                . '<field name="department">dept1</field>'
+                . '</doc>'
+                . '<doc>'
+                . '<field name="id">course2|inst2|dept2</field>'
+                . '<field name="bib_id">2</field>'
+                . '<field name="instructor_id">inst2</field>'
+                . '<field name="instructor">inst2</field>'
+                . '<field name="course_id">course2</field>'
+                . '<field name="course">course2</field>'
+                . '<field name="department_id">dept2</field>'
+                . '<field name="department">dept2</field>'
+                . '</doc>'
+                . '<doc>'
+                . '<field name="id">course3|inst3|dept3</field>'
+                . '<field name="bib_id">3</field>'
+                . '<field name="instructor_id">inst3</field>'
+                . '<field name="instructor">inst3</field>'
+                . '<field name="course_id">course3</field>'
+                . '<field name="course">course3</field>'
+                . '<field name="department_id">dept3</field>'
+                . '<field name="department">dept3</field>'
+                . '</doc>'
+                . '</add>';
+            $that->assertEquals($expectedXml, trim($update->asXml()));
+            return true;
+        };
+        $writer->expects($this->once())->method('save')
+            ->with(
+                $this->equalTo('SolrReserves'),
+                $this->callback($updateValidator)
+            );
+        $writer->expects($this->once())->method('commit')
+            ->with($this->equalTo('SolrReserves'));
+        $writer->expects($this->once())->method('optimize')
+            ->with($this->equalTo('SolrReserves'));
+        $command = $this->getCommand($writer);
+        $commandTester = new CommandTester($command);
+        $fixture1 = __DIR__ . '/../../../../../fixtures/reserves/fixture1';
+        $fixture2 = __DIR__ . '/../../../../../fixtures/reserves/fixture2';
+        $commandTester->execute(
+            [
+                '--filename' => [$fixture1, $fixture2],
+                '--delimiter' => '|',
+                '--template' => 'BIB_ID,SKIP,COURSE,DEPARTMENT,INSTRUCTOR',
+            ]
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Successfully loaded 3 rows.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test successful ILS loading.
+     *
+     * @return void
+     */
+    public function testSuccessWithILS()
+    {
+        $ils = $this->getMockIlsConnection();
+        $instructors = ['inst1' => 'inst1', 'inst2' => 'inst2', 'inst3' => 'inst3'];
+        $ils->expects($this->at(0))->method('__call')
+            ->with($this->equalTo('getInstructors'))
+            ->will($this->returnValue($instructors));
+        $courses = [
+            'course1' => 'course1', 'course2' => 'course2', 'course3' => 'course3'
+        ];
+        $ils->expects($this->at(1))->method('__call')
+            ->with($this->equalTo('getCourses'))
+            ->will($this->returnValue($courses));
+        $departments = ['dept1' => 'dept1', 'dept2' => 'dept2', 'dept3' => 'dept3'];
+        $ils->expects($this->at(2))->method('__call')
+            ->with($this->equalTo('getDepartments'))
+            ->will($this->returnValue($departments));
+        $reserves = [
+            [
+                'BIB_ID' => 1,
+                'COURSE_ID' => 'course1',
+                'DEPARTMENT_ID' => 'dept1',
+                'INSTRUCTOR_ID' => 'inst1',
+            ],
+            [
+                'BIB_ID' => 2,
+                'COURSE_ID' => 'course2',
+                'DEPARTMENT_ID' => 'dept2',
+                'INSTRUCTOR_ID' => 'inst2',
+            ],
+            [
+                'BIB_ID' => 3,
+                'COURSE_ID' => 'course3',
+                'DEPARTMENT_ID' => 'dept3',
+                'INSTRUCTOR_ID' => 'inst3',
+            ],
+        ];
+        $ils->expects($this->at(3))->method('__call')
+            ->with($this->equalTo('findReserves'), $this->equalTo(['', '', '']))
+            ->will($this->returnValue($reserves));
+        $writer = $this->getMockSolrWriter();
+        $writer->expects($this->once())->method('deleteAll')
+            ->with($this->equalTo('SolrReserves'));
+        $that = $this;
+        $updateValidator = function ($update) use ($that) {
+            $expectedXml = "<?xml version=\"1.0\"?>\n"
+                . '<add>'
+                . '<doc>'
+                . '<field name="id">course1|inst1|dept1</field>'
+                . '<field name="bib_id">1</field>'
+                . '<field name="instructor_id">inst1</field>'
+                . '<field name="instructor">inst1</field>'
+                . '<field name="course_id">course1</field>'
+                . '<field name="course">course1</field>'
+                . '<field name="department_id">dept1</field>'
+                . '<field name="department">dept1</field>'
+                . '</doc>'
+                . '<doc>'
+                . '<field name="id">course2|inst2|dept2</field>'
+                . '<field name="bib_id">2</field>'
+                . '<field name="instructor_id">inst2</field>'
+                . '<field name="instructor">inst2</field>'
+                . '<field name="course_id">course2</field>'
+                . '<field name="course">course2</field>'
+                . '<field name="department_id">dept2</field>'
+                . '<field name="department">dept2</field>'
+                . '</doc>'
+                . '<doc>'
+                . '<field name="id">course3|inst3|dept3</field>'
+                . '<field name="bib_id">3</field>'
+                . '<field name="instructor_id">inst3</field>'
+                . '<field name="instructor">inst3</field>'
+                . '<field name="course_id">course3</field>'
+                . '<field name="course">course3</field>'
+                . '<field name="department_id">dept3</field>'
+                . '<field name="department">dept3</field>'
+                . '</doc>'
+                . '</add>';
+            $that->assertEquals($expectedXml, trim($update->asXml()));
+            return true;
+        };
+        $writer->expects($this->once())->method('save')
+            ->with(
+                $this->equalTo('SolrReserves'),
+                $this->callback($updateValidator)
+            );
+        $writer->expects($this->once())->method('commit')
+            ->with($this->equalTo('SolrReserves'));
+        $writer->expects($this->once())->method('optimize')
+            ->with($this->equalTo('SolrReserves'));
+        $command = $this->getCommand($writer, $ils);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Successfully loaded 3 rows.\n",
+            $commandTester->getDisplay()
+        );
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/LintMarcCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/LintMarcCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a3552bd041c2b155fd5ea54adea7c0f235e02832
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/LintMarcCommandTest.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * LintMarc command test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\LintMarcCommand;
+
+/**
+ * LintMarc command test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class LintMarcCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "filename").'
+        );
+        $command = new LintMarcCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test that linting a file yields useful messages.
+     *
+     * @return void
+     */
+    public function testLintingFile()
+    {
+        $command = new LintMarcCommand();
+        $commandTester = new CommandTester($command);
+        $filename = __DIR__ . '/../../../../../../../../tests/data/heb.mrc';
+        $commandTester->execute(compact('filename'));
+        $expected = <<<EXPECTED
+Checking record 1 (001 = testbug1)...
+Warnings: 245: Must end with . (period).
+245: Subfield _b should be preceded by space-colon, space-semicolon, or space-equals sign.
+
+EXPECTED;
+        $this->assertEquals($expected, $commandTester->getDisplay());
+        $this->assertEquals(0, $commandTester->getStatusCode());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/OptimizeCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/OptimizeCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..42ecb3975d039169495b9249ca661f5a19635eab
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/OptimizeCommandTest.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * OptimizeCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\OptimizeCommand;
+
+/**
+ * OptimizeCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class OptimizeCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test success with all options set.
+     *
+     * @return void
+     */
+    public function testSuccessWithOptions()
+    {
+        $writer = $this->getMockBuilder(\VuFind\Solr\Writer::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $writer->expects($this->once())->method('commit')
+            ->with($this->equalTo('foo'));
+        $writer->expects($this->once())->method('optimize')
+            ->with($this->equalTo('foo'));
+        $command = new OptimizeCommand($writer);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['core' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SitemapCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SitemapCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0e0102efca7d3f145e5c5e8ad0726944deec205
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SitemapCommandTest.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * SitemapCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFindConsole\Command\Util\SitemapCommand;
+
+/**
+ * SitemapCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class SitemapCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Test success with all options set.
+     *
+     * @return void
+     */
+    public function testSuccessWithOptions()
+    {
+        $generator = $this->getMockBuilder(\VuFind\Sitemap\Generator::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $generator->expects($this->once())->method('setBaseUrl')
+            ->with($this->equalTo('http://foo'));
+        $generator->expects($this->once())->method('setBaseSitemapUrl')
+            ->with($this->equalTo('http://bar'));
+        $generator->expects($this->once())->method('generate');
+        $generator->expects($this->once())->method('getWarnings')
+            ->will($this->returnValue(['Sample warning']));
+        $command = new SitemapCommand($generator);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(
+            ['--baseurl' => 'http://foo', '--basesitemapurl' => 'http://bar']
+        );
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Sample warning\n",
+            $commandTester->getDisplay()
+        );
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SuppressedCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SuppressedCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fa88d33574068978eb2cedced7a34b1f7b665f63
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SuppressedCommandTest.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * SuppressedCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\ILS\Connection;
+use VuFind\Solr\Writer;
+use VuFindConsole\Command\Util\SuppressedCommand;
+
+/**
+ * SuppressedCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class SuppressedCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Get mock ILS connection.
+     *
+     * @return Connection
+     */
+    protected function getMockIlsConnection()
+    {
+        return $this->getMockBuilder(Connection::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get mock Solr writer.
+     *
+     * @return Writer
+     */
+    protected function getMockSolrWriter()
+    {
+        return $this->getMockBuilder(Writer::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get command to test.
+     *
+     * @param Writer     $solr Solr writer
+     * @param Connection $ils  ILS connection
+     *
+     * @return SuppressedCommand
+     */
+    protected function getCommand(Writer $solr = null, Connection $ils = null)
+    {
+        $args = [
+            $solr ?? $this->getMockSolrWriter(),
+            $ils ?? $this->getMockIlsConnection()
+        ];
+        return $this->getMockBuilder(SuppressedCommand::class)
+            ->setConstructorArgs($args)
+            ->setMethods(['writeToDisk'])
+            ->getMock();
+    }
+
+    /**
+     * Test no results coming back from ILS
+     *
+     * @return void
+     */
+    public function testNoRecordsToDelete()
+    {
+        $ils = $this->getMockIlsConnection();
+        $ils->expects($this->once())->method('__call')
+            ->with($this->equalTo('getSuppressedRecords'))
+            ->will($this->returnValue([]));
+        $command = $this->getCommand(null, $ils);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "No suppressed records to delete.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test successful Solr update.
+     *
+     * @return void
+     */
+    public function testRecordsToDelete()
+    {
+        $ils = $this->getMockIlsConnection();
+        $ils->expects($this->once())->method('__call')
+            ->with($this->equalTo('getSuppressedRecords'))
+            ->will($this->returnValue([1, 2]));
+        $solr = $this->getMockSolrWriter();
+        $solr->expects($this->once())->method('deleteRecords')
+            ->with($this->equalTo('Solr'), $this->equalTo([1, 2]));
+        $solr->expects($this->once())->method('commit')
+            ->with($this->equalTo('Solr'));
+        $solr->expects($this->once())->method('optimize')
+            ->with($this->equalTo('Solr'));
+        $command = $this->getCommand($solr, $ils);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals("", $commandTester->getDisplay());
+    }
+
+    /**
+     * Test no results coming back from ILS
+     *
+     * @return void
+     */
+    public function testNoAuthorityRecordsToDelete()
+    {
+        $ils = $this->getMockIlsConnection();
+        $ils->expects($this->once())->method('__call')
+            ->with($this->equalTo('getSuppressedAuthorityRecords'))
+            ->will($this->returnValue([]));
+        $command = $this->getCommand(null, $ils);
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--authorities' => true]);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "No suppressed records to delete.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test write to file.
+     *
+     * @return void
+     */
+    public function testWriteToFile()
+    {
+        $ils = $this->getMockIlsConnection();
+        $ils->expects($this->once())->method('__call')
+            ->with($this->equalTo('getSuppressedRecords'))
+            ->will($this->returnValue([1, 2]));
+        $command = $this->getCommand(null, $ils);
+        $command->expects($this->once())->method('writeToDisk')
+            ->with($this->equalTo('foo'), $this->equalTo("1\n2"))
+            ->will($this->returnValue(true));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--outfile' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals('', $commandTester->getDisplay());
+    }
+
+    /**
+     * Test failed write to file.
+     *
+     * @return void
+     */
+    public function testFailedWriteToFile()
+    {
+        $ils = $this->getMockIlsConnection();
+        $ils->expects($this->once())->method('__call')
+            ->with($this->equalTo('getSuppressedRecords'))
+            ->will($this->returnValue([1, 2]));
+        $command = $this->getCommand(null, $ils);
+        $command->expects($this->once())->method('writeToDisk')
+            ->with($this->equalTo('foo'), $this->equalTo("1\n2"))
+            ->will($this->returnValue(false));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['--outfile' => 'foo']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Problem writing to foo\n", $commandTester->getDisplay()
+        );
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SwitchDbHashCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SwitchDbHashCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c66c1cbc8f9399a11c48649a556cd7a42297f560
--- /dev/null
+++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/SwitchDbHashCommandTest.php
@@ -0,0 +1,312 @@
+<?php
+/**
+ * SwitchDbHashCommand test.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\Command\Util;
+
+use Laminas\Config\Config;
+use Laminas\Crypt\BlockCipher;
+use Laminas\Crypt\Symmetric\Openssl;
+use Symfony\Component\Console\Tester\CommandTester;
+use VuFind\Config\Locator;
+use VuFind\Config\Writer;
+use VuFind\Db\Table\User;
+use VuFindConsole\Command\Util\SwitchDbHashCommand;
+
+/**
+ * SwitchDbHashCommand test.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class SwitchDbHashCommandTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * Prepare a mock object
+     *
+     * @param string $class Class to mock
+     *
+     * @return mixed
+     */
+    protected function prepareMock($class)
+    {
+        return $this->getMockBuilder($class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * Get mock table object
+     *
+     * @return User
+     */
+    protected function getMockTable()
+    {
+        return $this->prepareMock(User::class);
+    }
+
+    /**
+     * Get mock command object
+     *
+     * @param array $config Config settings
+     * @param User  $table  User table gateway
+     */
+    protected function getMockCommand(array $config = [], $table = null)
+    {
+        return $this->getMockBuilder(SwitchDbHashCommand::class)
+            ->setConstructorArgs(
+                [
+                    new Config($config),
+                    $table ?? $this->getMockTable(),
+                ]
+            )->setMethods(['getConfigWriter'])
+            ->getMock();
+    }
+
+    /**
+     * Get a mock config writer
+     *
+     * @return Writer
+     */
+    protected function getMockConfigWriter()
+    {
+        return $this->prepareMock(Writer::class);
+    }
+
+    /**
+     * Test that missing parameters yield an error message.
+     *
+     * @return void
+     */
+    public function testWithoutParameters()
+    {
+        $this->expectException(
+            \Symfony\Component\Console\Exception\RuntimeException::class
+        );
+        $this->expectExceptionMessage(
+            'Not enough arguments (missing: "newmethod").'
+        );
+        $command = $this->getMockCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute([]);
+    }
+
+    /**
+     * Test missing key parameter (not in config or on command line).
+     *
+     * @return void
+     */
+    public function testWithoutKeyParameter()
+    {
+        $command = $this->getMockCommand();
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['newmethod' => 'blowfish']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "Please specify a key as the second parameter.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test no action needed because no changes requested.
+     *
+     * @return void
+     */
+    public function testNoActionNeeded()
+    {
+        $command = $this->getMockCommand(
+            [
+                'Authentication' => [
+                    'encrypt_ils_password' => true,
+                    'ils_encryption_algo' => 'blowfish',
+                    'ils_encryption_key' => 'bar',
+                ]
+            ]
+        );
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['newmethod' => 'blowfish', 'newkey' => 'bar']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $this->assertEquals(
+            "No changes requested -- no action needed.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test failed configurate write.
+     *
+     * @return void
+     */
+    public function testFailedConfigWrite()
+    {
+        $writer = $this->getMockConfigWriter();
+        $writer->expects($this->once())->method('save')
+            ->will($this->returnValue(false));
+        $command = $this->getMockCommand();
+        $command->expects($this->once())->method('getConfigWriter')
+            ->will($this->returnValue($writer));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['newmethod' => 'blowfish', 'newkey' => 'foo']);
+        $this->assertEquals(1, $commandTester->getStatusCode());
+        $expectedConfig = Locator::getLocalConfigPath('config.ini', null, true);
+        $this->assertEquals(
+            "\tUpdating $expectedConfig...\n\tWrite failed!\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Test success with no users to update.
+     *
+     * @return void
+     */
+    public function testSuccessNoUsers()
+    {
+        $writer = $this->getMockConfigWriter();
+        $writer->expects($this->at(0))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('encrypt_ils_password'),
+                $this->equalTo(true)
+            );
+        $writer->expects($this->at(1))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('ils_encryption_algo'),
+                $this->equalTo('blowfish')
+            );
+        $writer->expects($this->at(2))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('ils_encryption_key'),
+                $this->equalTo('foo')
+            );
+        $writer->expects($this->once())->method('save')
+            ->will($this->returnValue(true));
+        $table = $this->getMockTable();
+        $table->expects($this->once())->method('select')
+            ->will($this->returnValue([]));
+        $command = $this->getMockCommand([], $table);
+        $command->expects($this->once())->method('getConfigWriter')
+            ->will($this->returnValue($writer));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['newmethod' => 'blowfish', 'newkey' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $expectedConfig = Locator::getLocalConfigPath('config.ini', null, true);
+        $this->assertEquals(
+            "\tUpdating $expectedConfig...\n\tConverting hashes for 0 user(s).\n"
+            . "\tFinished.\n",
+            $commandTester->getDisplay()
+        );
+    }
+
+    /**
+     * Get a mock row representing a user.
+     *
+     * @return \VuFind\Db\Row\Search
+     */
+    protected function getMockUserObject()
+    {
+        $data = [
+            'id' => 2,
+            'username' => 'foo',
+            'email' => 'fake@myuniversity.edu',
+            'created' => '2000-01-01 00:00:00',
+            'cat_password' => 'mypassword',
+            'last_language' => 'en',
+        ];
+        $adapter = $this->prepareMock(\Laminas\Db\Adapter\Adapter::class);
+        $user = $this->getMockBuilder(\VuFind\Db\Row\User::class)
+            ->setConstructorArgs([$adapter])
+            ->setMethods(['save'])
+            ->getMock();
+        $user->populate($data, true);
+        return $user;
+    }
+
+    /**
+     * Decode a hash to confirm that it was encoded correctly.
+     */
+    protected function decode($hash)
+    {
+        $cipher = new BlockCipher(new Openssl(['algorithm' => 'blowfish']));
+        $cipher->setKey('foo');
+        return $cipher->decrypt($hash);
+    }
+
+    /**
+     * Test success with a user to update.
+     *
+     * @return void
+     */
+    public function testSuccessWithUser()
+    {
+        $writer = $this->getMockConfigWriter();
+        $writer->expects($this->at(0))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('encrypt_ils_password'),
+                $this->equalTo(true)
+            );
+        $writer->expects($this->at(1))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('ils_encryption_algo'),
+                $this->equalTo('blowfish')
+            );
+        $writer->expects($this->at(2))->method('set')
+            ->with(
+                $this->equalTo('Authentication'),
+                $this->equalTo('ils_encryption_key'),
+                $this->equalTo('foo')
+            );
+        $writer->expects($this->once())->method('save')
+            ->will($this->returnValue(true));
+        $user = $this->getMockUserObject();
+        $user->expects($this->once())->method('save');
+        $table = $this->getMockTable();
+        $table->expects($this->once())->method('select')
+            ->will($this->returnValue([$user]));
+        $command = $this->getMockCommand([], $table);
+        $command->expects($this->once())->method('getConfigWriter')
+            ->will($this->returnValue($writer));
+        $commandTester = new CommandTester($command);
+        $commandTester->execute(['newmethod' => 'blowfish', 'newkey' => 'foo']);
+        $this->assertEquals(0, $commandTester->getStatusCode());
+        $expectedConfig = Locator::getLocalConfigPath('config.ini', null, true);
+        $this->assertEquals(
+            "\tUpdating $expectedConfig...\n\tConverting hashes for 1 user(s).\n"
+            . "\tFinished.\n",
+            $commandTester->getDisplay()
+        );
+        $this->assertEquals(null, $user['cat_password']);
+        $this->assertEquals('mypassword', $this->decode($user['cat_pass_enc']));
+    }
+}
diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Route/RouteGeneratorTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Route/RouteGeneratorTest.php
deleted file mode 100644
index 171e28bb07980df7bdb9f0fa2fe3d62c6b3926c4..0000000000000000000000000000000000000000
--- a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Route/RouteGeneratorTest.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-/**
- * Route generator tests.
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2016.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  Tests
- * @author   Demian Katz <demian.katz@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
- */
-namespace VuFindConsoleTest\Route;
-
-use VuFindConsole\Route\RouteGenerator;
-
-/**
- * Route generator tests.
- *
- * @category VuFind
- * @package  Tests
- * @author   Demian Katz <demian.katz@villanova.edu>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
- */
-class CacheTest extends \PHPUnit\Framework\TestCase
-{
-    /**
-     * Test route generation
-     *
-     * @return void
-     */
-    public function testGeneration()
-    {
-        $config = [];
-        $routes = [
-            'controller1/action1' => 'controller1 action1',
-            'controller2/action2' => 'controller2 action2',
-        ];
-        $generator = new RouteGenerator();
-        $generator->addRoutes($config, $routes);
-        $expected = [
-            'console' => [
-                'router' => [
-                    'routes' => [
-                        'controller1-action1' => [
-                            'options' => [
-                                'route' => 'controller1 action1',
-                                'defaults' => [
-                                    'controller' => 'controller1',
-                                    'action' => 'action1',
-                                ],
-                            ],
-                        ],
-                        'controller2-action2' => [
-                            'options' => [
-                                'route' => 'controller2 action2',
-                                'defaults' => [
-                                    'controller' => 'controller2',
-                                    'action' => 'action2',
-                                ],
-                            ],
-                        ],
-                    ],
-                ],
-            ],
-        ];
-        $this->assertEquals($expected, $config);
-    }
-}
diff --git a/module/VuFindTheme/src/VuFindTheme/GeneratorInterface.php b/module/VuFindTheme/src/VuFindTheme/GeneratorInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..488a188f29aef36c0e6d0722cfd8400fb87b5763
--- /dev/null
+++ b/module/VuFindTheme/src/VuFindTheme/GeneratorInterface.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Interface shared by theme and mixin generator classes.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2017.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Theme
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFindTheme;
+
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Interface shared by theme and mixin generator classes.
+ *
+ * @category VuFind
+ * @package  Theme
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+interface GeneratorInterface
+{
+    /**
+     * Generate a new resource.
+     *
+     * @param string $name Name of resource to generate.
+     *
+     * @return bool
+     */
+    public function generate($name);
+
+    /**
+     * Get last error message.
+     *
+     * @return string
+     */
+    public function getLastError();
+
+    /**
+     * Set the output interface.
+     *
+     * @param OutputInterface $output Output interface
+     *
+     * @return void
+     */
+    public function setOutputInterface(OutputInterface $output): void;
+}
diff --git a/module/VuFindTheme/src/VuFindTheme/Initializer.php b/module/VuFindTheme/src/VuFindTheme/Initializer.php
index 6e8d97eb5778429f1f6e08f47ceaa79984a77710..fa5f4f2c727796132b007ff54339f723f0653436 100644
--- a/module/VuFindTheme/src/VuFindTheme/Initializer.php
+++ b/module/VuFindTheme/src/VuFindTheme/Initializer.php
@@ -29,7 +29,6 @@ namespace VuFindTheme;
 
 use Interop\Container\ContainerInterface;
 use Laminas\Config\Config;
-use Laminas\Console\Console;
 use Laminas\Mvc\MvcEvent;
 use Laminas\Stdlib\RequestInterface as Request;
 use Laminas\View\Resolver\TemplatePathStack;
@@ -201,7 +200,7 @@ class Initializer
     {
         // Load standard configuration options:
         $standardTheme = $this->config->theme;
-        if (Console::isConsole()) {
+        if (PHP_SAPI == 'cli') {
             return $standardTheme;
         }
         $mobileTheme = $this->mobile->enabled()
@@ -259,7 +258,7 @@ class Initializer
     protected function sendThemeOptionsToView()
     {
         // Get access to the view model:
-        if (!Console::isConsole()) {
+        if (PHP_SAPI !== 'cli') {
             $viewModel = $this->serviceManager->get('ViewManager')->getViewModel();
 
             // Send down the view options:
diff --git a/module/VuFindTheme/src/VuFindTheme/LessCompiler.php b/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
index b931262354bfbc1f86d62e840b3096aaaed25a68..d6bccff42af748de6326ed029c058d8d0ef5b1f0 100644
--- a/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
+++ b/module/VuFindTheme/src/VuFindTheme/LessCompiler.php
@@ -27,7 +27,7 @@
  */
 namespace VuFindTheme;
 
-use Laminas\Console\Console;
+use Symfony\Component\Console\Output\OutputInterface;
 
 /**
  * Class to compile LESS into CSS within a theme.
@@ -62,22 +62,22 @@ class LessCompiler
     protected $fakePath = '/zzzz_basepath_zzzz/';
 
     /**
-     * Console log?
+     * Output object (set for logging)
      *
-     * @var bool
+     * @var OutputInterface
      */
-    protected $verbose;
+    protected $output;
 
     /**
      * Constructor
      *
-     * @param bool $verbose Display messages while compiling?
+     * @param OutputInterface $output Output interface for logging (optional)
      */
-    public function __construct($verbose = false)
+    public function __construct(OutputInterface $output = null)
     {
         $this->basePath = realpath(__DIR__ . '/../../../../');
         $this->tempPath = sys_get_temp_dir();
-        $this->verbose = $verbose;
+        $this->output = $output;
     }
 
     /**
@@ -270,8 +270,8 @@ class LessCompiler
      */
     protected function logMessage($str)
     {
-        if ($this->verbose) {
-            Console::writeLine($str);
+        if ($this->output) {
+            $this->output->writeln($str);
         }
     }
 }
diff --git a/module/VuFindTheme/src/VuFindTheme/MixinGenerator.php b/module/VuFindTheme/src/VuFindTheme/MixinGenerator.php
index 2d2eb89ef492815459d5dd357f806ece4ea643e1..9a278a596ed8e8b57b4e8af8a56f56b5e64571a2 100644
--- a/module/VuFindTheme/src/VuFindTheme/MixinGenerator.php
+++ b/module/VuFindTheme/src/VuFindTheme/MixinGenerator.php
@@ -28,8 +28,6 @@
  */
 namespace VuFindTheme;
 
-use Laminas\Console\Console;
-
 /**
  * Class to generate a new mixin from a template.
  *
@@ -40,8 +38,10 @@ use Laminas\Console\Console;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Site
  */
-class MixinGenerator extends AbstractThemeUtility
+class MixinGenerator extends AbstractThemeUtility implements GeneratorInterface
 {
+    use \VuFindConsole\ConsoleOutputTrait;
+
     /**
      * Generate a new mixin from a template.
      *
@@ -57,12 +57,12 @@ class MixinGenerator extends AbstractThemeUtility
         if (realpath($baseDir . $name)) {
             return $this->setLastError('Mixin "' . $name . '" already exists');
         }
-        Console::writeLine('Creating new mixin: "' . $name . '"');
+        $this->writeln('Creating new mixin: "' . $name . '"');
         $source = $baseDir . $template;
         $dest = $baseDir . $name;
-        Console::writeLine("\tCopying $template");
-        Console::writeLine("\t\tFrom: " . $source);
-        Console::writeLine("\t\tTo: " . $dest);
+        $this->writeln("\tCopying $template");
+        $this->writeln("\t\tFrom: " . $source);
+        $this->writeln("\t\tTo: " . $dest);
         return $this->copyDir($source, $dest);
     }
 }
diff --git a/module/VuFindTheme/src/VuFindTheme/ThemeGenerator.php b/module/VuFindTheme/src/VuFindTheme/ThemeGenerator.php
index 20d744ab6d10c6e8ac1ce05c23213d6be1454f7e..68ae464ea7c7015801ab487e67c43b5fc5974e09 100644
--- a/module/VuFindTheme/src/VuFindTheme/ThemeGenerator.php
+++ b/module/VuFindTheme/src/VuFindTheme/ThemeGenerator.php
@@ -29,7 +29,6 @@
 namespace VuFindTheme;
 
 use Laminas\Config\Config;
-use Laminas\Console\Console;
 use VuFind\Config\Locator as ConfigLocator;
 use VuFind\Config\Writer as ConfigWriter;
 
@@ -43,8 +42,10 @@ use VuFind\Config\Writer as ConfigWriter;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Site
  */
-class ThemeGenerator extends AbstractThemeUtility
+class ThemeGenerator extends AbstractThemeUtility implements GeneratorInterface
 {
+    use \VuFindConsole\ConsoleOutputTrait;
+
     /**
      * Generate a new theme from a template.
      *
@@ -60,12 +61,12 @@ class ThemeGenerator extends AbstractThemeUtility
         if (realpath($baseDir . $name)) {
             return $this->setLastError('Theme "' . $name . '" already exists');
         }
-        Console::writeLine('Creating new theme: "' . $name . '"');
+        $this->writeln('Creating new theme: "' . $name . '"');
         $source = $baseDir . $themeTemplate;
         $dest = $baseDir . $name;
-        Console::writeLine("\tCopying $themeTemplate");
-        Console::writeLine("\t\tFrom: " . $source);
-        Console::writeLine("\t\tTo: " . $dest);
+        $this->writeln("\tCopying $themeTemplate");
+        $this->writeln("\t\tFrom: " . $source);
+        $this->writeln("\t\tTo: " . $dest);
         return $this->copyDir($source, $dest);
     }
 
@@ -86,8 +87,8 @@ class ThemeGenerator extends AbstractThemeUtility
             return $this
                 ->setLastError("Expected configuration file missing: $configPath");
         }
-        Console::writeLine("\tUpdating $configPath...");
-        Console::writeLine("\t\t[Site] > theme = $name");
+        $this->writeln("\tUpdating $configPath...");
+        $this->writeln("\t\t[Site] > theme = $name");
         $writer = new ConfigWriter($configPath);
         $writer->set('Site', 'theme', $name);
         // Enable dropdown
@@ -96,7 +97,7 @@ class ThemeGenerator extends AbstractThemeUtility
             'custom' => strtolower(str_replace(' ', '', $name))
         ];
         // - Set alternate_themes
-        Console::writeLine("\t\t[Site] > alternate_themes");
+        $this->writeln("\t\t[Site] > alternate_themes");
         $altSetting = [];
         if (isset($config->Site->alternate_themes)) {
             $alts = explode(',', $config->Site->alternate_themes);
@@ -115,7 +116,7 @@ class ThemeGenerator extends AbstractThemeUtility
         $altSetting[] = $settingPrefixes['custom'] . ':' . $name;
         $writer->set('Site', 'alternate_themes', implode(',', $altSetting));
         // - Set selectable_themes
-        Console::writeLine("\t\t[Site] > selectable_themes");
+        $this->writeln("\t\t[Site] > selectable_themes");
         $dropSetting = [
             $settingPrefixes['bootstrap'] . ':Bootstrap',
             $settingPrefixes['custom'] . ':' . ucwords($name)
diff --git a/public/index.php b/public/index.php
index 125fe7220b5dc1ae77f5aee3cab0ccb847ac433f..ddae01413d8b955b390047d548cfa3a5ef9eabbd 100644
--- a/public/index.php
+++ b/public/index.php
@@ -80,4 +80,10 @@ if (!class_exists('Laminas\Loader\AutoloaderFactory')) {
 }
 
 // Run the application!
-Laminas\Mvc\Application::init(require 'config/application.config.php')->run();
+$app = Laminas\Mvc\Application::init(require 'config/application.config.php');
+if (PHP_SAPI === 'cli') {
+    return $app->getServiceManager()
+        ->get(\VuFindConsole\ConsoleRunner::class)->run();
+} else {
+    $app->run();
+}
diff --git a/util/dedupe.php b/util/dedupe.php
index d5cc104cca3d7e4fe3281188eb1bbd68ba6de88d..a899aa19863dfaec5497d0c26637878bc242463d 100644
--- a/util/dedupe.php
+++ b/util/dedupe.php
@@ -2,7 +2,7 @@
 /**
  * Remove duplicate lines from a file -- needed for the Windows version of
  * the alphabetical browse database generator, since Windows sort does not
- * support deduplication. Assumed presorted
+ * support deduplication. Assumed presorted.
  *
  * PHP version 7
  *
@@ -27,31 +27,8 @@
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/indexing:alphabetical_heading_browse Wiki
  */
-if(count($argv) < 2 || $argv[1] == "") {
-    echo "\nPlease specify an input file: ";
-    $argv[1] = chop(fgets(STDIN)); // Read the input
-}
-$in = fopen($argv[1], 'r');
-if (!$in) {
-    die('Could not open input file: '.$argv[1]."\n");
-}
 
-if(count($argv) < 3 || $argv[2] == "") {
-    echo "\nPlease specify an output file: ";
-    $argv[2] = chop(fgets(STDIN)); // Read the input
-}
-$out = fopen($argv[2], 'w');
-if (!$out) {
-    die('Could not open output file: '.$argv[2]."\n");
-}
-
-$last = '';
-while ($tmp = fgets($in)) {
-    if ($tmp != $last) {
-        fputs($out, $tmp);
-    }
-    $last = $tmp;
-}
-
-fclose($in);
-fclose($out);
\ No newline at end of file
+// Manipulate command line to load correct route, then run the main index page:
+array_unshift($_SERVER['argv'], array_shift($_SERVER['argv']), 'util', 'dedupe');
+$_SERVER['argc'] += 2;
+require_once __DIR__ . '/../public/index.php';