Lazily download tools (#62)

Closes #59.

Refactor os/cpu repos into per-tool repos and split bzlmod/non-bzlmod handling in order to add a workspace-only shim. As a result of these changes, only tools needed by a build will be downloaded/all tool downloads become lazy.

**Breaking change** for workspace users.

diff --git a/.github/workflows/release_prep.sh b/.github/workflows/release_prep.sh
index f9fcec0..18221fd 100755
--- a/.github/workflows/release_prep.sh
+++ b/.github/workflows/release_prep.sh
@@ -62,5 +62,10 @@
     name = "multitool",
     lockfile = "//:multitool.lock.json",
 )
+
+# required since 0.15.0 to enable only downloading tools used by this workspace
+load("@multitool.workspace//:tools.bzl", "register_tools")
+
+register_tools()
 \`\`\`
 EOF
diff --git a/examples/workspace/WORKSPACE.bazel b/examples/workspace/WORKSPACE.bazel
index 99dc0cb..b94bb8c 100644
--- a/examples/workspace/WORKSPACE.bazel
+++ b/examples/workspace/WORKSPACE.bazel
@@ -8,3 +8,7 @@
     name = "multitool",
     lockfile = "//:multitool.lock.json",
 )
+
+load("@multitool.workspace//:tools.bzl", "register_tools")
+
+register_tools()
diff --git a/examples/workspace/minimal_download_test.sh b/examples/workspace/minimal_download_test.sh
index 4ee0b88..26c16c4 100755
--- a/examples/workspace/minimal_download_test.sh
+++ b/examples/workspace/minimal_download_test.sh
@@ -21,6 +21,7 @@
   if [ "$file" != "$ALLOWED" ]; then
     cat >$DL_CONFIG <<EOF
 rewrite github.com/bazel-contrib/target-determinator/releases/download/([^/]+)/$file disallowed.build/\$1/$file
+rewrite github.com/cli/cli/releases/download/(.*) disallowed.build/\$1
 EOF
   fi
 done
diff --git a/examples/workspace/multitool.lock.json b/examples/workspace/multitool.lock.json
index a7fee4f..80118c3 100644
--- a/examples/workspace/multitool.lock.json
+++ b/examples/workspace/multitool.lock.json
@@ -1,35 +1,71 @@
 {
   "$schema": "../../lockfile.schema.json",
   "target-determinator": {
-      "binaries": [
-          {
-              "kind": "file",
-              "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.amd64",
-              "sha256": "8c7245603dede429b978e214ca327c3f3d686a1bc712c1298fca0396a0f25f23",
-              "os": "macos",
-              "cpu": "x86_64"
-          },
-          {
-              "kind": "file",
-              "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.arm64",
-              "sha256": "8f975b471c4a51d32781b757e1ece9700221bfd4c0ea507c18fa382360d1111f",
-              "os": "macos",
-              "cpu": "arm64"
-          },
-          {
-              "kind": "file",
-              "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.linux.amd64",
-              "sha256": "c8a09143e9fe6eccc4b27a6be92c5929e5a78034a8d0b4c43dbed4ee539ec903",
-              "os": "linux",
-              "cpu": "x86_64"
-          },
-          {
-            "kind": "file",
-            "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.windows.amd64.exe",
-            "sha256": "e14fd75e33d193f579505cf3e641e07025904fc027686e13e154ba8e10ac0f58",
-            "os": "windows",
-            "cpu": "x86_64"
-          }
-      ]
+    "binaries": [
+      {
+        "kind": "file",
+        "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.amd64",
+        "sha256": "8c7245603dede429b978e214ca327c3f3d686a1bc712c1298fca0396a0f25f23",
+        "os": "macos",
+        "cpu": "x86_64"
+      },
+      {
+        "kind": "file",
+        "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.arm64",
+        "sha256": "8f975b471c4a51d32781b757e1ece9700221bfd4c0ea507c18fa382360d1111f",
+        "os": "macos",
+        "cpu": "arm64"
+      },
+      {
+        "kind": "file",
+        "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.linux.amd64",
+        "sha256": "c8a09143e9fe6eccc4b27a6be92c5929e5a78034a8d0b4c43dbed4ee539ec903",
+        "os": "linux",
+        "cpu": "x86_64"
+      },
+      {
+        "kind": "file",
+        "url": "https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.windows.amd64.exe",
+        "sha256": "e14fd75e33d193f579505cf3e641e07025904fc027686e13e154ba8e10ac0f58",
+        "os": "windows",
+        "cpu": "x86_64"
+      }
+    ]
+  },
+  "gh": {
+    "binaries": [
+      {
+        "kind": "archive",
+        "url": "https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_macOS_amd64.zip",
+        "sha256": "1c545505b5b88feaffeba00b7284ccac3f2002b67461b1246eaec827eb07c31b",
+        "file": "gh_2.44.1_macOS_amd64/bin/gh",
+        "os": "macos",
+        "cpu": "x86_64"
+      },
+      {
+        "kind": "archive",
+        "url": "https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_macOS_amd64.zip",
+        "sha256": "1c545505b5b88feaffeba00b7284ccac3f2002b67461b1246eaec827eb07c31b",
+        "file": "gh_2.44.1_macOS_amd64/bin/gh",
+        "os": "macos",
+        "cpu": "arm64"
+      },
+      {
+        "kind": "archive",
+        "url": "https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_linux_amd64.tar.gz",
+        "sha256": "f11eefb646768e3f53e2185f6d3b01b4cb02112c2c60e65a4b5875150287ff97",
+        "file": "gh_2.44.1_linux_amd64/bin/gh",
+        "os": "linux",
+        "cpu": "x86_64"
+      },
+      {
+        "kind": "archive",
+        "url": "https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_windows_amd64.zip",
+        "sha256": "5a19a334d5032cfcb01e71e418c5e355104f7afd9129daf8939b729ffa46aa08",
+        "file": "bin/gh.exe",
+        "os": "windows",
+        "cpu": "x86_64"
+      }
+    ]
   }
 }
diff --git a/multitool/extension.bzl b/multitool/extension.bzl
index 9360b05..8ab4090 100644
--- a/multitool/extension.bzl
+++ b/multitool/extension.bzl
@@ -1,6 +1,6 @@
 "multitool module extension"
 
-load("//multitool/private:multitool.bzl", _hub = "hub")
+load("//multitool/private:multitool.bzl", _hub = "bzlmod_hub")
 
 hub = tag_class(
     attrs = {
@@ -18,6 +18,7 @@
     _hub(
         name = "multitool",
         lockfiles = lockfiles,
+        module_ctx = module_ctx,
     )
 
 multitool = module_extension(
diff --git a/multitool/multitool.bzl b/multitool/multitool.bzl
index e4c0315..b99f067 100644
--- a/multitool/multitool.bzl
+++ b/multitool/multitool.bzl
@@ -1,6 +1,6 @@
 "multitool workspace macros"
 
-load("//multitool/private:multitool.bzl", "hub")
+load("//multitool/private:multitool.bzl", "workspace_hub")
 
 def multitool(name, lockfile = None, lockfiles = None):
     """(non-bzlmod) Create a multitool hub and register its toolchains.
@@ -17,5 +17,5 @@
 
     lockfiles = lockfiles if lockfiles else [lockfile]
 
-    hub(name, lockfiles)
+    workspace_hub(name, lockfiles)
     native.register_toolchains("@{name}//toolchains:all".format(name = name))
diff --git a/multitool/private/hub_repo_template/toolchain_info.bzl.template b/multitool/private/hub_repo_template/toolchain_info.bzl.template
index 90fa4b7..f5dd020 100644
--- a/multitool/private/hub_repo_template/toolchain_info.bzl.template
+++ b/multitool/private/hub_repo_template/toolchain_info.bzl.template
@@ -29,7 +29,7 @@
     ext = _extension(os)
     toolchain_info(
         name = "{name}_{os}_{cpu}_toolchain_info".format(name=name, os=os, cpu=cpu),
-        executable = "@@{hub_name}.{os}_{cpu}//tools/{name}:{os}_{cpu}_executable{ext}".format(name=name, os=os, cpu=cpu, ext=ext),
+        executable = "@@{hub_name}.{name}.{os}_{cpu}//tools/{name}:{os}_{cpu}_executable{ext}".format(name=name, os=os, cpu=cpu, ext=ext),
         os = os,
         cpu = cpu,
         ext = ext,
diff --git a/multitool/private/lockfile.bzl b/multitool/private/lockfile.bzl
new file mode 100644
index 0000000..2bc0425
--- /dev/null
+++ b/multitool/private/lockfile.bzl
@@ -0,0 +1,60 @@
+"Utilities for interacting with the multitool lockfile."
+
+def _check(condition, message):
+    "fails iff condition is False and emits message"
+    if not condition:
+        fail(message)
+
+def _check_version(os, binary_os):
+    # require bazel 7.1 on windows. Only do this check for windows artifacts to avoid regressing anyone
+    # skip version check on windows if we don't have a release version. We can't tell from a hash what features we have.
+    if os == "windows" and binary_os == "windows" and native.bazel_version:
+        version = native.bazel_version.split(".")
+        if int(version[0]) > 7 or (int(version[0]) == 7 and int(version[1]) >= 1):
+            pass
+        else:
+            fail("rules_multitool: windows platform requires bazel 7.1+ to read artifacts; current bazel is " + native.bazel_version)
+
+def _load(ctx, lockfiles):
+    tools = {}
+    for lockfile in lockfiles:
+        # TODO: validate no conflicts from multiple hub declarations and/or
+        #  fix toolchains to also declare their versions and enable consumers
+        #  to use constraints to pick the right one.
+        #  (this is also a very naive merge at the tool level)
+        tools = tools | json.decode(ctx.read(lockfile))
+
+    # a special key says this JSON document conforms to a schema
+    tools.pop("$schema", None)
+
+    # validation
+    for tool_name, tool in tools.items():
+        for binary in tool["binaries"]:
+            _check(
+                binary["os"] in ["linux", "macos", "windows"],
+                "{tool_name}: Unknown os '{os}'".format(
+                    tool_name = tool_name,
+                    os = binary["os"],
+                ),
+            )
+            _check(
+                binary["cpu"] in ["x86_64", "arm64"],
+                "{tool_name}: Unknown cpu '{cpu}'".format(
+                    tool_name = tool_name,
+                    cpu = binary["cpu"],
+                ),
+            )
+            _check_version(ctx.os.name, binary["os"])
+
+    return tools
+
+def _sort_fn(tup):
+    return tup[0]
+
+def _sorted(tools):
+    return sorted(tools.items(), key = _sort_fn)
+
+lockfile = struct(
+    load_defs = _load,
+    sorted_defs = _sorted,
+)
diff --git a/multitool/private/multitool.bzl b/multitool/private/multitool.bzl
index 3ba0328..7919f4b 100644
--- a/multitool/private/multitool.bzl
+++ b/multitool/private/multitool.bzl
@@ -3,18 +3,22 @@
 
 Multitool takes as input a JSON lockfile and emits the following repos:
 
- - [hub].[os]_[cpu], for each [os]/[cpu] combo in _SUPPORTED_ENVS:
-     This repository holds os/cpu specific binaries for all tools in the provided
-     lockfile(s) and constructs clean symlinks to their content for inclusion in
-     toolchains defined in the [hub] repo.
+ - [hub].[tool].[os]_[cpu]:
+     This repository holds the per-tool os/cpu binary in the provided lockfile.
 
-     The structure of this repo is, very simply:
+     The structure of this repo is:
        tools/
          [tool-name]/
            BUILD.bazel            (export all *_executable files)
            [os]_[cpu]_executable  (a downloaded file or a symlink to a file in a
                                    downloaded and extracted archive)
 
+ - [hub].workspace:
+     Iff not running in bzlmod, a utility for registering the per-tool repos with Bazel.
+
+     The structure of this repo is:
+        tools.bzl         (a file containing one utility method, "register_tools")
+
  - [hub]:
      This repository holds toolchain definitions for all tools in the provided
      lockfile(s), as well as an executable tool target that will pick the
@@ -32,9 +36,10 @@
        (additional BUILD.bazel and a WORKSPACE file are included as required by Bazel)
 
 To keep things orderly, we keep all the toolchain Bazel goo in the [hub] repo and only stash
-the binaries in the [hub].[os]_[cpu] repos. It's a conscious decision not to place some fragments
-of the toolchain definitions in the latter repos to make the dependencies run exactly one way:
-[hub] -> [hub].[os]_[cpu].
+the binaries in the [hub].[tool].[os]_[cpu] repos. It's a conscious decision not to place some
+fragments of the toolchain definitions in the latter repos to make the dependencies run exactly
+one way:
+    [hub] -> [hub].[tool].[os]_[cpu].
 
 This implementation depends on rendering a number of templates, which are defined in sibling
 folders and managed by the templates starlark file.
@@ -45,32 +50,9 @@
 
 load("@bazel_features//:features.bzl", "bazel_features")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "read_netrc", "read_user_netrc", "use_netrc")
+load(":lockfile.bzl", "lockfile")
 load(":templates.bzl", "templates")
 
-_SUPPORTED_ENVS = [
-    ("linux", "arm64"),
-    ("linux", "x86_64"),
-    ("macos", "arm64"),
-    ("macos", "x86_64"),
-    ("windows", "arm64"),
-    ("windows", "x86_64"),
-]
-
-def _check(condition, message):
-    "fails iff condition is False and emits message"
-    if not condition:
-        fail(message)
-
-def _check_version(os, binary_os):
-    # require bazel 7.1 on windows. Only do this check for windows artifacts to avoid regressing anyone
-    # skip version check on windows if we don't have a release version. We can't tell from a hash what features we have.
-    if os == "windows" and binary_os == "windows" and native.bazel_version:
-        version = native.bazel_version.split(".")
-        if int(version[0]) > 7 or (int(version[0]) == 7 and int(version[1]) >= 1):
-            pass
-        else:
-            fail("rules_multitool: windows platform requires bazel 7.1+ to read artifacts; current bazel is " + native.bazel_version)
-
 def _get_auth(rctx, urls, auth_patterns):
     "Returns an auth dict for the provided list of URLs."
     if "NETRC" in rctx.os.environ:
@@ -79,39 +61,6 @@
         netrc = read_user_netrc(rctx)
     return use_netrc(netrc, urls, auth_patterns)
 
-def _load_tools(rctx):
-    tools = {}
-    for lockfile in rctx.attr.lockfiles:
-        # TODO: validate no conflicts from multiple hub declarations and/or
-        #  fix toolchains to also declare their versions and enable consumers
-        #  to use constraints to pick the right one.
-        #  (this is also a very naive merge at the tool level)
-        tools = tools | json.decode(rctx.read(lockfile))
-
-    # a special key says this JSON document conforms to a schema
-    tools.pop("$schema", None)
-
-    # validation
-    for tool_name, tool in tools.items():
-        for binary in tool["binaries"]:
-            _check(
-                binary["os"] in ["linux", "macos", "windows"],
-                "{tool_name}: Unknown os '{os}'".format(
-                    tool_name = tool_name,
-                    os = binary["os"],
-                ),
-            )
-            _check(
-                binary["cpu"] in ["x86_64", "arm64"],
-                "{tool_name}: Unknown cpu '{cpu}'".format(
-                    tool_name = tool_name,
-                    cpu = binary["cpu"],
-                ),
-            )
-            _check_version(rctx.os.name, binary["os"])
-
-    return tools
-
 def _feature_sensitive_args(binary):
     args = {}
     if bazel_features.external_deps.download_has_headers_param:
@@ -124,122 +73,128 @@
         return ".exe"
     return ""
 
-def _env_specific_tools_impl(rctx):
-    tools = _load_tools(rctx)
+def _download_extract_tool(rctx, tool_name, binary):
+    target_filename = "{os}_{cpu}_executable{ext}".format(
+        cpu = binary["cpu"],
+        os = binary["os"],
+        ext = _extension(binary["os"]),
+    )
+    target_executable = "tools/{tool_name}/{filename}".format(
+        tool_name = tool_name,
+        filename = target_filename,
+    )
 
-    for tool_name, tool in tools.items():
-        for binary in tool["binaries"]:
-            if binary["os"] != rctx.attr.os or binary["cpu"] != rctx.attr.cpu:
-                continue
+    if binary["kind"] == "file":
+        rctx.download(
+            url = binary["url"],
+            sha256 = binary["sha256"],
+            output = target_executable,
+            executable = True,
+            auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
+            **_feature_sensitive_args(binary)
+        )
+    elif binary["kind"] == "archive":
+        archive_path = "tools/{tool_name}/{os}_{cpu}_archive".format(
+            tool_name = tool_name,
+            cpu = binary["cpu"],
+            os = binary["os"],
+        )
 
-            target_filename = "{os}_{cpu}_executable{ext}".format(
-                cpu = binary["cpu"],
-                os = binary["os"],
-                ext = _extension(binary["os"]),
-            )
-            target_executable = "tools/{tool_name}/{filename}".format(
+        rctx.download_and_extract(
+            url = binary["url"],
+            sha256 = binary["sha256"],
+            output = archive_path,
+            type = binary.get("type", ""),
+            auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
+            **_feature_sensitive_args(binary)
+        )
+
+        # link to the executable
+        archive_file = "{archive_path}/{file}".format(archive_path = archive_path, file = binary["file"])
+        if not rctx.path(archive_file).exists:
+            fail("{tool_name} ({os}, {cpu}): Cannot find {file} in archive from {url}".format(
                 tool_name = tool_name,
-                filename = target_filename,
-            )
+                os = binary["os"],
+                cpu = binary["cpu"],
+                file = archive_file,
+                url = binary["url"],
+            ))
+        rctx.symlink(archive_file, target_executable)
+    elif binary["kind"] == "pkg":
+        # Check if pkgutil is on the path, and if not fail silently.
+        # repository rules execute irrespective of platform/OS, so this
+        # check is required for `pkg_archive` to not fail on Linux.
+        pkgutil_cmd = rctx.which("pkgutil")
+        if not pkgutil_cmd:
+            return
 
-            if binary["kind"] == "file":
-                rctx.download(
-                    url = binary["url"],
-                    sha256 = binary["sha256"],
-                    output = target_executable,
-                    executable = True,
-                    auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
-                    **_feature_sensitive_args(binary)
-                )
-            elif binary["kind"] == "archive":
-                archive_path = "tools/{tool_name}/{os}_{cpu}_archive".format(
-                    tool_name = tool_name,
-                    cpu = binary["cpu"],
-                    os = binary["os"],
-                )
+        archive_path = "tools/{tool_name}/{os}_{cpu}_pkg".format(
+            tool_name = tool_name,
+            cpu = binary["cpu"],
+            os = binary["os"],
+        )
 
-                rctx.download_and_extract(
-                    url = binary["url"],
-                    sha256 = binary["sha256"],
-                    output = archive_path,
-                    type = binary.get("type", ""),
-                    auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
-                    **_feature_sensitive_args(binary)
-                )
+        rctx.download(
+            url = binary["url"],
+            sha256 = binary["sha256"],
+            output = archive_path + ".pkg",
+            auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
+            **_feature_sensitive_args(binary)
+        )
 
-                # link to the executable
-                archive_file = "{archive_path}/{file}".format(archive_path = archive_path, file = binary["file"])
-                if not rctx.path(archive_file).exists:
-                    fail("{tool_name} ({os}, {cpu}): Cannot find {file} in archive from {url}".format(
-                        tool_name = tool_name,
-                        os = binary["os"],
-                        cpu = binary["cpu"],
-                        file = archive_file,
-                        url = binary["url"],
-                    ))
-                rctx.symlink(archive_file, target_executable)
-            elif binary["kind"] == "pkg":
-                # Check if pkgutil is on the path, and if not fail silently.
-                # repository rules execute irrespective of platform/OS, so this
-                # check is required for `pkg_archive` to not fail on Linux.
-                pkgutil_cmd = rctx.which("pkgutil")
-                if not pkgutil_cmd:
-                    continue
+        rctx.execute([pkgutil_cmd, "--expand-full", archive_path + ".pkg", archive_path])
 
-                archive_path = "tools/{tool_name}/{os}_{cpu}_pkg".format(
-                    tool_name = tool_name,
-                    cpu = binary["cpu"],
-                    os = binary["os"],
-                )
+        # link to the executable
+        archive_file = "{archive_path}/{file}".format(archive_path = archive_path, file = binary["file"])
+        if not rctx.path(archive_file).exists:
+            fail("{tool_name} ({os}, {cpu}): Cannot find {file} in archive from {url}".format(
+                tool_name = tool_name,
+                os = binary["os"],
+                cpu = binary["cpu"],
+                file = archive_file,
+                url = binary["url"],
+            ))
+        rctx.symlink(archive_file, target_executable)
+    else:
+        fail("Unknown 'kind' {kind}".format(kind = binary["kind"]))
 
-                rctx.download(
-                    url = binary["url"],
-                    sha256 = binary["sha256"],
-                    output = archive_path + ".pkg",
-                    auth = _get_auth(rctx, [binary["url"]], binary.get("auth_patterns", {})),
-                    **_feature_sensitive_args(binary)
-                )
+    templates.tool_tool(rctx, tool_name, "BUILD.bazel", {"{target_filename}": target_filename})
 
-                rctx.execute([pkgutil_cmd, "--expand-full", archive_path + ".pkg", archive_path])
+def _workspace_hub_impl(rctx):
+    tools = lockfile.load_defs(rctx, rctx.attr.lockfiles)
+    templates.workspace(rctx, "BUILD.bazel", rctx.attr.hub_name, {})
+    templates.workspace(rctx, "tools.bzl", rctx.attr.hub_name, tools)
 
-                # link to the executable
-                archive_file = "{archive_path}/{file}".format(archive_path = archive_path, file = binary["file"])
-                if not rctx.path(archive_file).exists:
-                    fail("{tool_name} ({os}, {cpu}): Cannot find {file} in archive from {url}".format(
-                        tool_name = tool_name,
-                        os = binary["os"],
-                        cpu = binary["cpu"],
-                        file = archive_file,
-                        url = binary["url"],
-                    ))
-                rctx.symlink(archive_file, target_executable)
-            else:
-                fail("Unknown 'kind' {kind}".format(kind = binary["kind"]))
-
-            templates.env_tool(rctx, tool_name, "BUILD.bazel", {"{target_filename}": target_filename})
-
-    templates.env(rctx, "tools/BUILD.bazel")
-    templates.env(rctx, "BUILD.bazel")
-
-_env_specific_tools = repository_rule(
+_workspace_hub = repository_rule(
     attrs = {
+        "hub_name": attr.string(mandatory = True),
         "lockfiles": attr.label_list(mandatory = True, allow_files = True),
+    },
+    implementation = _workspace_hub_impl,
+)
+
+def _tool_repo_impl(rctx):
+    _download_extract_tool(rctx, rctx.attr.tool_name, json.decode(rctx.attr.binary))
+    templates.tool(rctx, "tools/BUILD.bazel")
+    templates.tool(rctx, "BUILD.bazel")
+
+tool_repo = repository_rule(
+    attrs = {
+        "tool_name": attr.string(),
+        "binary": attr.string(),
         "os": attr.string(),
         "cpu": attr.string(),
     },
-    implementation = _env_specific_tools_impl,
+    implementation = _tool_repo_impl,
 )
 
-def _sort_fn(tup):
-    return tup[0]
-
 def _multitool_hub_impl(rctx):
-    tools = _load_tools(rctx)
+    tools = lockfile.load_defs(rctx, rctx.attr.lockfiles)
 
     loads = []
     defines = []
 
-    for tool_name, tool in sorted(tools.items(), key = _sort_fn):
+    for tool_name, tool in lockfile.sorted_defs(tools):
         toolchains = []
 
         for binary in tool["binaries"]:
@@ -278,13 +233,45 @@
     implementation = _multitool_hub_impl,
 )
 
-def hub(name, lockfiles):
-    "Create a multitool hub."
-    for env in _SUPPORTED_ENVS:
-        _env_specific_tools(
-            name = "{name}.{os}_{cpu}".format(name = name, os = env[0], cpu = env[1]),
-            lockfiles = lockfiles,
-            os = env[0],
-            cpu = env[1],
-        )
+def bzlmod_hub(name, lockfiles, module_ctx):
+    """
+    Creates a multitool hub for bzlmod.
+
+    Args:
+       name: name of the hub
+       lockfiles: a list of lockfile labels containing multitool lockfiles
+       module_ctx: a valid module_ctx instance
+    """
+
+    tools = lockfile.load_defs(module_ctx, lockfiles)
+
+    for tool_name, tool in lockfile.sorted_defs(tools):
+        for binary in tool["binaries"]:
+            tool_repo(
+                name = "{name}.{tool_name}.{os}_{cpu}".format(
+                    name = name,
+                    tool_name = tool_name,
+                    os = binary["os"],
+                    cpu = binary["cpu"],
+                ),
+                tool_name = tool_name,
+                binary = json.encode(binary),
+            )
+
+    _multitool_hub(name = name, lockfiles = lockfiles)
+
+def workspace_hub(name, lockfiles):
+    """
+    Creates a multitool hub for non-bzlmod.
+
+    Args:
+       name: name of the hub
+       lockfiles: a list of lockfile labels containing multitool lockfiles
+    """
+
+    _workspace_hub(
+        name = "{name}.workspace".format(name = name),
+        hub_name = name,
+        lockfiles = lockfiles,
+    )
     _multitool_hub(name = name, lockfiles = lockfiles)
diff --git a/multitool/private/templates.bzl b/multitool/private/templates.bzl
index 91a5db8..35319cd 100644
--- a/multitool/private/templates.bzl
+++ b/multitool/private/templates.bzl
@@ -1,22 +1,24 @@
 "multitool templating"
 
-_ENV_TEMPLATE = "//multitool/private:env_repo_template/{filename}.template"
-_ENV_TOOL_TEMPLATE = "//multitool/private:env_repo_tool_template/{filename}.template"
-
 _HUB_TEMPLATE = "//multitool/private:hub_repo_template/{filename}.template"
 _HUB_TOOL_TEMPLATE = "//multitool/private:hub_repo_tool_template/{filename}.template"
 
-def _render_env(rctx, filename, substitutions = None):
+_TOOL_TEMPLATE = "//multitool/private:tool_repo_template/{filename}.template"
+_TOOL_TOOL_TEMPLATE = "//multitool/private:tool_repo_tool_template/{filename}.template"
+
+_WORKSPACE_TEMPLATE = "//multitool/private:workspace_hub_repo_template/{filename}.template"
+
+def _render_tool(rctx, filename, substitutions = None):
     rctx.template(
         filename,
-        Label(_ENV_TEMPLATE.format(filename = filename)),
+        Label(_TOOL_TEMPLATE.format(filename = filename)),
         substitutions = substitutions or {},
     )
 
-def _render_env_tool(rctx, tool_name, filename, substitutions = None):
+def _render_tool_tool(rctx, tool_name, filename, substitutions = None):
     rctx.template(
         "tools/{tool_name}/{filename}".format(tool_name = tool_name, filename = filename),
-        Label(_ENV_TOOL_TEMPLATE.format(filename = filename)),
+        Label(_TOOL_TOOL_TEMPLATE.format(filename = filename)),
         substitutions = {
             "{name}": tool_name,
         } | (substitutions or {}),
@@ -38,9 +40,47 @@
         } | (substitutions or {}),
     )
 
+def _render_tool_repo(hub_name, tool_name, binary):
+    name = "{name}.{tool_name}.{os}_{cpu}".format(
+        name = hub_name,
+        tool_name = tool_name,
+        os = binary["os"],
+        cpu = binary["cpu"],
+    )
+    return "\n".join([
+        "    tool_repo(",
+        "        name = \"{name}\",".format(name = name),
+        "        tool_name = \"{tool_name}\",".format(tool_name = tool_name),
+        "        binary = '{binary}',".format(binary = json.encode(binary)),
+        "    )",
+        "",
+    ])
+
+def _render_tool_repos(hub_name, tools):
+    if len(tools) == 0:
+        # in the case the tool dict is empty, ensure the function body we're
+        # templating into is non-empty
+        return "    pass\n"
+
+    return "\n".join([
+        _render_tool_repo(hub_name, tool_name, binary)
+        for tool_name, tool in tools.items()
+        for binary in tool["binaries"]
+    ])
+
+def _render_workspace(rctx, filename, hub_name, tools):
+    rctx.template(
+        filename,
+        Label(_WORKSPACE_TEMPLATE.format(filename = filename)),
+        substitutions = {
+            "{tool_repos}": _render_tool_repos(hub_name, tools),
+        },
+    )
+
 templates = struct(
-    env = _render_env,
-    env_tool = _render_env_tool,
     hub = _render_hub,
     hub_tool = _render_hub_tool,
+    tool = _render_tool,
+    tool_tool = _render_tool_tool,
+    workspace = _render_workspace,
 )
diff --git a/multitool/private/env_repo_template/BUILD.bazel.template b/multitool/private/tool_repo_template/BUILD.bazel.template
similarity index 100%
rename from multitool/private/env_repo_template/BUILD.bazel.template
rename to multitool/private/tool_repo_template/BUILD.bazel.template
diff --git a/multitool/private/env_repo_template/tools/BUILD.bazel.template b/multitool/private/tool_repo_template/tools/BUILD.bazel.template
similarity index 100%
rename from multitool/private/env_repo_template/tools/BUILD.bazel.template
rename to multitool/private/tool_repo_template/tools/BUILD.bazel.template
diff --git a/multitool/private/env_repo_tool_template/BUILD.bazel.template b/multitool/private/tool_repo_tool_template/BUILD.bazel.template
similarity index 100%
rename from multitool/private/env_repo_tool_template/BUILD.bazel.template
rename to multitool/private/tool_repo_tool_template/BUILD.bazel.template
diff --git a/multitool/private/env_repo_template/BUILD.bazel.template b/multitool/private/workspace_hub_repo_template/BUILD.bazel.template
similarity index 100%
copy from multitool/private/env_repo_template/BUILD.bazel.template
copy to multitool/private/workspace_hub_repo_template/BUILD.bazel.template
diff --git a/multitool/private/workspace_hub_repo_template/tools.bzl.template b/multitool/private/workspace_hub_repo_template/tools.bzl.template
new file mode 100644
index 0000000..a1c54d4
--- /dev/null
+++ b/multitool/private/workspace_hub_repo_template/tools.bzl.template
@@ -0,0 +1,4 @@
+load("@rules_multitool//multitool/private:multitool.bzl", "tool_repo")
+
+def register_tools():
+{tool_repos}