diff --git a/ale/isd_generate.py b/ale/isd_generate.py
new file mode 100755
index 0000000000000000000000000000000000000000..f991e2fb74ec0af5383620ab82839654551a3bfa
--- /dev/null
+++ b/ale/isd_generate.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""Create ISD .json file from image or label file."""
+
+# This is free and unencumbered software released into the public domain.
+#
+# The authors of ale do not claim copyright on the contents of this file.
+# For more details about the LICENSE terms and the AUTHORS, you will
+# find files of those names at the top level of this repository.
+#
+# SPDX-License-Identifier: CC0-1.0
+
+import argparse
+import concurrent.futures
+import logging
+import os
+from pathlib import Path
+import sys
+
+import ale
+
+logger = logging.getLogger(__name__)
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "-k", "--kernel",
+        type=Path,
+        help="Typically this is an optional metakernel file, care should be "
+             "taken by the user that it is applicable to all of the input "
+             "files.  It can also be a single ISIS cube, which is sometimes "
+             "needed if the input file is a label file."
+    )
+    parser.add_argument(
+        "--max_workers",
+        default=None,
+        type=int,
+        help="If more than one file is provided to work on, this program "
+             "will engage multiprocessing to parallelize the work.  This "
+             "multiprocessing will default to the number of processors on the "
+             "machine.  If you want to throttle this to use less resources on "
+             "your machine, indicate the number of processors you want to use."
+    )
+    parser.add_argument(
+        "-o", "--out",
+        type=Path,
+        help="Optional output file.  If not specified, this will be set to "
+             "the input filename with its final suffix replaced with .json. "
+             "If multiple input files are provided, this option will be ignored "
+             "and the default strategy of replacing their final suffix with "
+             ".json will be used to generate the output file paths."
+    )
+    parser.add_argument(
+        "-v", "--verbose",
+        action="store_true",
+        help="Display information as program runs."
+    )
+    parser.add_argument(
+        '--version',
+        action='version',
+        version=f"ale version {ale.__version__}",
+        help="Shows ale version number."
+    )
+    parser.add_argument(
+        "input",
+        nargs="+",
+        help="Path to image or label file (or multiple)."
+    )
+    args = parser.parse_args()
+
+    log_level = logging.WARNING
+    if args.verbose:
+        log_level = logging.INFO
+
+    logging.basicConfig(format="%(message)s", level=log_level)
+    logger.setLevel(log_level)
+
+    if args.kernel is None:
+        k = None
+    else:
+        try:
+            k = ale.util.generate_kernels_from_cube(args.kernel, expand=True)
+        except KeyError:
+            k = [args.kernel, ]
+
+    if len(args.input) == 1:
+        try:
+            file_to_isd(args.input[0], args.out, kernels=k, log_level=log_level)
+        except Exception as err:
+            # Seriously, this just throws a generic Exception?
+            sys.exit(f"File {args.input[0]}: {err}")
+    else:
+        with concurrent.futures.ProcessPoolExecutor(
+            max_workers=args.max_workers
+        ) as executor:
+            futures = {
+                executor.submit(
+                    file_to_isd, f, **{"kernels": k, "log_level": log_level}
+                ): f for f in args.input
+            }
+            for f in concurrent.futures.as_completed(futures):
+                # Since file_to_isd() doesn't return anything,
+                # we don't need to do anything with the return value,
+                # just check its result for an Exception, if we get
+                # one, note it and continue.
+                try:
+                    f.result()
+                except Exception as err:
+                    logger.error(f"File {futures[f]}: {err}")
+
+
+def file_to_isd(
+    file: os.PathLike,
+    out: os.PathLike = None,
+    kernels: list = None,
+    log_level=logging.WARNING
+):
+    """
+    Returns nothing, but acts as a thin wrapper to take the *file* and generate
+    an ISD at *out* (if given, defaults to replacing the extension on *file*
+    with .json), optionally using the passed *kernels*.
+    """
+    # Yes, it is aggravating to have to pass the log_level into the function.
+    # If this weren't trying to be fancy with multiprocessing, it wouldn't
+    # be needed, and if this program were more complex, you'd build different
+    # infrastructure.  Probably overkill to use logging here.
+
+    if out is None:
+        isd_file = Path(file).with_suffix(".json")
+    else:
+        isd_file = Path(out)
+
+    # These two lines might seem redundant, but they are the only
+    # way to guarantee that when file_to_isd() is spun up in its own
+    # process, that these are set properly.
+    logging.basicConfig(format="%(message)s", level=log_level)
+    logger.setLevel(log_level)
+
+    logger.info(f"Reading: {file}")
+    if kernels is not None:
+        usgscsm_str = ale.loads(file, props={'kernels': kernels})
+    else:
+        usgscsm_str = ale.loads(file)
+
+    logger.info(f"Writing: {isd_file}")
+    isd_file.write_text(usgscsm_str)
+
+    return
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except ValueError as err:
+        sys.exit(err)
diff --git a/setup.py b/setup.py
index cb62110f697963714da46f2a4585c698914cdf37..e40e3b272af718f34b7dd969941bc2d9ea7f736a 100644
--- a/setup.py
+++ b/setup.py
@@ -25,5 +25,10 @@ setup(
     long_description="""\
     An Abstraction library for reading, writing and computing ephemeris data
     """,
-    package_data={'': ['config.yml']}
+    package_data={'': ['config.yml']},
+    entry_points={
+        "console_scripts": [
+            "isd_generate=ale.isd_generate:main"
+        ],
+    },
 )
diff --git a/tests/pytests/test_isd_generate.py b/tests/pytests/test_isd_generate.py
new file mode 100644
index 0000000000000000000000000000000000000000..1be7691f7db97699ef09abdbac6aa4e9817dd9d3
--- /dev/null
+++ b/tests/pytests/test_isd_generate.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""This module has unit tests for the isd_generate functions."""
+
+# This is free and unencumbered software released into the public domain.
+#
+# The authors of ale do not claim copyright on the contents of this file.
+# For more details about the LICENSE terms and the AUTHORS, you will
+# find files of those names at the top level of this repository.
+#
+# SPDX-License-Identifier: CC0-1.0
+
+import unittest
+from unittest.mock import call, patch
+
+import ale.isd_generate as isdg
+
+
+class TestFile(unittest.TestCase):
+
+    @patch("ale.isd_generate.Path.write_text")
+    def test_file_to_isd(self, m_path_wt):
+
+        json_text = "some json text"
+        cube_str = "dummy.cub"
+
+        with patch("ale.loads", return_value=json_text) as m_loads:
+            cube_str = "dummy.cub"
+            isdg.file_to_isd(cube_str)
+            self.assertEqual(
+                m_loads.call_args_list, [call(cube_str)]
+            )
+            self.assertEqual(
+                m_path_wt.call_args_list, [call(json_text)]
+            )
+
+        m_path_wt.reset_mock()
+        with patch("ale.loads", return_value=json_text) as m_loads:
+            out_str = "dummy.json"
+            kernel_val = "list of kernels"
+            isdg.file_to_isd(cube_str, out=out_str, kernels=kernel_val)
+            self.assertEqual(
+                m_loads.call_args_list,
+                [call(cube_str, props={'kernels': kernel_val})]
+            )
+            self.assertEqual(
+                m_path_wt.call_args_list, [call(json_text)]
+            )