---
title: "CLI Pack System Implementation Plan"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/cli-pack-system-plan
markdown: https://docs.nodetool.ai/cli-pack-system-plan.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/cli-pack-system-plan.md
---

# CLI Pack System Implementation Plan

# CLI Pack System Implementation Plan

**Status:** Plan. Not started.
**Design:** [CLI Pack System](cli-pack-system-design.md), called "the pack spec"
below. **Related:** [Runtime Install Design](runtime-install-design.md).

This plan turns the five migration steps of the pack spec (section 20) into
assignments. Each assignment names the assignments that block it, the files it
changes, the work, and its acceptance checks. Assignments A1 to A9 are the
step 1 items. A10 and later are new. Decisions D17 and later continue the
decision codes of the pack spec.

## 1. Execution rules

| Code | Rule |
|---|---|
| A0.1 | Start an assignment only when every assignment that blocks it is on `main`. |
| A0.2 | Re-read the files in section 3 before you edit them. The line numbers in the pack spec date from its review. |
| A0.3 | Land each assignment as one commit, or as a series that is green at each commit. Run the [mandatory post-change verification](../AGENTS.md#mandatory-post-change-verification) before each commit. |
| A0.4 | Run `npm run backend:smoke` for each assignment that changes a manifest, the server entry, or a lazy import. The desktop bundle flattens modules, so a cut can pass in the monorepo and fail in the artifact. |
| A0.5 | Make each new gate fail on deliberately invalid input before you enforce it (pack spec 19.2). |
| A0.6 | Do not change the published CLI until A24. Before A24, the CLI manifest keeps its node packs, and the installer stays behind `NODETOOL_PACKS_INSTALLER=1`. |
| A0.7 | When an assignment changes a decision, update the pack spec in the same commit. |

## 2. Plan decisions

The pack spec leaves these points open, or the code has changed since the
spec was written.

### D17. One registry for runtime libraries

The runtime library tier of the pack spec uses the ids of
`RUNTIME_NPM_PACKAGES` (`packages/protocol/src/runtime-npm-packages.ts`).
These rules apply:

- A missing library throws `missingRuntimePackageError`.
- `nodetool runtime add <id>` installs a library. `nodetool packs add`
  installs node packs only.
- The catalog has no `CatalogRuntimeLibrary` entries. A pack lists runtime
  package ids in `runtimeLibraries`.
- No `importRuntimeLibrary` helper is built. Core code calls
  `importOptionalModule` and throws the runtime package error.

The CLI already works this way. `3f9f9c2c98` loads `js-tiktoken` through the
`tokenizer` id. Without D17, NodeTool has two registries, two install commands,
two errors and two install roots for one concept.

| Pack spec library | Runtime package id |
|---|---|
| `imaging` (`sharp`, `@napi-rs/canvas`) | `imaging` (new) |
| `llama` | `node-llama-cpp` (exists) |
| `typescript` | `typescript` (new) |
| `tokenizer` | `tokenizer` (exists) |
| `tracing` (OpenTelemetry) | `tracing` (new) |
| `esbuild` (6.1 row, not in the tier list) | `esbuild` (new) |
| `game-renderer` | none, see D18 |

The desktop app and the Docker image bundle the four new libraries. Their
registry entries name the hosts that bundle them. The desktop Package Manager
shows these entries as included and never offers an install.

### D18. First-party libraries belong to a pack

`game-renderer` becomes a regular dependency of `game-nodes`, so the `game`
pack carries it. `transformers-js-provider` belongs to the `transformers-js`
pack in the same way.

- `CatalogPack` gains `provides: string[]`. This is the list of first-party
  npm names in the pack closure other than the pack package itself.
- `importPack(packId, specifier?)` accepts a specifier under the pack's own
  name or under a `provides` name.
- `nodetool game`, the game capabilities and the games router return
  `missing_pack` for `game`.

A first-party package then never needs a runtime library pin that follows the
core version.

### D19. Each node package exports its own registrar

No domain node package exports a registrar today. `registerBaseNodes`
(`packages/base-nodes/src/index.ts`) registers the merged `ALL_BASE_NODES`
list. The generator in pack spec 9.2 needs one registrar per pack.

- Each domain node package exports `register<Name>Nodes(registry)` from its
  `src/index.ts`. The domain packages are `core`, `text`, `llm`, `code`,
  `data`, `document`, `image`, `audio`, `video`, `integration`, `automation`,
  `game`, `blender` and `manimgx`.
- The four metadata overrides in `registerBaseNodes` move into
  `registerCoreNodes`. The overrides are Preview, Workflow, App and Subgraph.
- `registerBaseNodes` becomes a list of calls to the domain registrars, in the
  current `ALL_BASE_NODES` order. The desktop app sees no change.

### D20. The bundled server entry moves to a private workspace

`packages/websocket/src/server.ts` imports `base-nodes`, the local providers
and `setCodeNodeAgentsModule` statically (`:28-30`).
`scripts/bundle-backend.mjs` bundles `packages/websocket/dist/server.js`.
After the cut, `websocket` cannot import packs. The bundle still needs a
static import of every pack.

- `websocket` exports `startServer({ packSource })`. Its main block uses the
  catalog pack source (D22). `nodetool serve` starts this main block.
- A new private workspace `packages/server` (`@nodetool-ai/server`,
  `"private": true`) has the entry `src/main.ts`. It imports
  `@nodetool-ai/base-nodes/builtin-packs` and the local providers, and it
  calls `startServer` with the bundled pack source.
- These consumers start `@nodetool-ai/server`:
  - the bundle entry (`ENTRY_POINT` in `scripts/bundle-backend.mjs`)
  - the Docker image, which uses the same bundle script
  - `scripts/dev-server.mjs`
  - `dev:server:dist`
- `@nodetool-ai/server` declares each optional peer of a core package as a
  regular dependency. This keeps the monorepo tree, the desktop bundle and the
  image complete (pack spec R4).

### D21. The catalog stores node metadata as sorted text

The repository stores `packages/node-catalog/data/nodes.ndjson`, with one
node per line, sorted by `node_type`. The package build writes
`dist/nodes.ndjson.gz`. A gzip file in git changes completely on each node
edit, so its history cannot be compressed and its diff cannot be reviewed.
`index.json` stays as specified in the pack spec.

### D22. Steps 1 and 2 register every pack that resolves

The pack spec cuts the static pack imports in step 1, but it builds the
installed-pack loader in step 3. Between these steps, the CLI must still load
the packs that its manifest installs (A0.6).

`registerAvailablePacks(registry, policy)` is part of A2. It works in four
steps:

1. Read the catalog.
2. Call `importPack` for each pack that the policy enables.
3. Call the registrar of each pack that resolves.
4. Load catalog entries for the node types of every other pack.

With all packs installed, the result is equal to `registerBuiltinPacks`. With
no packs installed, the result is the core nodes plus catalog entries. A15
replaces the "resolves" test with the installed set and its version check.

## 3. Current seams

| Area | Starting point | Consequence |
|---|---|---|
| Pack ids | `packages/protocol/src/builtin-packs.ts` (`BUILTIN_NODE_PACKS`, 14 ids, `base` required) | The desktop keeps these ids. The catalog adds the split ids. `findBuiltinPackForNodeType` picks by namespace, which is ambiguous (pack spec 9.2). |
| Registrars | `packages/base-nodes/src/builtin-packs.ts` (`BUILTIN_PACK_REGISTRARS`, `registerBuiltinPacks`, `CLI_BUILTIN_PACK_POLICY`), `packages/base-nodes/src/index.ts` (`ALL_BASE_NODES`, `registerBaseNodes`) | D19 and D22. Keep the policy semantics: the CLI excludes `kie`, `topaz`, `higgsfield` and `together`, and local runs also exclude `atlascloud`. |
| Server registry | `packages/websocket/src/node-registry-setup.ts:28-33` | It imports `registerBuiltinPacks`, `setBuiltinPackEnabled`, `applyBuiltinNodePolicy` and `builtinPackUnavailableReason`. The pack source interface (A12) covers these four calls. `applyBuiltinNodePolicy` uses `protocol` only, so it moves to core. |
| Server entry | `packages/websocket/src/server.ts:28-30`, `scripts/bundle-backend.mjs:42-48`, `scripts/dev-server.mjs:79` | D20. |
| CLI serve | `packages/cli/src/nodetool-main.ts:452` | It spawns `node ../../websocket/dist/server.js`. Keep the path, and use `process.execPath` instead of `node`. |
| CLI registry | `packages/cli/src/node-registry.ts:12-15`, `packages/cli/src/nodetool-main.ts:46`, `packages/cli/src/providers.ts:38-42` | Static pack and provider imports. |
| Lazy modules | `packages/config/src/optional-modules.ts`, `packages/cli/src/optional-node-root.ts`, `packages/cli/src/commands/runtime.ts` | `importOptionalModule` and the `runtime` command exist. A19 extracts the npm runner from `optional-node-root.ts`. |
| Image codecs | `packages/runtime/src/image-codec.ts` | `sharp` is lazy already. Its error text names `npm install sharp`. A4.1 changes it to the `imaging` runtime error. |
| Packed smoke | `scripts/smoke-packed-cli.mjs` | Packs every public workspace and installs the CLI tarball into a throwaway project with `overrides`. A7 and A8 extend it. |
| Publishing | `.github/workflows/publish.yaml:19,83-97`, `scripts/publish-packages.mjs`, `scripts/prepare-publish.mjs` | Uses `NODE_AUTH_TOKEN` and `--provenance`. A13. |
| Test selection | `scripts/test-affected.mjs` (`PATH_CHECKS`), `packages/cli/src/harness/registry.ts` | A1 adds the catalog drift check. |
| Validation | `packages/node-sdk/src/registry.ts` (`NodeMetadataSource`), `packages/node-sdk/src/graph-validation.ts` | A3. |

## 4. Phase 0: prerequisites

### A10. Land the open work and correct the pack spec

**Blocked by:** none.

1. Commit the two design docs.
2. Commit the `runtime` command and D16 in a separate commit.
3. Correct the stale parts of the pack spec:
   - 6.1 and 6.2 still list cuts that are done. The done cuts are
     `typescript` (`aa3b120861`), `js-tiktoken` (`3f9f9c2c98`) and
     `node-llama-cpp` in `cli`. `office-text-extractor` is uncommitted.
   - The OpenTelemetry row says six packages. Three remain (`core`,
     `resources`, `sdk-trace-base`). They also pull in `sdk-trace` and
     `semantic-conventions`.
   - Q7 is answered. Q10 and the fs-safe part of the 8.4 ratchet are
     obsolete, because `fefdcafc37` removed fs-safe.
4. Add D17 to D22 to the pack spec, or link this plan from section 5.

**Acceptance:** the working tree has no packs or runtime work outside a commit.
The pack spec names no cut that is already done.

### A11. Domain registrars (D19)

**Blocked by:** none.

**Files:** `packages/*-nodes/src/index.ts` for the 14 domain packages, and
`packages/base-nodes/src/index.ts`.

1. Add a registrar to each domain package. It registers the lists that
   `ALL_BASE_NODES` takes from that package, in the same order.
2. Move the four metadata overrides into `registerCoreNodes`.
3. Change `registerBaseNodes` to call the 14 registrars.

**Acceptance:**

- A new test in `packages/base-nodes/tests/` builds a registry with the old
  `registerBaseNodes` logic and a registry with the new logic. It requires
  equal node type lists and deeply equal metadata for each type. Keep the
  snapshot of the old logic as a fixture. Do not keep the old code.
- Each domain registrar registers only the types of its own package. The test
  fails when two registrars register one type.
- `npm run backend:smoke` passes.

### A12. The pack source and the server entry (D20)

**Blocked by:** A11.

**Files:** `packages/websocket/src/server.ts`,
`packages/websocket/src/node-registry-setup.ts`, the new `packages/server/`,
`scripts/bundle-backend.mjs`, `scripts/dev-server.mjs`, the root
`package.json`, `Dockerfile` and `electron/` only if a path changes.

1. Define `PackSource` in `websocket`. It has `register`, `setEnabled`,
   `unavailableReason` and `registerProviders`.
2. Move `applyBuiltinNodePolicy` into `node-sdk`. Re-export it from
   `base-nodes` for existing callers.
3. Split `server.ts` into `startServer(options)` and a main block.
4. Create `packages/server` with `src/main.ts` and the bundled pack source.
   Add the optional peers of the core packages as regular dependencies.
5. Point the bundle entry, `dev-server.mjs` and `dev:server:dist` to the new
   entry.
6. Import `setCodeNodeAgentsModule` from `code-nodes`, not `base-nodes`.

Until A2 lands, the main block in `websocket` uses the bundled source through
a dynamic import of `@nodetool-ai/server`. A4.6 removes this temporary path.

**Acceptance:**

- `npm run backend:smoke` passes.
- `npm run dev:server` serves `/health`.
- The desktop node list is unchanged. Use the A11 snapshot against
  `GET /api/nodes/metadata`.
- A test asserts that every optional peer of a core package is a regular
  dependency of `@nodetool-ai/server` or of a pack.
- The test fails when you remove `sharp` from `@nodetool-ai/server`.

## 5. Phase 1: dependency cut and measurement (pack spec step 1)

### A7. The size walker (report only)

**Blocked by:** none. Do this first, so that each cut reports its effect.

**Files:** new `scripts/measure-cli-closure.mjs`, new
`scripts/node-catalog/tiers.mjs`, `scripts/smoke-packed-cli.mjs`.

1. Write the tier list in `scripts/node-catalog/tiers.mjs`. It contains the
   core packages, the node packs and the runtime libraries. The walker, the
   lint rule (A2), the generator (A1) and the smoke guard (A8) all read this
   file.
2. Implement the walker as pack spec 8.1 describes. Print apparent size, disk
   usage, the package count and the 20 largest packages. `--json` writes the
   same data.
3. In the packed smoke, run `du -sk node_modules` after the install and print
   the result. Do not fail on it yet.

**Acceptance:**

- The walker prints the current S0 and S3 scenarios. Record both numbers in
  pack spec 8.2, because the commits since the review changed the tree.
- A test in `scripts/__tests__/` runs the walker on a fixture tree with known
  sizes and asserts the totals.
- The test also asserts that the walker visited more than zero packages.

### A1. The node catalog

**Blocked by:** A11, A7 (for the tier list).

**Files:** new `packages/node-catalog/`, new
`scripts/generate-node-catalog.mjs`, new `scripts/node-catalog/packs.mjs`,
`packages/config/src/package-asset-registry.ts`, `scripts/test-affected.mjs`,
`packages/cli/src/harness/registry.ts`.

1. **A1.1 Package.** Create `@nodetool-ai/node-catalog`. Its dependencies are
   `config`, `protocol` and `node-sdk`, and it has no pack dependency. Export
   these items:
   - the 9.1 types, with D17 and D18 applied
   - `readCatalogIndex()`
   - `readCatalogNodes()`, which loads the nodes lazily and gunzips them
   - `packForNodeType(nodeType)`, which uses `owners`

   Register both data files in `PACKAGE_RUNTIME_ASSETS`, so the desktop bundle
   finds them. Add a binary loader to the asset registry if it has none.
2. **A1.2 Pack definitions.** `scripts/node-catalog/packs.mjs` lists each
   pack with these fields:
   - id
   - npm name
   - registrar export
   - `provides`
   - `runtimeLibraries`
   - `secrets`

   The generator derives `requires` from the pack manifests (D10). It derives
   `nodeCount` from the registrars.
3. **A1.3 Generator.** Follow pack spec 9.2. Write `data/index.json` and
   `data/nodes.ndjson` (D21). Write placeholder pins: `integrity: ""` and
   `installBytes: 0`. A13.3 replaces them at release.
4. **A1.4 Drift gate.** Add `npm run check:node-catalog`. It regenerates the
   catalog and fails on a difference. It also asserts that it compared more
   than zero node types. Add the check in three places:
   - to `PATH_CHECKS` for `scripts/node-catalog/**` and
     `scripts/generate-node-catalog.mjs`
   - to the harness registry for changes in node packages
   - to the CI quality checks

**Acceptance:**

- Generator unit tests cover these cases (pack spec 19.1):
  - ownership per registrar
  - the duplicate ownership error
  - deterministic output for two runs
  - the error for a property value that JSON cannot represent
  - the non-zero comparison
- The generator writes equal output on linux-x64 and darwin-arm64 (R6).
- `check:node-catalog` fails after a deliberate edit of `index.json`.
- `owners` maps `nodetool.control.If` to the core pack and
  `nodetool.game.SpriteSheet` to `image`. It maps every `kie.*` type to the
  pack that registers it.

### A2. The loader helpers

**Blocked by:** A1.

**Files:** `packages/node-catalog/src/`, `packages/protocol/src/`
(the runtime ids), `electron/src/runtime/packages/definitions.ts`,
`tools/oxlint/` or the lint config, `packages/cli/src/commands/runtime.ts`
(help text).

1. **A2.1** `importPack(packId, specifier?)` and `MissingPackError` follow
   pack spec 6.3 and 12.2, with D18 applied. Until A14 lands, the `install`
   field reports `supported: false` with the reason "Pack installs are not
   available in this version."
2. **A2.2** `registerAvailablePacks(registry, policy)` follows D22. The
   policy semantics stay the same as in `builtinPackUnavailableReason`.
3. **A2.3** A lint rule blocks static and dynamic imports of node pack names
   from core packages. It reads the tier list. The only allowed path is
   `importPack`.
4. **A2.4** Add the runtime ids `imaging`, `typescript`, `tracing` and
   `esbuild` to `RUNTIME_NPM_PACKAGES`, `RuntimePackageId` and the desktop
   definitions (D17). Add the bundling hosts to each entry.

**Acceptance:**

- With all packs in the tree, `registerAvailablePacks` produces the same types
  and metadata as `registerBuiltinPacks` with the same policy. Test it for the
  CLI policy and the local policy.
- A test hides one pack through the module resolver. Its types become
  `catalog`, and the other packs still register.
- The lint rule fails on a deliberate `import "@nodetool-ai/fal-nodes"` in
  `websocket`.
- A test checks each runtime registry version against the optional peer range
  of the core package that loads it.

### A3. Catalog-only nodes

**Blocked by:** A1.

**Files:** `packages/node-sdk/src/registry.ts`,
`packages/node-sdk/src/graph-validation.ts`,
`packages/execution/src/service/workflow-run.ts`,
`packages/execution/src/executor-resolver.ts`,
`packages/websocket/src/plugins/websocket.ts`,
`packages/websocket/src/http-api.ts`, `packages/dsl/src/registry.ts`.

1. Add the `"catalog"` source, `loadCatalog`, `availability` and
   `catalogEntry` (pack spec 10.1).
2. Change each call site in the 10.2 table.

**Acceptance:**

- `availability()` returns the correct value for each origin.
- The parity test from 10.2 runs fixture property bags through the class
  validator and the catalog validator for every catalog type. It requires
  equal issue lists.
- A graph with an unloaded TypeScript node and invalid properties reports the
  property issues. `ensurePythonBridge` is never called.
- `validate` reports `pack_not_installed` for a catalog type.

### A4 and A5. The manifest cuts and the source cuts

A4 is the manifest cuts (pack spec 6.1), and A5 is the source cuts (6.2).
Each batch below lands one package's A4 row with its A5 rows in one change. A
manifest cut without its source cut breaks a clean install. A source cut
without its manifest cut saves nothing.

| Batch | Package | A4 manifest cut | A5 source cut | Blocked by |
|---|---|---|---|---|
| A4.1 | `runtime` | `sharp` and the three OpenTelemetry packages become optional peers | Make `run-trace-processor.ts` load OpenTelemetry only when a trace sink is set. The `image-codec.ts` error uses `imaging`. Add `loadCanvas()` in `runtime/src/canvas-codec.ts`, which throws the `imaging` error. | A2 |
| A4.2 | `timeline`, `data-nodes` | `@napi-rs/canvas` in `data-nodes` becomes an optional peer. `timeline` has this already. | `fonts/google-fonts-node.ts` and `fonts/register-node.ts` use `loadCanvas()`. | A4.1 |
| A4.3 | `agents`, `game-nodes` | Remove `blender-nodes`, `game-nodes`, `game-renderer` and `video-nodes` from `agents`. `@napi-rs/canvas` becomes an optional peer. `game-nodes` gains `game-renderer` (D18). | The 10 static canvas imports use `loadCanvas()`. `model3d.ts` and `timeline-bake.ts` use `importPack("blender")`. `timeline-preview/model3d.ts` uses `importPack("video", …)`. The game capabilities use `importPack("game", …)`. | A2, A4.1 |
| A4.4 | `model-pricing` | Remove `fal-nodes` and `kie-nodes`. | Move both unit-price catalogs and `kie-pricing-api.ts` into `model-pricing`. Change the output paths in `fal-codegen` and `kie-codegen`. Do not edit prices by hand. Refresh the fixtures with `scripts/provider-codegen-check.mjs --write`. | none |
| A4.5 | `dsl` | Remove the 7 packs. | `export.ts` and `script-export.ts` read metadata from the catalog. `registry.ts` uses `registerAvailablePacks` and never treats catalog types as Python candidates. | A2, A3 |
| A4.6 | `websocket` | Remove the 18 packs. `sharp` becomes an optional peer. | The main block uses `registerAvailablePacks`, which removes the A12 temporary path. Providers register through the pack source. The other source cuts are below this table. | A2, A3, A4.4, A12 |
| A4.7 | `cli` | Remove `reliability-harness`. Keep the 14 node packs and `game-renderer` until A24. | `node-registry.ts` and `nodetool-main.ts:46` use `registerAvailablePacks`. `providers.ts` registers providers through the pack. `timeline-render.ts` uses `importPack("video", …)`. `game.ts` and `game-smoke.ts` use `importPack("game", …)`. | A2, A6 |
| A4.8 | `game-runtime`, `sandbox-compiler` | `@dimforge/rapier3d-compat` and `esbuild` become optional peers. | Make the `esbuild` import in `sandbox-compiler/src/bundle.ts` lazy. A missing `esbuild` throws the `esbuild` runtime error. | A2 |

These are the other A4.6 source cuts:

- `routes/nodes.ts` uses `importPack("integration")`.
- `routes/documents.ts` uses `importPack("document", "lib/pdf-text")`.
- The file-watch predicates move from `automation-nodes` into `websocket`.
- `routes/kie-pricing.ts` reads from `model-pricing`.
- `trpc/routers/models.ts` returns empty model lists with a pack hint.
- `trpc/routers/games.ts`, `routes/games.ts` and `model-download-runtime.ts`
  use `importPack`.
- The 4 static `sharp` imports go through `image-codec.ts`.

**Acceptance for each batch:**

- No `src/` file of the package imports a node pack outside `importPack`. The
  A2.3 lint rule checks this.
- The walker reports the saving of the batch. Record the number in the
  commit message.
- `npm run backend:smoke` passes.
- Each changed lazy path has a test with the dependency hidden. The test
  asserts the error kind and the runtime id or pack id.

### A6. `tsx` in the CLI (D11)

**Blocked by:** none.

1. Add `tsx` to the `cli` dependencies.
2. Replace the `npx tsx` calls at `nodetool-main.ts:588` and
   `debug/target.ts:49` with an in-process `tsImport`.

**Acceptance:**

- No `npx` call remains in `packages/cli/src`. A source scan test asserts
  this.
- `nodetool run examples/workflows/concat_text.ts` works in the packed smoke.

### A8. The core-only smoke

**Blocked by:** A2, A3, A4.1 to A4.8, A6.

**Files:** `scripts/smoke-packed-cli.mjs`, a new test fixture for the
mediabunny decode.

1. Add `--core-only`. It writes the CLI tarball again with the node packs and
   `game-renderer` removed from its manifest. Then it installs that tarball
   with no pack tarballs available.
2. Run checks 1 to 7 and check 9 of pack spec 6.4. For check 8, this step
   asserts only that the run fails and names `fal`. A16 adds the
   `missing_pack` JSON and exit code 3.
3. Add the pack spec 7 decode test. Call `ensureCodecs()`, then decode the
   first audio frame and the first video frame of a checked-in fixture. Assert
   the sample counts and the frame dimensions.
4. Run the core-only smoke in CI beside the existing packed smoke.

**Acceptance:**

- The core-only smoke passes in CI.
- The check 9 guard fails when a pack tarball is deliberately added.

### A9. The size gate

**Blocked by:** A8.

1. Measure `du -sk node_modules` of the core-only install on linux-x64 in CI
   (D13).
2. Set the hard gate to that value plus 5 %. Write the value in pack spec 8.4.
3. Make the core-only smoke fail above the gate.

**Acceptance:** the gate fails on a deliberately oversized fixture before you
enforce it.

### Step 1 exit criteria

- The core-only smoke passes, with the size gate enforced.
- `npm run backend:smoke` passes.
- The published CLI is unchanged. The full packed smoke still passes.

## 6. Phase 2: publishing (pack spec step 2)

### A13. Publishing hardening

**Blocked by:** none for A13.1 and A13.2. A1 for A13.3. This phase can run
beside phase 1.

**Files:** `.github/workflows/publish.yaml`, `scripts/publish-packages.mjs`,
`scripts/prepare-publish.mjs`, new `scripts/release/pin-catalog.mjs`, new
`scripts/release/verify-staging.mjs`.

1. **A13.1 OIDC.** Remove `NODE_AUTH_TOKEN` (`publish.yaml:96`). An npm
   organization owner must configure trusted publishing for each package on
   npmjs.com and then delete the `NPM_TOKEN` secret. The plan cannot do this
   step.
2. **A13.2 Tarballs.** Pack each package once and publish the tarball files.
3. **A13.3 Pins.** Use this order:
   1. Pack the node packs.
   2. Compute the integrity and `installBytes` of each pack on linux-x64.
   3. Write the pins into `node-catalog/data/index.json`.
   4. Pack the core packages.
4. **A13.4 Staging.** Publish everything under the dist-tag `staging`. Then
   verify these points:
   - For each pin, `npm view <name>@<version> dist.integrity` equals the pin.
   - A provenance attestation exists with the source `nodetool-ai/nodetool`
     and the workflow `publish.yaml`.

   Before A22, the release job installs one pack beside the CLI with plain npm.
   After A22, it uses `packs add`.
5. **A13.5 Promotion.** Run `npm dist-tag add <name>@<version> latest` for
   each package, with the CLI last. A verification failure leaves `latest`
   unchanged.

**Acceptance:**

- Unit tests cover the pin writer, and the verifier with a fake `npm view`.
- A dry run of the workflow on a fork or a test scope reaches the promotion
  step.
- The verifier fails on a deliberately wrong pin.

## 7. Phase 3: pack loader (pack spec step 3)

### A14. Install mode and installed set

**Blocked by:** A2.

**Files:** new `packages/cli/src/packs/mode.ts`, new
`packages/cli/src/packs/installed.ts`.

1. Detect the install mode from the realpath of the CLI entry (pack spec
   11.1). Do not run `npm root -g` at startup.
2. Read the installed set (pack spec 11.3, D14). Ignore a pack whose version
   differs from the core version, and record it for `packs doctor`.
3. Fill the `install` field of `MissingPackError` for each mode. Use the npx
   text from 11.1.

**Acceptance:** the unit tests of pack spec 19.1 for mode detection pass on
Unix and Windows paths. They cover `_npx`, `.pnpm`, Yarn PnP, the Bun global
directory and a workspace checkout.

### A15. The installed pack source

**Blocked by:** A14, A4.6, A4.7.

1. Replace the "resolves" test in `registerAvailablePacks` with the installed
   set.
2. Use the same source in the CLI commands and in the `websocket` main block
   (`nodetool serve`).

**Acceptance:**

- A pack with a wrong version stays catalog-only.
- `packs doctor` reports the wrong version. Before A22, a unit test of the
  report function covers this.

### A16. `missing_pack` on every surface

**Blocked by:** A15, and runtime install step 2 for the shared exit code.

**Files:** `packages/execution/src/service/workflow-run.ts`, the CLI run
commands, `packages/websocket` run handlers, the MCP tool result path.

1. Add `findMissingPacks` (pack spec 12.1). It covers subgraphs,
   sub-workflows, `requires` and runtime libraries. Call it in the execution
   preflight.
2. Map the error to each surface in the 12.2 table:
   - exit code 3 in the CLI, and the JSON with `--json`
   - status 424 in HTTP
   - a job error in WebSocket
   - `isError` with `restartRequired: true` in MCP
3. Make `validate` exit with 3 when every error is `pack_not_installed`.
4. Add check 8 of pack spec 6.4 to the core-only smoke.

Runtime install step 2 also adds exit code 3, for missing runtime packages.
Build one mapping from error to exit code in the CLI. Make both errors use it.

**Acceptance:**

- One test for each surface asserts the payload and the status or the exit
  code.
- A graph whose sub-workflow uses `fal` reports `fal`.

### A17. Node types that a run picks at run time

**Blocked by:** A16.

1. CodeAct `invoke_node`, `open_node_stream`, `run_node` and agent control
   tools return `missing_pack` as a structured tool error.
2. The run continues, and nothing retries (pack spec 12.1).

**Acceptance:** a test for each tool calls a catalog-only type and asserts
the structured error. The run continues after the error.

## 8. Phase 4: installer (pack spec step 4)

The installer stays behind `NODETOOL_PACKS_INSTALLER=1` until A24.

### A18. The install lock

**Blocked by:** none. If runtime install step 1 has landed, reuse its lock.

1. Build one lock implementation for both installers (pack spec 14.3). It has
   exclusive create, a token, a heartbeat every 5 seconds, a 10-minute wait
   and stale removal after 60 seconds.
2. `packs` uses `<root>/node_modules/.nodetool-packs.lock`. The runtime
   installer uses its own path inside its target.

**Acceptance:** the unit tests of pack spec 19.1 for the lock pass. A test
with two processes shows that the second process waits.

### A19. The npm runner

**Blocked by:** none.

**Files:** `packages/cli/src/optional-node-root.ts`, new
`packages/cli/src/npm-runner.ts`.

1. Extract the npm call from `optional-node-root.ts` into `npm-runner.ts`.
   Then `runtime add` and `packs add` use one runner.
2. Locate `npm-cli.js` beside `process.execPath` (pack spec 14.1). Check
   npm 10.9.0 or later (D15). Refuse `--preserve-symlinks`.
3. Build the forced flags of pack spec 14.2 for each mode.

**Acceptance:** unit tests cover the flag builder and the version check. The
`runtime` tests still pass.

### A20. The install flow

**Blocked by:** A14, A18, A19, A13.3 (for real pins), Q9 (for local mode).

**Files:** new `packages/cli/src/packs/installer.ts`.

1. Implement pack spec 14.4 steps 1 to 11:
   - the snapshot
   - the dry-run plan, which parses npm 10 text and npm 11 JSON (14.5)
   - the install with `--ignore-scripts`
   - the verification (14.6)
   - the allowlisted rebuilds
   - the load check in a child process
   - the write of `cli-packs.json`
2. Implement rollback (pack spec 14.7) and the `dirty` flag.

**Acceptance:**

- The plan parser tests pin both npm output formats.
- The pack spec 19.2 integration tests pass against a local test registry
  fixture: additive plan refusal, rollback, and verification failure on a
  wrong integrity.

### A21. Install authorization

**Blocked by:** A20.

1. Only the CLI command layer constructs `InstallAuthorization` (pack spec
   13.2).
2. The installer rejects any id outside the authorization.
3. `serve` and `mcp serve` set a process flag. The installer refuses to run
   when the flag is set.
4. Add the interactive prompt (13.1). Its default answer is No.

**Acceptance:**

- The authorization matrix test of pack spec 19.1 covers each source, each
  refusing process, and ids outside the authorization.
- No module that `websocket`, `agents` or `runtime` imports exports the
  installer. A source scan test asserts this.

### A22. The commands and the re-exec

**Blocked by:** A21, Q6.

1. Add `list`, `add`, `add --restore`, `add --for`, `remove`, `doctor` and
   `why` (pack spec 15).
2. Add `--install-missing` to `run`, `debug`, `node run` and `workflows run`,
   and support `NODETOOL_AUTO_INSTALL=1`.
3. Re-execute after an install (pack spec 16), and print the restart notice.
4. Print the restore line after an upgrade (pack spec 13.3).

**Acceptance:** a TTY-simulated `run` with a missing pack does these steps:

1. It installs the pack.
2. It re-executes.
3. It ends with the exit code of the child.

A second re-executed process never installs.

### A23. The npm matrix

**Blocked by:** A22.

1. Add a CI workflow for the pack spec 19.3 matrix:
   - macOS arm64, Linux x64 (glibc) and Windows x64
   - Node 22 with npm 10.9 and Node 24 with npm 11
2. Run it on changes under `packages/cli/src/packs/`, on changes to
   `npm-runner.ts`, and every night.

**Acceptance:** every case in the 19.3 table passes on each platform.

## 9. Phase 5: switch (pack spec step 5)

### A24. Remove the node packs from the CLI

**Blocked by:** every assignment above.

1. Remove the 14 node packs and `game-renderer` from
   `packages/cli/package.json`.
2. Make the full packed smoke install packs through `packs add`.
3. Remove `NODETOOL_PACKS_INSTALLER`.
4. Update `docs/cli.md` and `docs/node-packs.md`. Add a release note about
   `packs add --restore`.

**Acceptance:**

- A clean `npm install -g @nodetool-ai/cli@<staging>` is within the size
  gate.
- `packs add fal` followed by a `fal` workflow run succeeds.

## 10. Dependency order

| Assignment | Blocked by |
|---|---|
| A10, A11, A7, A6, A4.4, A13.1, A13.2, A18, A19 | none |
| A12 | A11 |
| A1 | A11, A7 |
| A2, A3 | A1 |
| A4.1, A4.8 | A2 |
| A4.2, A4.3 | A4.1 (A4.3 also A2) |
| A4.5 | A2, A3 |
| A4.6 | A2, A3, A4.4, A12 |
| A4.7 | A2, A6 |
| A8 | A2, A3, A4.1 to A4.8, A6 |
| A9 | A8 |
| A13.3 to A13.5 | A1 |
| A14 | A2 |
| A15 | A14, A4.6, A4.7 |
| A16 | A15, runtime install step 2 |
| A17 | A16 |
| A20 | A14, A18, A19, A13.3, Q9 |
| A21 | A20 |
| A22 | A21, Q6 |
| A23 | A22 |
| A24 | all |

The critical path is A11, A1, A2, the A4 and A5 batches, A8 and A9. Run A13
and the runtime install steps 1 and 2 beside it.

## 11. Open questions

- **Q6.** `nodetool packs` already names the sandbox pack tooling. Use the
  same command for node packs, or rename the new verbs to `nodetool nodes
  add` and similar? This blocks A22. The recommendation is `nodetool nodes`,
  because one verb with two meanings misleads agents.
- **Q8.** This plan keeps `llm-nodes` and `code-nodes` in core, as the
  pack spec tier table does.
- **Q9.** Can local mode write the user's `package.json` and lockfile? This
  blocks the local mode part of A20. Global mode does not depend on it.

## 12. Risks of this plan

- **R6.** A registrar can register different types on different platforms.
  The catalog then differs between a darwin checkout and the linux release
  job. A1 compares the generator output on both platforms.
- **R7.** D19 changes the order of registration. A metadata override can then
  apply to a different class. The A11 snapshot test catches this.
- **R8.** From A2 to A15, two registration paths exist: bundled and catalog.
  The A2 equality test keeps them equal while both exist.
- **R9.** A dependency from `node-catalog` on a pack would make
  `test:affected` select the CLI and server suites for every node change. The
  generator therefore lives in `scripts/`, and `node-catalog` depends on no
  pack.
