diff --git a/AUTHORS b/AUTHORS
index 194456f0..d28bcd1b 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -109,3 +109,4 @@
 * Dmitry Ivanov <dmitry.ivanov@divanov.eu>
 * Andrey Vlasovskikh <andrey.vlasovskikh@gmail.com>
 * Joseph LaFreniere <joseph@lafreniere.xyz>
+* Daniel Tan <danieltanfh95@gmail.com>
diff --git a/NEWS.rst b/NEWS.rst
index 4fa2722b..85058939 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -38,6 +38,7 @@ New Features
   remaining special rule that `...` compiles to `Ellipsis`)
 * On Pythons ≥ 3.7, Hy modules can now be imported from ZIP
   archives in the same way as Python modules, via `zipimport`_.
+* `hy2py` now supports directory input, and will recursively convert hy source code into python source code.
 
 .. _zipimport: https://docs.python.org/3.11/library/zipimport.html
 
diff --git a/docs/cli.rst b/docs/cli.rst
index 374be98c..2390d44e 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -43,7 +43,7 @@ for a complete list of options and :py:ref:`Python's documentation
 hy2py
 -----
 
-``hy2py`` is a program to convert Hy source code into Python source code. Use ``hy2py --help`` for usage instructions. It can take its input from standard input or from a filename provided as a command-line argument. The result is written to standard output.
+``hy2py`` is a program to convert Hy source code into Python source code. Use ``hy2py --help`` for usage instructions. It can take its input from standard input, from a filename, or folder name provided as a command-line argument. If it is a folder, the output parameter (--output/-o) must be provided. When the output parameter is provided, the output will be written into the folder or file, otherwise the result is written to standard output.
 
     .. warning::
        ``hy2py`` can execute arbitrary code. Don't give it untrusted input.
diff --git a/hy/cmdline.py b/hy/cmdline.py
index f6093b2e..72d0bc22 100644
--- a/hy/cmdline.py
+++ b/hy/cmdline.py
@@ -7,6 +7,7 @@ import platform
 import py_compile
 import runpy
 import sys
+from contextlib import nullcontext
 from pathlib import Path
 
 import hy
@@ -330,6 +331,48 @@ def hyc_main():
     return rv
 
 
+def hy2py_worker(source, options, filename, output_filepath=None):
+    if isinstance(source, Path):
+        source = source.read_text(encoding="UTF-8")
+
+    if not output_filepath and options.output:
+        output_filepath = options.output
+
+    set_path(filename)
+    with (
+        open(output_filepath, "w", encoding="utf-8")
+        if output_filepath
+        else nullcontext()
+    ) as output_file:
+
+        def printing_source(hst):
+            for node in hst:
+                if options.with_source:
+                    print(node, file=output_file)
+                yield node
+
+        hst = hy.models.Lazy(
+            printing_source(read_many(source, filename, skip_shebang=True))
+        )
+        hst.source = source
+        hst.filename = filename
+
+        with filtered_hy_exceptions():
+            _ast = hy_compile(hst, "__main__", filename=filename, source=source)
+
+        if options.with_source:
+            print()
+            print()
+
+        if options.with_ast:
+            print(ast.dump(_ast, **(dict(indent=2) if PY3_9 else {})), file=output_file)
+            print()
+            print()
+
+        if not options.without_python:
+            print(ast.unparse(_ast), file=output_file)
+
+
 # entry point for cmd line script "hy2py"
 def hy2py_main():
     options = dict(
@@ -342,7 +385,8 @@ def hy2py_main():
         "FILE",
         type=str,
         nargs="?",
-        help='Input Hy code (use STDIN if "-" or ' "not provided)",
+        help='Input Hy code (can be file or directory) (use STDIN if "-" or '
+        "not provided)",
     )
     parser.add_argument(
         "--with-source",
@@ -359,46 +403,51 @@ def hy2py_main():
         action="store_true",
         help=("Do not show the Python code generated " "from the AST"),
     )
+    parser.add_argument(
+        "--output",
+        "-o",
+        type=str,
+        nargs="?",
+        help="output file / directory",
+    )
 
     options = parser.parse_args(sys.argv[1:])
 
     if options.FILE is None or options.FILE == "-":
         sys.path.insert(0, "")
         filename = "<stdin>"
-        source = sys.stdin.read()
+        hy2py_worker(sys.stdin.read(), options, filename)
     else:
         filename = options.FILE
-        set_path(filename)
-        with open(options.FILE, "r", encoding="utf-8") as source_file:
-            source = source_file.read()
-
-    def printing_source(hst):
-        for node in hst:
-            if options.with_source:
-                print(node)
-            yield node
-
-    hst = hy.models.Lazy(
-        printing_source(read_many(source, filename, skip_shebang=True))
-    )
-    hst.source = source
-    hst.filename = filename
-
-    with filtered_hy_exceptions():
-        _ast = hy_compile(hst, "__main__", filename=filename, source=source)
-
-    if options.with_source:
-        print()
-        print()
-
-    if options.with_ast:
-        print(ast.dump(_ast, **(dict(indent=2) if PY3_9 else {})))
-        print()
-        print()
-
-    if not options.without_python:
-        print(ast.unparse(_ast))
+        if os.path.isdir(filename):
+            # handle recursively if --output is specified
+            if not options.output:
+                raise ValueError(
+                    f"{filename} is a directory but the output directory is not specified. Use --output or -o in command line arguments to specify the output directory."
+                )
+            os.makedirs(options.output, exist_ok=True)
+            for path, subdirs, files in os.walk(filename):
+                for name in files:
+                    filename_raw, filename_ext = os.path.splitext(name)
+                    if filename_ext == ".hy":
+                        filepath = os.path.join(path, name)
+                        # make sure to follow original file structure
+                        subdirectory = os.path.relpath(path, filename)
+                        output_directory_path = os.path.join(
+                            options.output if options.output else path, subdirectory
+                        )
+                        os.makedirs(output_directory_path, exist_ok=True)
+                        hy2py_worker(
+                            Path(filepath),
+                            options,
+                            filename,
+                            output_filepath=os.path.join(
+                                output_directory_path, filename_raw + ".py"
+                            ),
+                        )
 
+        else:
+            hy2py_worker(Path(options.FILE), options, filename)
     parser.exit(0)
 
 

diff --git a/tests/test_bin.py b/tests/test_bin.py
index 95899287..4319bce9 100644
--- a/tests/test_bin.py
+++ b/tests/test_bin.py
@@ -26,7 +26,7 @@ def pyr(s=""):
 
 def run_cmd(
         cmd, stdin_data=None, expect=0, dontwritebytecode=False,
-        stdout=subprocess.PIPE):
+        cwd=None, stdout=subprocess.PIPE):
     env = dict(os.environ)
     if dontwritebytecode:
         env["PYTHONDONTWRITEBYTECODE"] = "1"
@@ -41,6 +41,7 @@ def run_cmd(
         universal_newlines=True,
         shell=False,
         env=env,
+        cwd=cwd,
     )
     output = p.communicate(input=stdin_data)
     assert p.wait() == expect
@@ -717,3 +718,28 @@ def test_assert(tmp_path, monkeypatch):
             show_msg = has_msg and not optim and not test
             assert ("msging" in out) == show_msg
             assert ("bye" in err) == show_msg
+
+
+def test_hy2py_recursive(tmp_path):
+    (tmp_path / 'hy').mkdir()
+    (tmp_path / "hy/first.hy").write_text("""
+        (import folder.second [a b])
+        (print a)
+        (print b)""")
+    (tmp_path / "hy/folder").mkdir()
+    (tmp_path / "hy/folder/second.hy").write_text("""
+        (setv a 1)
+        (setv b "hello world")""")
+
+    _, err = run_cmd(f"hy2py {(tmp_path / 'hy').as_posix()}", expect=1)
+    assert "ValueError" in err
+
+    run_cmd("hy2py " +
+        f"{(tmp_path / 'hy').as_posix()} " +
+        f"--output {(tmp_path / 'py').as_posix()}")
+    assert set((tmp_path / 'py').rglob('*')) == {
+        tmp_path / 'py' / p
+        for p in ('first.py', 'folder', 'folder/second.py')}
+
+    output, _ = run_cmd(f"python3 first.py", cwd = tmp_path / 'py')
+    assert output == "1\nhello world\n"
diff --git a/tests/test_hy2py.py b/tests/test_hy2py.py
index d4c12950..c5743984 100644
--- a/tests/test_hy2py.py
+++ b/tests/test_hy2py.py
@@ -1,8 +1,6 @@
 import asyncio
 import itertools
 import math
-import os
-import platform
 
 import pytest
 

Recursive `hy2py`
We could give `hy2py` a recursive mode that goes through a source tree and saves a matching Python file for each Hy file, if an up-to-date one doesn't already exist. Optionally, it could delete all such Python files and bytecode first, in order to ensure all macro expansions are fully up to date.

I'm not sure how often this would be useful in practice, but it seems like an obvious feature to have.
tests/test_bin.py::test_hy2py_recursive
tests/test_bin.py::test_simple tests/test_bin.py::test_stdin tests/test_bin.py::test_stdin_multiline tests/test_bin.py::test_history tests/test_bin.py::test_stdin_comments tests/test_bin.py::test_stdin_assignment tests/test_bin.py::test_multi_setv tests/test_bin.py::test_stdin_error_underline_alignment tests/test_bin.py::test_stdin_except_do tests/test_bin.py::test_stdin_unlocatable_hytypeerror tests/test_bin.py::test_error_parts_length tests/test_bin.py::test_syntax_errors tests/test_bin.py::test_stdin_bad_repr tests/test_bin.py::test_stdin_py_repr tests/test_bin.py::test_mangle_m tests/test_bin.py::test_ignore_python_env tests/test_bin.py::test_cmd tests/test_bin.py::test_icmd tests/test_bin.py::test_icmd_file tests/test_bin.py::test_icmd_and_spy tests/test_bin.py::test_missing_file tests/test_bin.py::test_file_with_args tests/test_bin.py::test_hyc tests/test_bin.py::test_hyc_missing_file tests/test_bin.py::test_builtins tests/test_bin.py::test_no_main tests/test_bin.py::test_byte_compile[cmd_fmt0-normal] tests/test_bin.py::test_byte_compile[cmd_fmt0-prevent_by_force] tests/test_bin.py::test_byte_compile[cmd_fmt0-prevent_by_env] tests/test_bin.py::test_byte_compile[cmd_fmt0-prevent_by_option] tests/test_bin.py::test_byte_compile[cmd_fmt1-normal] tests/test_bin.py::test_byte_compile[cmd_fmt1-prevent_by_force] tests/test_bin.py::test_byte_compile[cmd_fmt1-prevent_by_env] tests/test_bin.py::test_byte_compile[cmd_fmt1-prevent_by_option] tests/test_bin.py::test_byte_compile[cmd_fmt2-normal] tests/test_bin.py::test_byte_compile[cmd_fmt2-prevent_by_force] tests/test_bin.py::test_byte_compile[cmd_fmt2-prevent_by_env] tests/test_bin.py::test_byte_compile[cmd_fmt2-prevent_by_option] tests/test_bin.py::test_module_main_file tests/test_bin.py::test_file_main_file tests/test_bin.py::test_file_sys_path tests/test_bin.py::test_module_no_main tests/test_bin.py::test_sys_executable tests/test_bin.py::test_file_no_extension tests/test_bin.py::test_circular_macro_require tests/test_bin.py::test_macro_require tests/test_bin.py::test_tracebacks tests/test_bin.py::test_hystartup tests/test_bin.py::test_output_buffering tests/test_bin.py::test_uufileuu tests/test_bin.py::test_assert tests/test_hy2py.py::test_direct_import tests/test_hy2py.py::test_hy2py_import