diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c2ca5eacd9a4eb429380c3f645237f2ec3fb69e..cb3cbaa424f9dad204ef996641aa02298873e9b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,17 @@
 # `fsl_add_module` changelog
 
 
+## 0.5.0 (Monday 5th June 2023)
+
+ - New `fsl_generate_module_manifest` command, to generate a JSON manifest
+   for a set of files.
+ - Added ability to refer to 'built-in' manifests by aliases - the default
+   manifest is referred to as `fslcourse`, and the Graduate Programme manifest
+   as `gradcourse`, e.g. `fsl_add_module -m gradcourse`.
+ - Added support for a `size` field in manifest entries, specifying the file
+   size in bytes.
+
+
 ## 0.4.0 (Tuesday 24th January 2023)
 
  - Changed the default behaviour so that only files in the `fsl_course_data`
diff --git a/fsl/add_module/__init__.py b/fsl/add_module/__init__.py
index 83da9f81721da6fa8bad53e6120e0ac6ea9c0de5..62c9a92f7ec757c1e831e5ce0d244524ed5050a9 100644
--- a/fsl/add_module/__init__.py
+++ b/fsl/add_module/__init__.py
@@ -9,5 +9,5 @@
 """
 
 
-__version__ = '0.4.0'
+__version__ = '0.5.0'
 """``fsl_add_module`` version number."""
diff --git a/fsl/add_module/plugin_manifest.py b/fsl/add_module/manifest.py
similarity index 76%
rename from fsl/add_module/plugin_manifest.py
rename to fsl/add_module/manifest.py
index 1da82cad645f2e314b0aa53765eabb84d098cd57..d32db16f9f20909cecdedb279236862ce28f3147 100644
--- a/fsl/add_module/plugin_manifest.py
+++ b/fsl/add_module/manifest.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python
 #
-# plugin_manifest.py - Classes and functions for working with FSL plugin
-#                      manifest files.
+# manifest.py - Classes and functions for working with FSL plugin
+#               manifest files.
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
@@ -15,6 +15,7 @@ list of FSL plugin definitions, e.g.::
             "category"    : "fsl_course_data",
             "url"         : "http://.../UnixIntro.zip",
             "checksum"    : "f8222...",
+            "size"        : "15677939",
             "description" : "Data for the unix introduction",
             "version"     : "1.2.3",
             "destination" : "~/"
@@ -24,6 +25,7 @@ list of FSL plugin definitions, e.g.::
             "category"     : "fsl_atlases",
             "url"          : "http://localhost:8000/abcd123_mouse_atlas.tar.gz",
             "checksum"     : "658701ff9a49b06f0d0ee38bce36a1c370fdfd0751ab17c2fd7b7b561d3b92e0",
+            "size"         : "123841532",
             "description"  : "ABCD123 FSL mouse atlas derived from hand segmentations of 3813 small chocolate mice.",
             "terms_of_use" : "https://chocolate-mouse.com/terms-of-use",
             "destination"  : "$FSLDIR/data/atlases/"
@@ -39,6 +41,7 @@ optional but recommended:
   - ``category``:     The plugin category, used to organise plugins.
   - ``checksum``:     A SHA256 checksum of the plugin archive file, used to
                       verify that it has been successfully downloaded.
+  - ``size``:         Size of the file in bytes
   - ``description``:  An extended description of the plugin
   - ``version``:      A version identifier for the plugin (used solely for
                       descriptive purposes)
@@ -98,12 +101,15 @@ class Plugin:
     """SHA256 checksum of the plugin archive file, used to verify downloads.
     """
 
+    size : Optional[int] = None
+    """Size of the archive file in bytes. """
+
     version : Optional[str] = None
     """Version identifier for the plugin - displayed to the user, but not
     used for any other purpose.
     """
 
-    termsOfUse : Optional[str] = None
+    terms_of_use : Optional[str] = None
     """Terms of use for the plugin - displayed to the user during installation.
     """
 
@@ -137,13 +143,21 @@ class Plugin:
     @property
     def fileName(self):
         """Return a suitable name to use for the downloaded plugin file.
+        The file name may be prefixed with the plugin file category.
         """
-        fname = op.basename(urlparse.urlparse(self.url).path)
+        fname = self.rawFileName
         if self.category not in (None, UNCATEGORISED):
             fname = f'{self.category}_{fname}'
         return routines.sanitiseFileName(fname)
 
 
+    @property
+    def rawFileName(self):
+        """Return the file name of the plugin file as it appears in te URL.
+        """
+        return op.basename(urlparse.urlparse(self.url).path)
+
+
 class Manifest(dict):
     """The ``Manifest``class simply a container for :class:`Plugin` instances.
     A ``Manifest`` is a dict containing ``{name : Plugin}`` mappings.
@@ -154,27 +168,29 @@ class Manifest(dict):
 
 
     def addPlugin(self,
-                  url         : Union[str, pathlib.Path],
-                  name        : Optional[str] = None,
-                  description : Optional[str] = None,
-                  category    : Optional[str] = None,
-                  checksum    : Optional[str] = None,
-                  version     : Optional[str] = None,
-                  termsOfUse  : Optional[str] = None,
-                  destination : Optional[str] = None) -> str:
+                  url          : Union[str, pathlib.Path],
+                  name         : Optional[str] = None,
+                  description  : Optional[str] = None,
+                  category     : Optional[str] = None,
+                  checksum     : Optional[str] = None,
+                  size         : Optional[int] = None,
+                  version      : Optional[str] = None,
+                  terms_of_use : Optional[str] = None,
+                  destination  : Optional[str] = None) -> str:
         """Add a new :class:`Plugin` to the manifest.
 
-        :arg url:         URL of plugin archive file.
-        :arg name:        Plugin name. Defaults to filename component of
-                          ``url``.
-        :arg description: Extended description of plugin
-        :arg category:    Plugin category
-        :arg checksum:    SHA256 checksum of archive file.
-        :arg version:     Version identifier for the plugin.
-        :arg termsOfUse:  Terms of use for the plugin.
-        :arg destination: Default installation directory.
-        :returns:         The registered plugin name (equivalent to ``name``,
-                          if provided).
+        :arg url:          URL of plugin archive file.
+        :arg name:         Plugin name. Defaults to filename component of
+                           ``url``.
+        :arg description:  Extended description of plugin
+        :arg category:     Plugin category
+        :arg checksum:     SHA256 checksum of archive file.
+        :arg size:         Size of archive file in bytes.
+        :arg version:      Version identifier for the plugin.
+        :arg terms_of_use: Terms of use for the plugin.
+        :arg destination:  Default installation directory.
+        :returns:          The registered plugin name (equivalent to ``name``,
+                           if provided).
         """
 
         # support paths to local files
@@ -213,8 +229,9 @@ class Manifest(dict):
             category=category,
             description=description,
             checksum=checksum,
+            size=size,
             version=version,
-            termsOfUse=termsOfUse,
+            terms_of_use=terms_of_use,
             destination=destination,
             origDestination=origDestination,
             archiveFile=archiveFile,
@@ -264,7 +281,7 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest:
     with tempfile.TemporaryDirectory() as d:
         dest = op.join(d, 'manifest.txt')
         routines.downloadFile(url, dest, progress=False)
-        rawmanifest = open(dest, 'rt').read()
+        rawmanifest = open(dest, 'rt', encoding='utf-8').read()
 
     try:
         rawmanifest = json.loads(rawmanifest)
@@ -276,14 +293,17 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest:
 
         # plugins must at least contain a URL
         try:
+            try:              size = int(plugin.get('size'))
+            except Exception: size = None
             manifest.addPlugin(
                 url=plugin['url'],
                 name=plugin.get('name'),
                 description=plugin.get('description'),
                 category=plugin.get('category'),
                 checksum=plugin.get('checksum'),
+                size=size,
                 version=plugin.get('version'),
-                termsOfUse=plugin.get('terms_of_use'),
+                terms_of_use=plugin.get('terms_of_use'),
                 destination=plugin.get('destination'))
         except KeyError as e:
             raise ManifestInvalid(f'The manifest file {url} '
@@ -291,3 +311,15 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest:
                                   'definition: {plugin}') from e
 
     return manifest
+
+
+def genManifest(manifest : Manifest) -> str:
+    """Converts the given manifest to JSON. """
+
+    fields  = ['name', 'url', 'category', 'checksum', 'size', 'description',
+               'version', 'destination', 'terms_of_use']
+    plugins = list(manifest.values())
+    plugins = [dataclasses.asdict(p)                           for p in plugins]
+    plugins = [{f : p[f] for f in fields}                      for p in plugins]
+    plugins = [{k : v for k, v in p.items() if v is not None } for p in plugins]
+    return json.dumps(plugins, indent=4)
diff --git a/fsl/add_module/tests/test_fsl_add_module.py b/fsl/add_module/tests/test_fsl_add_module.py
index 66a45b3d8d2404afbe8ac0c3fc9c18709e80d4de..c8e68f084c66bf968a6f37f8c0d9fb1fd272a9a6 100644
--- a/fsl/add_module/tests/test_fsl_add_module.py
+++ b/fsl/add_module/tests/test_fsl_add_module.py
@@ -73,14 +73,14 @@ def test_loadManifest_different_sources():
         with pytest.raises(RuntimeError):
             fam.loadManifest(fam.parseArgs(['-m', 'nomanifest']))
 
-        # otherwise should work ok
+        # Specify plugin URL
         m, p = fam.loadManifest(
             fam.parseArgs(['-m', 'nomanifest', 'http://abc.com/plugin.zip']))
         assert p == ['plugin.zip']
         assert list(m.keys()) == ['plugin.zip']
 
         # default manifest
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', url):
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', url):
             m, p = fam.loadManifest(fam.parseArgs([]))
             assert len(p) == 0
             assert list(m.keys()) == ['abc', 'def']
@@ -158,7 +158,7 @@ def test_loadManifest_destination_specified():
 
 
 def test_selectPlugins_from_filepath_and_url():
-    with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None):
+    with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None):
         args    = fam.parseArgs('abc.zip def.zip -d dest'.split())
         m, p    = fam.loadManifest(args)
         plugins = fam.selectPlugins(args, m, p)
@@ -188,7 +188,7 @@ def test_selectPlugins_name_from_manifest():
 
 
 def test_selectPlugins_no_destination_specified():
-    with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None):
+    with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None):
 
         args    = fam.parseArgs('abc.zip def.zip'.split())
         m, p    = fam.loadManifest(args)
@@ -389,9 +389,9 @@ def test_main_noargs():
 
         # patch os.environ in case FSLDIR is set
         fammod = 'fsl.scripts.fsl_add_module'
-        with mock.patch(f'{fammod}.DEFAULT_MANIFEST_URL', f'{cwd}/manifest.json'), \
-             mock.patch(f'{fammod}.DEFAULT_CATEGORY',     None), \
-             mock.patch(f'{fammod}.ARCHIVE_DIR',          f'{cwd}/archives'), \
+        with mock.patch(f'{fammod}.DEFAULT_MANIFEST', f'{cwd}/manifest.json'), \
+             mock.patch(f'{fammod}.DEFAULT_CATEGORY', None), \
+             mock.patch(f'{fammod}.ARCHIVE_DIR',      f'{cwd}/archives'), \
              mock.patch.dict(os.environ, clear=True):
 
             # user will be asked what plugins they want,
@@ -414,7 +414,7 @@ def test_main_list():
         with open('manifest.json', 'wt') as f:
             f.write(json.dumps(manifest))
 
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL',
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST',
                         f'{cwd}/manifest.json'):
             fam.main(['-l'])
             fam.main('-l -v'.split())
@@ -511,8 +511,8 @@ def test_main_plugin_paths():
         args = f'./abc.zip http://localhost:{srv.port}/def.zip'.split()
 
         fammod = 'fsl.scripts.fsl_add_module'
-        with mock.patch(f'{fammod}.DEFAULT_MANIFEST_URL', None), \
-             mock.patch(f'{fammod}.ARCHIVE_DIR',          f'{cwd}/archives'), \
+        with mock.patch(f'{fammod}.DEFAULT_MANIFEST', None), \
+             mock.patch(f'{fammod}.ARCHIVE_DIR',      f'{cwd}/archives'), \
              mock.patch.dict(os.environ, clear=True):
 
             with mock_input(''):
@@ -615,7 +615,7 @@ def test_main_skip_already_downloaded():
 
         # direct download - if file exists, it is
         # assumed to be ok, and not re-downloaded.
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None):
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None):
             fam.main(f'{url["abc"]} -a {archiveDir} -d {destDir} -f'.split())
         assert os.stat(archivePath['abc']).st_mtime_ns == mtime['abc']
         check_dir(destDir, ['a/b', 'c/d'])
@@ -652,7 +652,7 @@ def test_main_customise_plugin_dir():
         os.mkdir(defdest)
         os.mkdir(ghidest)
 
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None), \
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None), \
              mock_input('c', abcdest, defdest, ghidest):
             fam.main(f'abc.zip def.zip ghi.zip'.split())
 
@@ -743,7 +743,7 @@ def test_default_categories():
         make_archive('d.zip', 'd/d.txt')
 
         # default -> only fsl_course_data downloaded
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL',
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST',
                         f'{cwd}/manifest.json'):
             fam.main('-f -a archives -d .'.split())
         check_dir('.',
@@ -758,7 +758,7 @@ def test_default_categories():
         shutil.rmtree('d')
 
         # --category all -> all modules downloaded
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL',
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST',
                         f'{cwd}/manifest.json'):
             fam.main('-f -a archives -d . -c all'.split())
         check_dir('.',
@@ -775,7 +775,7 @@ def test_default_categories():
         shutil.rmtree('d')
 
         # --category <something> -> <something> modules downloaded
-        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL',
+        with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST',
                         f'{cwd}/manifest.json'):
             fam.main('-f -a archives -d . -c patches'.split())
         check_dir('.',
diff --git a/fsl/add_module/tests/test_fsl_generate_module_manifest.py b/fsl/add_module/tests/test_fsl_generate_module_manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..7752fc44963260650667ec6edc77953f334360fb
--- /dev/null
+++ b/fsl/add_module/tests/test_fsl_generate_module_manifest.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+#
+# test_fsl_generate_module_manifest.py -
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+
+import os.path as op
+import json
+import shlex
+import textwrap as tw
+
+from . import tempdir, touch
+
+import fsl.add_module.routines as routines
+
+import fsl.scripts.fsl_generate_module_manifest as fgmm
+
+
+def test_fsl_generate_module_manifest():
+    plugins = ['1.zip', '2.zip', '3.zip']
+    existing = tw.dedent("""
+    [{
+        "name": "1.zip",
+        "url": "http://a.com/1.zip",
+        "category": "cat1",
+        "description": "desc1",
+        "checksum": "baba",
+        "size": 3
+    },
+    {
+        "name": "2.zip",
+        "url": "http://a.com/2.zip",
+        "category": "cat2",
+        "description": "desc2",
+        "checksum": "abcdf",
+        "size": 4
+    },
+    {
+        "name": "3.zip",
+        "url": "http://a.com/3.zip",
+        "category": "cat3",
+        "description": "desc3",
+        "checksum": "aew",
+        "size": 10
+    }]
+    """).strip()
+    with tempdir():
+        for p in plugins:
+            touch(p)
+
+        fgmm.main(shlex.split('-o manifest.json') + plugins)
+
+        with open('manifest.json', 'rt', encoding='utf-8') as f:
+            got = json.load(f)
+        assert len(got) == len(plugins)
+        for expp, gotp in zip(plugins, got):
+            assert gotp['name']     == expp
+            assert gotp['size']     == op.getsize(expp)
+            assert gotp['checksum'] == routines.calcChecksum(expp)
+
+        with open('existing.json', 'wt', encoding='utf-8') as f:
+            f.write(existing)
+
+        fgmm.main(shlex.split('-o manifest.json -m existing.json') + plugins)
+
+        with open('manifest.json', 'rt', encoding='utf-8') as f:
+            got = json.load(f)
+
+        assert len(got) == len(plugins)
+        for i, (expp, gotp) in enumerate(zip(plugins, got), 1):
+            assert gotp['name']        == expp
+            assert gotp['size']        == op.getsize(expp)
+            assert gotp['checksum']    == routines.calcChecksum(expp)
+            assert gotp['category']    == f'cat{i}'
+            assert gotp['description'] == f'desc{i}'
+            assert gotp['url']         == f'http://a.com/{i}.zip'
diff --git a/fsl/add_module/tests/test_plugin_manifest.py b/fsl/add_module/tests/test_manifest.py
similarity index 89%
rename from fsl/add_module/tests/test_plugin_manifest.py
rename to fsl/add_module/tests/test_manifest.py
index d04c96b4114fff6843433e9bd0bf0e003843a2d9..61aff61afb5c10f94325a782a52beb8d7c0b7cc6 100644
--- a/fsl/add_module/tests/test_plugin_manifest.py
+++ b/fsl/add_module/tests/test_manifest.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 #
-# test_plugin_manifest.py -
+# test_manifest.py -
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
@@ -14,7 +14,7 @@ from unittest import mock
 
 from . import tempdir, touch
 
-import fsl.add_module.plugin_manifest as plgman
+import fsl.add_module.manifest as plgman
 
 
 def test_downloadManifest():
@@ -115,3 +115,17 @@ def test_addPlugin():
         assert manifest['plugin13'].url == 'http://abc.com/plugin13'
         manifest.addPlugin('http://abc.com/plugin14', name='plugin13')
         assert manifest['plugin13'].url == 'http://abc.com/plugin14'
+
+
+def test_genManifest():
+    m = plgman.Manifest()
+    m['plugin1'] = plgman.Plugin(url='http://a.b.c.1', name='plugin1', category='abc')
+    m['plugin2'] = plgman.Plugin(url='http://a.b.c.2', name='plugin2', description='Second')
+
+    exp = [{'name' : 'plugin1', 'url' : 'http://a.b.c.1', 'category' : 'abc'},
+           {'name' : 'plugin2', 'url' : 'http://a.b.c.2', 'category' : 'uncategorised',
+            'description' : 'Second'}]
+
+    got = plgman.genManifest(m)
+
+    assert exp == json.loads(got)
diff --git a/fsl/add_module/tests/test_ui.py b/fsl/add_module/tests/test_ui.py
index da7fdfc4f8134751d6fd15bdc7694585a1b6414a..f476aa4dcd4aa6ea089966496d3b52ad22198514 100644
--- a/fsl/add_module/tests/test_ui.py
+++ b/fsl/add_module/tests/test_ui.py
@@ -14,9 +14,9 @@ import pathlib as plib
 
 import pytest
 
-import fsl.add_module.routines        as routines
-import fsl.add_module.plugin_manifest as plgman
-import fsl.add_module.ui              as ui
+import fsl.add_module.routines as routines
+import fsl.add_module.manifest as plgman
+import fsl.add_module.ui       as ui
 
 from . import tempdir, server, mock_input, make_archive, touch
 
diff --git a/fsl/add_module/ui.py b/fsl/add_module/ui.py
index d73af279d9654122f13af43fc29d7d176cf236d8..506648eed9b97fb4a58e79acbe8dca9ad97b24ad 100644
--- a/fsl/add_module/ui.py
+++ b/fsl/add_module/ui.py
@@ -14,22 +14,22 @@ import            pathlib
 
 from typing import Tuple, List, Union
 
-from   fsl.add_module.routines        import (expand,
-                                              calcChecksum,
-                                              downloadFile,
-                                              CorruptArchive,
-                                              extractArchive)
-from   fsl.add_module.plugin_manifest import (Plugin,
-                                              Manifest,
-                                              downloadManifest)
-from   fsl.add_module.messages        import (info,
-                                              important,
-                                              question,
-                                              warning,
-                                              error,
-                                              prompt,
-                                              EMPHASIS,
-                                              UNDERLINE)
+from fsl.add_module.routines import (expand,
+                                     calcChecksum,
+                                     downloadFile,
+                                     CorruptArchive,
+                                     extractArchive)
+from fsl.add_module.manifest import (Plugin,
+                                     Manifest,
+                                     downloadManifest)
+from fsl.add_module.messages import (info,
+                                     important,
+                                     question,
+                                     warning,
+                                     error,
+                                     prompt,
+                                     EMPHASIS,
+                                     UNDERLINE)
 
 
 def downloadPluginManifest(url : Union[str, pathlib.Path]) -> Manifest:
@@ -62,10 +62,15 @@ def printPlugin(plugin  : Plugin,
     :arg verbose: If ``True``, more information is printed.
     """
 
-    if index:
-        info(f'{index:2d} {plugin.name:25s}', EMPHASIS, indent=2)
-    else:
-        info(f'{plugin.name:25s}', EMPHASIS, indent=2)
+    header = plugin.name
+
+    if index is not None:
+        header = f'{index:2d} {header}'
+    if plugin.size is not None:
+        mbytes = plugin.size / 1048576
+        header = f'{header} [{mbytes:0.2f} MB]'
+
+    info(header, EMPHASIS, indent=2)
 
     if plugin.version is not None:
         info(f'[version {plugin.version}]', indent=4)
@@ -73,11 +78,11 @@ def printPlugin(plugin  : Plugin,
     if plugin.description is not None:
         info(plugin.description, indent=4, wrap=True)
 
-    if plugin.termsOfUse is not None:
+    if plugin.terms_of_use is not None:
         info('Installation of this plugin is subject '
              'to the following terms of use:',
              indent=4)
-        info(plugin.termsOfUse, indent=6, wrap=True)
+        info(plugin.terms_of_use, indent=6, wrap=True)
 
     if verbose:
         for item in ('url', 'destination', 'checksum'):
diff --git a/fsl/scripts/fsl_add_module.py b/fsl/scripts/fsl_add_module.py
index edfb74450207a1c681c422ef658b9876874cffd2..787d3bb508b30801260ba5ecb604741008975066 100755
--- a/fsl/scripts/fsl_add_module.py
+++ b/fsl/scripts/fsl_add_module.py
@@ -35,9 +35,9 @@ Plugin manifest file
 
 The plugin manifest file is a JSON file which contains information about all
 plugins that are available. An official manifest file is hosted on the FSL
-website, but an alternative manifest file may be specified when this script
-is invoked. See the :mod:`.plugin_manifest` module for more details on the
-format of a plugin manifest file.
+website, but an alternative manifest file may be specified when this script is
+invoked. See the :mod:`.manifest` module for more details on the format of a
+plugin manifest file.
 
 
 Usage examples
@@ -102,24 +102,32 @@ import                   argparse
 
 from typing import List, Tuple
 
-import fsl.add_module.ui              as     ui
-import fsl.add_module.routines        as     routines
-import fsl.add_module.plugin_manifest as     plgman
-from   fsl.add_module                 import __version__
-from   fsl.add_module.messages        import (info,
-                                              important,
-                                              warning,
-                                              error,
-                                              EMPHASIS,
-                                              UNDERLINE)
-
-
-DEFAULT_MANIFEST_URL = 'http://fsl.fmrib.ox.ac.uk/fslcourse/downloads/manifest.json'
-"""Location of the official FSL plugin manifest file, downloaded if an
-alternate manifest file is not specified.
+import fsl.add_module.ui        as     ui
+import fsl.add_module.routines  as     routines
+import fsl.add_module.manifest  as     plgman
+from   fsl.add_module           import __version__
+from   fsl.add_module.messages  import (info,
+                                        important,
+                                        warning,
+                                        error,
+                                        EMPHASIS,
+                                        UNDERLINE)
+
+
+OFFICIAL_MANIFEST_URLS = {
+    'fslcourse'  : 'http://fsl.fmrib.ox.ac.uk/fslcourse/downloads/manifest.json',
+    'gradcourse' : 'https://fsl.fmrib.ox.ac.uk/fslcourse/graduate/downloads/manifest.json'
+}
+"""Locations of the official FSL plugin manifest files, downloaded if an
+alternate manifest file is not specified. These 'built-in' manifest URLs
+can be referred to by their aliases.
 """
 
 
+DEFAULT_MANIFEST = 'fslcourse'
+"""Default manifest to use, if one is not specified."""
+
+
 DEFAULT_CATEGORY = 'fsl_course_data'
 """Default value for the ``--category`` command-line option, when the
 user does not specify an alternative ``--manifest`` or ``--category``.
@@ -139,9 +147,12 @@ def parseArgs(argv : List[str]) -> argparse.Namespace:
         'version'     : 'Print version and exit.',
         'verbose'     : 'Output more information.',
         'list'        : 'Print all available modules and exit. All other '
-                        'options, apart from --manifest, are ignored.',
+                        'options, apart from --manifest and --verbose, are '
+                        'ignored.',
         'module'      : 'Name or URL of FSL module to download.',
-        'manifest'    : 'URL to module manifest file.',
+        'manifest'    : 'Name of official manifest file to download, or '
+                        'alternate URL to module manifest file (default: '
+                        f'{DEFAULT_MANIFEST})',
         'archiveDir'  : 'Directory to cache downloaded files in.',
         'category'    : 'Only display available modules from the specified '
                         'category. Defaults to "fsl_course_data", unless '
@@ -187,12 +198,23 @@ def parseArgs(argv : List[str]) -> argparse.Namespace:
     # course data. If we are using a custom manifest,
     # we don't want to set a default category.
     if args.manifest is None:
-        args.manifest = DEFAULT_MANIFEST_URL
-    if args.manifest == DEFAULT_MANIFEST_URL and args.category is None:
+        args.manifest = DEFAULT_MANIFEST
+    if args.manifest == DEFAULT_MANIFEST and args.category is None:
         args.category = DEFAULT_CATEGORY
     if args.category == 'all':
         args.category = None
 
+    # Built-in manifest referred to by alias, e.g. "fslcourse"?
+    if not (args.manifest is None            or
+            args.manifest.startswith('http') or
+            op.exists(args.manifest)):
+        try:
+            args.manifest = OFFICIAL_MANIFEST_URLS[args.manifest]
+        except KeyError:
+            warning(f'Unknown manifest: {args.manifest}. Known manifests: ' +
+                    ", ".join(OFFICIAL_MANIFEST_URLS.keys()) + ' (or give a '
+                    'URL to an alternative manifest file)')
+
     if args.archiveDir:
         args.archiveDir = op.abspath(op.expanduser(args.archiveDir))
 
diff --git a/fsl/scripts/fsl_generate_module_manifest.py b/fsl/scripts/fsl_generate_module_manifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4d89c1e9b5b85a14c0f79530f7f400565a9b2ee
--- /dev/null
+++ b/fsl/scripts/fsl_generate_module_manifest.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python
+#
+# fsl_generate_module_manifest.py - Generate a template manifest.json file for
+#                                   use by fsl_add_module.
+#
+# Author: Paul McCarthy <pauldmccarthy@gmail.com>
+#
+"""The ``fsl_generate_module_manifest`` script is intended for use by FSL
+dataset maintainers. It can be used to generate a JSON manifest file which
+describes a set of archive/plugin files that are to be made available for
+download.
+"""
+
+
+import sys
+import argparse
+import contextlib as ctxlib
+import os.path as op
+
+from typing import List
+
+import fsl.add_module.manifest as plgman
+import fsl.add_module.routines as routines
+
+
+def parseArgs(argv : List[str]) -> argparse.Namespace:
+    """Parse command-line arguments. The user is expected to provide a set of
+    input archive/plugin files and a destination file to save the JSON
+    manifest. An existing manifest file/URL can optionally be provided -
+    metadata will be copied over from this file for archive files with
+    matching names.
+    """
+
+    parser = argparse.ArgumentParser(
+        'fsl_generate_module_manifest',
+        usage='fsl_generate_module_manifest [options] infile [infile ...]',
+        description='Generate a manifest.json file for use by fsl_add_module')
+
+    parser.add_argument('-m', '--manifest',
+                        help='Previous version of manifest to copy metadata '
+                             'from. The name, category, url, description, and '
+                             'destination fields are all copied across for '
+                             'archive files with matching names.')
+    parser.add_argument('-o', '--outfile',
+                        help='Location to save new manifest (default: stdout)')
+    parser.add_argument('infile', nargs='+',
+                        help='Archive file to include in new manifest.')
+
+    return parser.parse_args(argv)
+
+
+def main(argv=None):
+    """Main routine. Generates a JSON manifest describing a set of files.
+    """
+    if argv is None:
+        argv = sys.argv[1:]
+
+    args = parseArgs(argv)
+
+    if args.manifest is not None:
+        oldmanifest = plgman.downloadManifest(args.manifest)
+        oldmanifest = {p.rawFileName : p for p in oldmanifest.values()}
+    else:
+        oldmanifest = plgman.Manifest()
+
+    newmanifest = plgman.Manifest()
+
+    for infile in args.infile:
+        basename = op.basename(infile)
+
+        if basename in oldmanifest:
+            plugin = oldmanifest[basename]
+        else:
+            plugin = plgman.Plugin(url='', name=basename)
+
+        plugin.size              = op.getsize(infile)
+        plugin.checksum          = routines.calcChecksum(infile)
+        newmanifest[plugin.name] = plugin
+
+    newmanifest = plgman.genManifest(newmanifest)
+
+    if args.outfile is None:
+        print(newmanifest)
+    else:
+        with open(args.outfile, 'wt', encoding='utf-8') as f:
+            f.write(newmanifest)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/setup.py b/setup.py
index 366f3113f82872901caceb69d97c7b2be4260e20..e35bd2053ae78c95df46debe6b5212c25a91dfda 100644
--- a/setup.py
+++ b/setup.py
@@ -52,7 +52,8 @@ setup(
 
     entry_points={
         'console_scripts' : [
-            'fsl_add_module = fsl.scripts.fsl_add_module:main',
+            'fsl_add_module               = fsl.scripts.fsl_add_module:main',
+            'fsl_generate_module_manifest = fsl.scripts.fsl_generate_module_manifest:main',
         ]
     }
 )