Refactor to properly support bzlmod (#9)

diff --git a/.bazelrc b/.bazelrc
index 0c36346..694f59b 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1 +1,3 @@
-common --enable_bzlmod
\ No newline at end of file
+common --enable_bzlmod
+
+common --lockfile_mode=off
diff --git a/.bazelversion b/.bazelversion
index 19b860c..66ce77b 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-6.4.0
+7.0.0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1811e52..4f169fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,13 +15,15 @@
       folders: |
         [
           ".",
+          "examples/module",
           "examples/target-determinator"
         ]
-      # we only support Bazel 6, and only with bzlmod enabled
+      # we only support Bazel 7, and only with bzlmod enabled
       exclude: |
         [
           {"bzlmodEnabled": false},
           {"bazelversion": "5.4.0"},
+          {"bazelversion": "6.4.0"},
         ]
       # this ruleset only supports linux and macos
       exclude_windows: true
diff --git a/BUILD.bazel b/BUILD.bazel
index 695684b..f5bf95d 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -1,7 +1,7 @@
 load("@buildifier_prebuilt//:rules.bzl", "buildifier", "buildifier_test")
 
 exports_files([
-    "WORKSPACE.bazel",  # used by buildifier to locate the root of the sandbox
+    "MODULE.bazel",
 ])
 
 buildifier(
@@ -18,5 +18,5 @@
     lint_mode = "warn",
     mode = "diff",
     no_sandbox = True,
-    workspace = "//:WORKSPACE.bazel",
+    workspace = "//:MODULE.bazel",
 )
diff --git a/MODULE.bazel b/MODULE.bazel
index 2f911cf..46427b6 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -8,3 +8,10 @@
 
 bazel_dep(name = "bazel_skylib", version = "1.4.1")
 bazel_dep(name = "buildifier_prebuilt", version = "6.1.2")
+bazel_dep(name = "platforms", version = "0.0.8")
+
+# ensure toolchains get registered
+multitool = use_extension("//multitool:extension.bzl", "multitool")
+use_repo(multitool, "multitool")
+
+register_toolchains("@multitool//toolchains:all")
diff --git a/examples/module/.bazelrc b/examples/module/.bazelrc
new file mode 100644
index 0000000..694f59b
--- /dev/null
+++ b/examples/module/.bazelrc
@@ -0,0 +1,3 @@
+common --enable_bzlmod
+
+common --lockfile_mode=off
diff --git a/examples/module/.bazelversion b/examples/module/.bazelversion
new file mode 100644
index 0000000..66ce77b
--- /dev/null
+++ b/examples/module/.bazelversion
@@ -0,0 +1 @@
+7.0.0
diff --git a/examples/module/BUILD.bazel b/examples/module/BUILD.bazel
new file mode 100644
index 0000000..755f3bb
--- /dev/null
+++ b/examples/module/BUILD.bazel
@@ -0,0 +1,12 @@
+exports_files(
+    ["multitool.lock.json"],
+)
+
+sh_test(
+    name = "integration_test",
+    srcs = ["integration_test.sh"],
+    args = [
+        "$(location @multitool//tools/target-determinator)",
+    ],
+    data = ["@multitool//tools/target-determinator"],
+)
diff --git a/examples/module/MODULE.bazel b/examples/module/MODULE.bazel
new file mode 100644
index 0000000..6625e33
--- /dev/null
+++ b/examples/module/MODULE.bazel
@@ -0,0 +1,18 @@
+"multitool example using target-determinator"
+
+module(
+    name = "multitool_examples__target_determinator",
+    version = "0.0.0",
+    compatibility_level = 1,
+)
+
+bazel_dep(name = "platforms", version = "0.0.8")
+bazel_dep(name = "rules_multitool", version = "0.0.0")
+local_path_override(
+    module_name = "rules_multitool",
+    path = "../..",
+)
+
+multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
+multitool.hub(lockfile = "//:multitool.lock.json")
+use_repo(multitool, "multitool")
diff --git a/multitool/private/external_repo_template/BUILD.bazel.template b/examples/module/WORKSPACE.bazel
similarity index 100%
copy from multitool/private/external_repo_template/BUILD.bazel.template
copy to examples/module/WORKSPACE.bazel
diff --git a/examples/module/integration_test.sh b/examples/module/integration_test.sh
new file mode 100755
index 0000000..7ac2187
--- /dev/null
+++ b/examples/module/integration_test.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -eu
+
+$1 -version
diff --git a/examples/module/multitool.lock.json b/examples/module/multitool.lock.json
new file mode 100644
index 0000000..110e440
--- /dev/null
+++ b/examples/module/multitool.lock.json
@@ -0,0 +1,84 @@
+{
+  "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"
+          }
+      ]
+  },
+  "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",
+            "os": "linux",
+            "cpu": "x86_64"
+        }
+    ]
+  },
+  "aws": {
+    "binaries": [
+        {
+            "kind": "pkg",
+            "url": "https://awscli.amazonaws.com/AWSCLIV2-2.8.4.pkg",
+            "sha256": "df0df526521a5b6c38b6954ec08c16453916daacca83582d37582654e9ca05a3",
+            "file": "aws-cli.pkg/Payload/aws-cli/aws",
+            "os": "macos",
+            "cpu": "x86_64"
+        },
+        {
+            "kind": "pkg",
+            "url": "https://awscli.amazonaws.com/AWSCLIV2-2.8.4.pkg",
+            "sha256": "df0df526521a5b6c38b6954ec08c16453916daacca83582d37582654e9ca05a3",
+            "file": "aws-cli.pkg/Payload/aws-cli/aws",
+            "os": "macos",
+            "cpu": "arm64"
+        },
+        {
+            "kind": "archive",
+            "url": "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.8.4.zip",
+            "sha256": "d23b59d08c129daeda7535051eaa082e139cc14d243995aa754d4b250b9c9329",
+            "file": "aws/dist/aws",
+            "os": "linux",
+            "cpu": "x86_64"
+        }
+
+    ]
+  }
+}
diff --git a/examples/target-determinator/.bazelrc b/examples/target-determinator/.bazelrc
index 0c36346..694f59b 100644
--- a/examples/target-determinator/.bazelrc
+++ b/examples/target-determinator/.bazelrc
@@ -1 +1,3 @@
-common --enable_bzlmod
\ No newline at end of file
+common --enable_bzlmod
+
+common --lockfile_mode=off
diff --git a/examples/target-determinator/BUILD.bazel b/examples/target-determinator/BUILD.bazel
index 2b3f93b..38a0bfd 100644
--- a/examples/target-determinator/BUILD.bazel
+++ b/examples/target-determinator/BUILD.bazel
@@ -2,7 +2,7 @@
     name = "integration_test",
     srcs = ["integration_test.sh"],
     args = [
-        "$(location @target-determinator//tool)",
+        "$(location @multitool//tools/target-determinator)",
     ],
-    data = ["@target-determinator//tool"],
+    data = ["@multitool//tools/target-determinator"],
 )
diff --git a/examples/target-determinator/WORKSPACE.bazel b/examples/target-determinator/WORKSPACE.bazel
index d5c6527..6146eae 100644
--- a/examples/target-determinator/WORKSPACE.bazel
+++ b/examples/target-determinator/WORKSPACE.bazel
@@ -1,30 +1,6 @@
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
 load("@rules_multitool//multitool:multitool.bzl", "multitool")
 
-http_file(
-    name = "target_determinator_linux_x86_64",
-    executable = True,
-    sha256 = "c8a09143e9fe6eccc4b27a6be92c5929e5a78034a8d0b4c43dbed4ee539ec903",
-    urls = ["https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.linux.amd64"],
-)
-
-http_file(
-    name = "target_determinator_macos_arm64",
-    executable = True,
-    sha256 = "8f975b471c4a51d32781b757e1ece9700221bfd4c0ea507c18fa382360d1111f",
-    urls = ["https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.arm64"],
-)
-
-http_file(
-    name = "target_determinator_macos_x86_64",
-    executable = True,
-    sha256 = "8c7245603dede429b978e214ca327c3f3d686a1bc712c1298fca0396a0f25f23",
-    urls = ["https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.amd64"],
-)
-
 multitool(
-    name = "target-determinator",
-    linux_x86_64_binary = "@target_determinator_linux_x86_64//file",
-    macos_arm64_binary = "@target_determinator_macos_arm64//file",
-    macos_x86_64_binary = "@target_determinator_macos_x86_64//file",
+    name = "multitool",
+    lockfile = "//:multitool.lock.json",
 )
diff --git a/examples/target-determinator/multitool.lock.json b/examples/target-determinator/multitool.lock.json
new file mode 100644
index 0000000..436d090
--- /dev/null
+++ b/examples/target-determinator/multitool.lock.json
@@ -0,0 +1,27 @@
+{
+  "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"
+          }
+      ]
+  }
+}
diff --git a/multitool/BUILD.bazel b/multitool/BUILD.bazel
index 812b7d5..51fd426 100644
--- a/multitool/BUILD.bazel
+++ b/multitool/BUILD.bazel
@@ -1,6 +1,12 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 
 bzl_library(
+    name = "extension",
+    srcs = ["extension.bzl"],
+    visibility = ["//visibility:public"],
+)
+
+bzl_library(
     name = "multitool",
     srcs = ["multitool.bzl"],
     visibility = ["//visibility:public"],
diff --git a/multitool/extension.bzl b/multitool/extension.bzl
new file mode 100644
index 0000000..3ef40ee
--- /dev/null
+++ b/multitool/extension.bzl
@@ -0,0 +1,28 @@
+"multitool"
+
+load("//multitool/private:multitool.bzl", _hub = "hub")
+
+hub = tag_class(
+    attrs = {
+        "lockfile": attr.label(mandatory = True, allow_single_file = True),
+    },
+)
+
+def _extension(module_ctx):
+    lockfiles = []
+    for mod in module_ctx.modules:
+        for h in mod.tags.hub:
+            lockfiles.append(h.lockfile)
+
+    # TODO: we should be able to support multiple hubs
+    _hub(
+        name = "multitool",
+        lockfiles = lockfiles,
+    )
+
+multitool = module_extension(
+    implementation = _extension,
+    tag_classes = {
+        "hub": hub,
+    },
+)
diff --git a/multitool/private/external_repo_template/MODULE.bazel.template b/multitool/private/external_repo_template/MODULE.bazel.template
deleted file mode 100644
index b905e01..0000000
--- a/multitool/private/external_repo_template/MODULE.bazel.template
+++ /dev/null
@@ -1,3 +0,0 @@
-"Module for toolchain {name}"
-
-module(name = "{name}")
diff --git a/multitool/private/external_repo_template/WORKSPACE.bazel.template b/multitool/private/external_repo_template/WORKSPACE.bazel.template
deleted file mode 100644
index ed947c4..0000000
--- a/multitool/private/external_repo_template/WORKSPACE.bazel.template
+++ /dev/null
@@ -1 +0,0 @@
-workspace(name = "{name}")
diff --git a/multitool/private/external_repo_template/linux/arm64/BUILD.bazel.template b/multitool/private/external_repo_template/linux/arm64/BUILD.bazel.template
deleted file mode 100644
index 4254ab0..0000000
--- a/multitool/private/external_repo_template/linux/arm64/BUILD.bazel.template
+++ /dev/null
@@ -1,22 +0,0 @@
-load("//toolchain_type:toolchain_type.bzl", "TOOLCHAIN_TYPE")
-load("//:toolchain_info.bzl", "toolchain_info")
-
-PLATFORMS = [
-    "@platforms//cpu:arm64",
-    "@platforms//os:linux",
-]
-
-toolchain_info(
-    name = "toolchain",
-    cpu = "arm64",
-    {linux_arm64_binary}
-    os = "linux",
-)
-
-toolchain(
-    name = "arm64",
-    exec_compatible_with = PLATFORMS,
-    target_compatible_with = PLATFORMS,
-    toolchain = ":toolchain",
-    toolchain_type = TOOLCHAIN_TYPE,
-)
diff --git a/multitool/private/external_repo_template/linux/x86_64/BUILD.bazel.template b/multitool/private/external_repo_template/linux/x86_64/BUILD.bazel.template
deleted file mode 100644
index 3878f03..0000000
--- a/multitool/private/external_repo_template/linux/x86_64/BUILD.bazel.template
+++ /dev/null
@@ -1,22 +0,0 @@
-load("//toolchain_type:toolchain_type.bzl", "TOOLCHAIN_TYPE")
-load("//:toolchain_info.bzl", "toolchain_info")
-
-PLATFORMS = [
-    "@platforms//cpu:x86_64",
-    "@platforms//os:linux",
-]
-
-toolchain_info(
-    name = "toolchain",
-    cpu = "x86_64",
-    {linux_x86_64_binary}
-    os = "linux",
-)
-
-toolchain(
-    name = "x86_64",
-    exec_compatible_with = PLATFORMS,
-    target_compatible_with = PLATFORMS,
-    toolchain = ":toolchain",
-    toolchain_type = TOOLCHAIN_TYPE,
-)
diff --git a/multitool/private/external_repo_template/macos/arm64/BUILD.bazel.template b/multitool/private/external_repo_template/macos/arm64/BUILD.bazel.template
deleted file mode 100644
index 4b0121e..0000000
--- a/multitool/private/external_repo_template/macos/arm64/BUILD.bazel.template
+++ /dev/null
@@ -1,22 +0,0 @@
-load("//toolchain_type:toolchain_type.bzl", "TOOLCHAIN_TYPE")
-load("//:toolchain_info.bzl", "toolchain_info")
-
-PLATFORMS = [
-    "@platforms//cpu:arm64",
-    "@platforms//os:macos",
-]
-
-toolchain_info(
-    name = "toolchain",
-    cpu = "arm64",
-    {macos_arm64_binary}
-    os = "macos",
-)
-
-toolchain(
-    name = "arm64",
-    exec_compatible_with = PLATFORMS,
-    target_compatible_with = PLATFORMS,
-    toolchain = ":toolchain",
-    toolchain_type = TOOLCHAIN_TYPE,
-)
diff --git a/multitool/private/external_repo_template/macos/x86_64/BUILD.bazel.template b/multitool/private/external_repo_template/macos/x86_64/BUILD.bazel.template
deleted file mode 100644
index dd517c7..0000000
--- a/multitool/private/external_repo_template/macos/x86_64/BUILD.bazel.template
+++ /dev/null
@@ -1,22 +0,0 @@
-load("//toolchain_type:toolchain_type.bzl", "TOOLCHAIN_TYPE")
-load("//:toolchain_info.bzl", "toolchain_info")
-
-PLATFORMS = [
-    "@platforms//cpu:x86_64",
-    "@platforms//os:macos",
-]
-
-toolchain_info(
-    name = "toolchain",
-    cpu = "x86_64",
-    {macos_x86_64_binary}
-    os = "macos",
-)
-
-toolchain(
-    name = "x86_64",
-    exec_compatible_with = PLATFORMS,
-    target_compatible_with = PLATFORMS,
-    toolchain = ":toolchain",
-    toolchain_type = TOOLCHAIN_TYPE,
-)
diff --git a/multitool/private/external_repo_template/tool/BUILD.bazel.template b/multitool/private/external_repo_template/tool/BUILD.bazel.template
deleted file mode 100644
index 9a96b39..0000000
--- a/multitool/private/external_repo_template/tool/BUILD.bazel.template
+++ /dev/null
@@ -1,6 +0,0 @@
-load(":tool.bzl", "tool")
-
-tool(
-    name = "tool",
-    visibility = ["//visibility:public"],
-)
diff --git a/multitool/private/external_repo_template/tool/tool.bzl.template b/multitool/private/external_repo_template/tool/tool.bzl.template
deleted file mode 100644
index 5579bc8..0000000
--- a/multitool/private/external_repo_template/tool/tool.bzl.template
+++ /dev/null
@@ -1,9 +0,0 @@
-load("//toolchain_type:toolchain_type.bzl", "TOOLCHAIN_TYPE")
-
-def _tool_impl(ctx):
-    toolchain = ctx.toolchains[TOOLCHAIN_TYPE]
-    output = ctx.actions.declare_file(ctx.label.name)
-    ctx.actions.symlink(output = output, target_file = toolchain.executable)
-    return [DefaultInfo(executable = output)]
-
-tool = rule(executable = True, implementation = _tool_impl, toolchains = [TOOLCHAIN_TYPE])
diff --git a/multitool/private/external_repo_template/toolchain_type/BUILD.bazel.template b/multitool/private/external_repo_template/toolchain_type/BUILD.bazel.template
deleted file mode 100644
index 19b1b56..0000000
--- a/multitool/private/external_repo_template/toolchain_type/BUILD.bazel.template
+++ /dev/null
@@ -1,4 +0,0 @@
-toolchain_type(
-    name = "toolchain_type",
-    visibility = ["//:__subpackages__"],
-)
diff --git a/multitool/private/external_repo_template/toolchain_type/toolchain_type.bzl.template b/multitool/private/external_repo_template/toolchain_type/toolchain_type.bzl.template
deleted file mode 100644
index 08854cd..0000000
--- a/multitool/private/external_repo_template/toolchain_type/toolchain_type.bzl.template
+++ /dev/null
@@ -1 +0,0 @@
-TOOLCHAIN_TYPE = "@{name}//toolchain_type"
diff --git a/multitool/private/external_repo_template/BUILD.bazel.template b/multitool/private/hub_repo_template/BUILD.bazel.template
similarity index 100%
rename from multitool/private/external_repo_template/BUILD.bazel.template
rename to multitool/private/hub_repo_template/BUILD.bazel.template
diff --git a/multitool/private/external_repo_template/toolchain_info.bzl.template b/multitool/private/hub_repo_template/toolchain_info.bzl.template
similarity index 94%
rename from multitool/private/external_repo_template/toolchain_info.bzl.template
rename to multitool/private/hub_repo_template/toolchain_info.bzl.template
index 778d9a5..36775af 100644
--- a/multitool/private/external_repo_template/toolchain_info.bzl.template
+++ b/multitool/private/hub_repo_template/toolchain_info.bzl.template
@@ -1,16 +1,18 @@
+# generated by multitool
+
 def _toolchain_info_impl(ctx):
     return [
         platform_common.ToolchainInfo(
-            cpu = ctx.attr.cpu,
             executable = ctx.file.executable,
+            cpu = ctx.attr.cpu,
             os = ctx.attr.os,
         ),
     ]
 
 toolchain_info = rule(
     attrs = dict(
-        cpu = attr.string(mandatory = True, values = ["arm64", "x86_64"]),
         executable = attr.label(allow_single_file = True),
+        cpu = attr.string(mandatory = True, values = ["arm64", "x86_64"]),
         os = attr.string(mandatory = True, values = ["linux", "macos"]),
     ),
     implementation = _toolchain_info_impl,
diff --git a/multitool/private/hub_repo_template/toolchains/BUILD.bazel.template b/multitool/private/hub_repo_template/toolchains/BUILD.bazel.template
new file mode 100644
index 0000000..a20d481
--- /dev/null
+++ b/multitool/private/hub_repo_template/toolchains/BUILD.bazel.template
@@ -0,0 +1,5 @@
+# generated by multitool
+
+{loads}
+
+{defines}
diff --git a/multitool/private/hub_repo_template/tools/BUILD.bazel.template b/multitool/private/hub_repo_template/tools/BUILD.bazel.template
new file mode 100644
index 0000000..14f9e5b
--- /dev/null
+++ b/multitool/private/hub_repo_template/tools/BUILD.bazel.template
@@ -0,0 +1 @@
+# generated by multitool
diff --git a/multitool/private/multitool.bzl b/multitool/private/multitool.bzl
index 031491c..10b08f9 100644
--- a/multitool/private/multitool.bzl
+++ b/multitool/private/multitool.bzl
@@ -1,65 +1,149 @@
-"multitool"
+"multitool hub implementation"
 
-_COMMON_FILES_TO_GENERATE = [
-    "BUILD.bazel",
-    "MODULE.bazel",
-    "tool/BUILD.bazel",
-    "tool/tool.bzl",
-    "toolchain_info.bzl",
-    "toolchain_type/BUILD.bazel",
-    "toolchain_type/toolchain_type.bzl",
-    "WORKSPACE.bazel",
-]
+_HUB_TEMPLATE = "//multitool/private:hub_repo_template/{filename}.template"
+_TOOL_TEMPLATE = "//multitool/private:tool_template/{filename}.template"
 
-_PLATFORMS = [
-    ("linux", "arm64"),
-    ("linux", "x86_64"),
-    ("macos", "arm64"),
-    ("macos", "x86_64"),
-]
-_TEMPLATE = "//multitool/private:external_repo_template/{filename}.template"
-
-def _binary(os, cpu):
-    return "{os}_{cpu}_binary".format(os = os, cpu = cpu)
-
-def _template(ctx, filename, substitutions):
-    ctx.template(
+def _render_hub(rctx, filename, substitutions = None):
+    rctx.template(
         filename,
-        Label(_TEMPLATE.format(filename = filename)),
-        substitutions = substitutions,
+        Label(_HUB_TEMPLATE.format(filename = filename)),
+        substitutions = substitutions or {},
     )
 
-def _multitool_impl(ctx):
-    substitutions = {
-        "{%s}" % substitution: value
-        for substitution, value in dict(
-            {
-                attrname: """    executable = "%s",""" % str(attrval) if attrval else ""
-                for os, cpu in _PLATFORMS
-                for attrname in [_binary(os, cpu)]
-                for attrval in [getattr(ctx.attr, attrname)]
-            },
-            name = ctx.name,
-        ).items()
-    }
+def _render_tool(rctx, tool_name, filename, substitutions = None):
+    rctx.template(
+        "tools/{tool_name}/{filename}".format(tool_name = tool_name, filename = filename),
+        Label(_TOOL_TEMPLATE.format(filename = filename)),
+        substitutions = {
+            "{name}": tool_name,
+        } | (substitutions or {}),
+    )
 
-    for filename in _COMMON_FILES_TO_GENERATE:
-        _template(ctx, filename, substitutions)
+def _check(condition, message):
+    "fails iff condition is False and emits message"
+    if not condition:
+        fail(message)
 
-    attr_keys = dir(ctx.attr)
-    for os, cpu in _PLATFORMS:
-        if _binary(os, cpu) in attr_keys and getattr(ctx.attr, _binary(os, cpu)) != None:
-            _template(ctx, "%s/%s/BUILD.bazel" % (os, cpu), substitutions)
+def _multitool_hub_impl(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))
 
-_multitool = repository_rule(
-    attrs = {_binary(os, cpu): attr.label() for os, cpu in _PLATFORMS},
-    implementation = _multitool_impl,
+    loads = []
+    defines = []
+
+    for tool_name, tool in tools.items():
+        toolchains = []
+
+        for binary in tool["binaries"]:
+            _check(binary["os"] in ["linux", "macos"], "Unknown os '{os}'".format(os = binary["os"]))
+            _check(binary["cpu"] in ["x86_64", "arm64"], "Unknown cpu '{cpu}'".format(cpu = binary["cpu"]))
+
+            target_executable = "tools/{tool_name}/{os}_{cpu}_executable".format(
+                tool_name = tool_name,
+                cpu = binary["cpu"],
+                os = binary["os"],
+            )
+
+            if binary["kind"] == "file":
+                rctx.download(
+                    url = binary["url"],
+                    sha256 = binary["sha256"],
+                    output = target_executable,
+                    executable = True,
+                )
+            elif binary["kind"] == "archive":
+                archive_path = "tools/{tool_name}/{os}_{cpu}_archive".format(
+                    tool_name = tool_name,
+                    cpu = binary["cpu"],
+                    os = binary["os"],
+                )
+
+                rctx.download_and_extract(
+                    url = binary["url"],
+                    sha256 = binary["sha256"],
+                    output = archive_path,
+                )
+
+                # link to the executable
+                rctx.symlink(
+                    "{archive_path}/{file}".format(archive_path = archive_path, file = binary["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
+
+                archive_path = "tools/{tool_name}/{os}_{cpu}_pkg".format(
+                    tool_name = tool_name,
+                    cpu = binary["cpu"],
+                    os = binary["os"],
+                )
+
+                rctx.download(
+                    url = binary["url"],
+                    sha256 = binary["sha256"],
+                    output = archive_path + ".pkg",
+                )
+
+                rctx.execute([pkgutil_cmd, "--expand-full", archive_path + ".pkg", archive_path])
+
+                # link to the executable
+                rctx.symlink(
+                    "{archive_path}/{file}".format(archive_path = archive_path, file = binary["file"]),
+                    target_executable,
+                )
+            else:
+                fail("Unknown 'kind' {kind}".format(kind = binary["kind"]))
+
+            toolchains.append('\n    _declare_toolchain(name="{name}", os="{os}", cpu="{cpu}")'.format(
+                name = tool_name,
+                cpu = binary["cpu"],
+                os = binary["os"],
+            ))
+
+        _render_tool(rctx, tool_name, "BUILD.bazel")
+        _render_tool(rctx, tool_name, "tool.bzl", {
+            "{toolchains}": "\n".join(toolchains),
+        })
+
+        clean_name = tool_name.replace("-", "_")
+        loads.append('load("//tools/{tool_name}:tool.bzl", declare_{clean_name}_toolchains = "declare_toolchains")'.format(
+            tool_name = tool_name,
+            clean_name = clean_name,
+        ))
+        defines.append("declare_{clean_name}_toolchains()".format(clean_name = clean_name))
+
+    _render_hub(rctx, "BUILD.bazel")
+    _render_hub(rctx, "toolchain_info.bzl")
+    _render_hub(rctx, "tools/BUILD.bazel")
+    _render_hub(rctx, "toolchains/BUILD.bazel", {
+        "{loads}": "\n".join(loads),
+        "{defines}": "\n".join(defines),
+    })
+
+_multitool_hub = repository_rule(
+    attrs = {
+        "lockfiles": attr.label_list(mandatory = True, allow_files = True),
+    },
+    implementation = _multitool_hub_impl,
 )
 
-def multitool(name, **kwargs):
-    _multitool(name = name, **kwargs)
-    native.register_toolchains(*[
-        "@{name}//{os}/{cpu}".format(name = name, os = os, cpu = cpu)
-        for os, cpu in _PLATFORMS
-        if kwargs.get("{os}_{cpu}_binary".format(os = os, cpu = cpu)) != None
-    ])
+def hub(name, lockfiles):
+    "Create a multitool hub."
+    _multitool_hub(name = name, lockfiles = lockfiles)
+
+def multitool(name, lockfile):
+    "(non-bzlmod) Create a multitool hub and register its toolchains."
+
+    _multitool_hub(name = name, lockfiles = [lockfile])
+
+    native.register_toolchains("@multitool//toolchains:all")
diff --git a/multitool/private/tool_template/BUILD.bazel.template b/multitool/private/tool_template/BUILD.bazel.template
new file mode 100644
index 0000000..0165c6d
--- /dev/null
+++ b/multitool/private/tool_template/BUILD.bazel.template
@@ -0,0 +1,15 @@
+# generated by multitool
+
+load(":tool.bzl", "tool")
+
+exports_files(glob(include=["*_executable"]))
+
+toolchain_type(
+    name = "toolchain_type",
+    visibility = ["//:__subpackages__"],
+)
+
+tool(
+    name = "{name}",
+    visibility = ["//visibility:public"],
+)
diff --git a/multitool/private/tool_template/tool.bzl.template b/multitool/private/tool_template/tool.bzl.template
new file mode 100644
index 0000000..5ab6394
--- /dev/null
+++ b/multitool/private/tool_template/tool.bzl.template
@@ -0,0 +1,39 @@
+# generated by multitool
+
+load("//:toolchain_info.bzl", "toolchain_info")
+
+_TOOLCHAIN_TYPE = "//tools/{name}:toolchain_type"
+
+def _tool_impl(ctx):
+    toolchain = ctx.toolchains[_TOOLCHAIN_TYPE]
+    output = ctx.actions.declare_file(ctx.label.name)
+    ctx.actions.symlink(output = output, target_file = toolchain.executable)
+    return [DefaultInfo(executable = output)]
+
+tool = rule(executable = True, implementation = _tool_impl, toolchains = [_TOOLCHAIN_TYPE])
+
+def _declare_toolchain(name, os, cpu):
+    toolchain_info(
+        name = "{name}_{os}_{cpu}_toolchain_info".format(name=name, os=os, cpu=cpu),
+        executable = "//tools/{name}:{os}_{cpu}_executable".format(name=name, os=os, cpu=cpu),
+        os = os,
+        cpu = cpu,
+    )
+
+    native.toolchain(
+        name = "{name}_{os}_{cpu}_toolchain".format(name=name, os=os, cpu=cpu),
+        toolchain = ":{name}_{os}_{cpu}_toolchain_info".format(name=name, os=os, cpu=cpu),
+        toolchain_type = _TOOLCHAIN_TYPE,
+        exec_compatible_with = [
+            "@platforms//cpu:{cpu}".format(cpu=cpu),
+            "@platforms//os:{os}".format(os=os),
+        ],
+        target_compatible_with = [
+            "@platforms//cpu:{cpu}".format(cpu=cpu),
+            "@platforms//os:{os}".format(os=os),
+        ],
+    )
+
+def declare_toolchains():
+    "toolchain targets"
+    {toolchains}
diff --git a/readme.md b/readme.md
index e27b4de..c71480f 100644
--- a/readme.md
+++ b/readme.md
@@ -4,41 +4,52 @@
 
 ## Usage
 
-For a quickstart, see the [target-determinator example](examples/target-determinator/).
+For a quickstart, see the [bzlmod example](examples/module/).
 
-Load the ruleset in your **MODULE.bazel**:
+Define a lockfile that references the tools to load:
+
+```json
+{
+  "tool-name": {
+    "binaries": [
+      {
+        "kind": "file",
+        "url": "https://...",
+        "sha256": "sha256 of the file",
+        "os": "linux|macos",
+        "cpu": "x86_64|arm64"
+      }
+    ]
+  }
+}
+```
+
+The lockfile supports the following binary kinds:
+
+- **file**: the URL refers to a file to download
+
+  - `sha256`: the sha256 of the downloaded file
+
+- **archive**: the URL referes to an archive to download, specify additional options:
+
+  - `file`: executable file within the archive
+  - `sha256`: the sha256 of the downloaded archive
+
+- **pkg**: the URL refers to a MacOS pkg archive to download, specify additional options:
+
+  - `file`: executable file within the archive
+  - `sha256`: the sha256 of the downloaded pkg archive
+
+Save your lockfile and ensure the file is exported using `export_files` so that it's available to Bazel.
+
+Once your lockfile is defined, load the ruleset in your **MODULE.bazel** and create a hub that refers to your lockfile:
 
 ```python
 bazel_dep(name = "rules_multitool", version = "0.0.0")
+
+multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
+multitool.hub(lockfile = "//:multitool.lock.json")
+use_repo(multitool, "multitool")
 ```
 
-Define tools in your **WORKSPACE.bazel**:
-
-```python
-load("@rules_multitool//multitool:multitool.bzl", "multitool")
-
-# Load OS and architecture-specific binaries
-http_file(
-    name = "target_determinator_linux_x86_64",
-    executable = True,
-    sha256 = "c8a09143e9fe6eccc4b27a6be92c5929e5a78034a8d0b4c43dbed4ee539ec903",
-    urls = ["https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.linux.amd64"],
-)
-
-http_file(
-    name = "target_determinator_macos_x86_64",
-    executable = True,
-    sha256 = "8c7245603dede429b978e214ca327c3f3d686a1bc712c1298fca0396a0f25f23",
-    urls = ["https://github.com/bazel-contrib/target-determinator/releases/download/v0.25.0/target-determinator.darwin.amd64"],
-)
-
-# Declare a combined tool, runnable as `bazel run @[name]//tool`
-multitool(
-    name = "target-determinator",
-    linux_x86_64_binary = "@target_determinator_linux_x86_64//file",
-    macos_x86_64_binary = "@target_determinator_macos_x86_64//file",
-    # also valid to specify:
-    # linux_arm64_binary
-    # macos_arm64_binary
-)
-```
+Tools may then be accessed using `@multitool//tools/tool-name`.