# The authoring SDK

The installed editor type-checks TypeScript behaviours and describes a project's screens. It has
no checkout, no system Node, no pnpm and no network, so everything both need ships with it, in one
directory: the **authoring SDK**.

This is the contract between three things that are built and installed separately — the packaging
script, the workspace service that uses an SDK, and the installer that puts one on a machine. The
MSI (item 2 of the production-readiness plan) replaces the PowerShell installer, not this layout.

A shipped **game** needs none of this. The SDK is for authoring.

## What is in one

```text
<install>/sdk/
  sdk.json                                     the manifest: id, platform, versions, every file
  node/node          | node/node.exe           the pinned private runtime
  esbuild/esbuild    | esbuild/esbuild.exe     the transpiler, the same one a checkout builds with
  checker/pnpm-workspace.yaml                  the root marker the harness's paths.ts walks up to
  checker/packages/harness/src/typecheck.mjs   the checker (imports the compiler from beside itself)
  checker/packages/harness/src/typescript.cjs  the TypeScript compiler, once: the checker and the install's bundled engine CLI (`cli/`, built with `bundle.mjs --sdk-beside`) both import this file
  checker/packages/harness/src/lib.*.d.ts      TypeScript's standard-library declarations (the ES2020 closure only: no DOM, no web worker)
  checker/packages/api/src/index.ts            the @argus/api declaration closure
  checker/packages/api/src/*.ts                ...and every file that closure reaches
  packages/ui/package.json                     @argus/ui: the exports map the aliases come from
  packages/ui/src/**.ts, **.tsx, **.css        ...its sources, bundled into every screen
  packages/ui/theme/*.css, engine-tokens.json  ...and the theme layer (docs/UI.md §10)
  packages/gameplay/dist/entries.json          @argus/gameplay's build: the built-in behaviours
  packages/gameplay/dist/modules/*.js          ...a scene names by module,
  packages/gameplay/dist/lib/gameplay.js       ...the library a behaviour's `import … from
                                               "@argus/gameplay"` is bundled against,
  packages/gameplay/dist/lib/types/**.d.ts     ...and its declarations for the typecheck. Packing
                                               refuses without them: build the package first
  fixtures/render/fonts/*                      the engine's default font and fallback chain, with
                                               their licences: what an export packs for a project
                                               that ships no fonts (docs/EXPORT.md, "A font")
  fixtures/recipes/make-your-own-sounds/tools/synth.mjs
                                               the one script a recipe tells an agent to run (the
                                               sound synthesiser): recipes are `argus://` resources
                                               a sub-agent cannot read, so this one is a file
  resources/**                                 every `argus://` resource as a plain file (below)
  NOTICES.md                                   third-party notices
```

### Resources as files

A sub-agent has no tool for reading an MCP resource, so everything the server serves as `argus://`
is also carried here, built from the same sources (`scripts/sdk/resources.mjs`, which
`scripts/sdk/pack.mjs` calls). The path is one rule: `argus://X` is `<sdk>/resources/X`, with the
extension its kind has.

```text
argus://docs/<slug>             resources/docs/<slug>.md           the whole document (a #heading is a heading in it)
argus://recipes/<name>          resources/recipes/<name>/          README.md first, then every file, whole
argus://recipes/<name>/<path>   resources/recipes/<name>/<path>
argus://contract/scene|project  resources/contract/scene|project.json
argus://ui/schema[/<section>]   resources/ui/schema.json          the whole table
argus://ui/components[/<name>]  resources/ui/components.json
argus://examples/<id>           resources/examples/<id>.ts
argus://rules|components|api    resources/rules|components|api.json   the pack's whole index (a piece is a key in it)
argus://project/...             none: live state of one project; use the project tools
```

`<sdk>` is where an agent already finds the guides: `docsDir` in the `notes.read` result is
`<sdk>/docs`, `resourcesDir` is `<sdk>/resources`, and a new project's `AGENTS.md` names the
absolute `<sdk>` of the machine that created it. `scripts/agent-pack/tests/resource-files.test.mjs`
holds every `argus://` address the guides, the server and the templates mention to a carried file.
The engine ships no starter fonts or other starter assets: a project's own `ui/fonts/` holds the
faces it brings (docs/UI.md, "Fonts and fallback").

The private runtime is not a Node installation: nothing is added to `PATH`, no other program can
find it, and removing the SDK removes it.

`checker/` is laid out exactly as a checkout is, because the checker finds the declarations the way
it does at home: it walks up from its own file to `pnpm-workspace.yaml` and reads
`packages/api/src/index.ts` from there.

### Where the gameplay build is found

The engine (`crates/app/src/modules.rs`), the editor's in-process world (the same code) and the
workspace service (`crates/workspace-service/src/build/tools.rs`) look for `packages/gameplay/dist`
in one order, taking the first that has an `entries.json`:

1. `ARGUS_GAMEPLAY`, taken as given (and, for the service, a named toolchain root or the SDK it
   checks with);
2. the nearest `packages/gameplay/dist` at or above the scene or project: a checkout;
3. `$ARGUS_SDK/packages/gameplay/dist`;
4. `<exe dir>/sdk/packages/gameplay/dist`, then `<exe dir>/../sdk/packages/gameplay/dist`.

A project far from any checkout, with the editor and engine run from a build folder rather than
beside an SDK, therefore needs only `ARGUS_SDK`. An editor started for its control endpoint
(`--control-port`, as `editor.start` starts one) whose project will not open exits at once with
the reason, rather than leaving its caller waiting on an endpoint that only an open project has.

### `@argus/ui`, and why it sits beside `checker/` rather than in it

A project's `ui/screens/**.tsx` import `@argus/ui`, and describing one is esbuild plus one
evaluation (`docs/EDITOR.md`, "Describing a screen"): the screen is bundled against the package and
the bundle is evaluated. That is a different tool from the checker — the engine's own esbuild, not
the TypeScript compiler — so the package travels beside `checker/`, not inside the workspace the
checker walks.

It is at `packages/ui`, the path it has in a checkout, because that is where
`argus_host::screen::find_ui_package` already looked: under `<exe dir>` and `<exe dir>/sdk`,
walking up, so both install layouts — `bin/` and `sdk/` as siblings, or an `sdk/` folder beside the
binaries — find it with no path either side had to invent. Keeping the checkout's path is also what
keeps the package-relative style paths in `src/components/registry.ts` true in an install.

What ships is **source**, not a build: the package's `exports` map names `./src/index.ts` and
`./src/components/index.ts`, and esbuild compiles the TypeScript on the way into the bundle. The
`exports` map is the contract — `ui_package_aliases` turns each entry into one `--alias` flag, so
`@argus/ui/components` resolves to the file the package says rather than to a guess at its layout —
and the packer refuses to write a pack whose `exports` names a file it does not carry.

`test/`, `fixtures/`, `strings/` and the `dist/` a local `tsc` leaves do not ship. Nothing a screen
can reach is in them, and a pack that swept up whatever was in the working tree would not have the
same id twice.

Before this, an installed editor had to be told where the package was with `ARGUS_UI_PACKAGE`, and
without it every screen became `ui.screen.unknown` at run time. It now needs no such variable; the
variable remains for a layout discovery cannot see.

### The declaration closure, and why it is a closure

`packages/api/src/index.ts` is not one file. It re-exports the generated component declarations
beside it, and it may grow more neighbours. An SDK that carried only `index.ts` would produce, for
every behaviour a person wrote:

```text
build.typecheck | scenes/jump.ts:0:0 | (api).ts:1:49: TS2307: Cannot find module
  './components.generated.js' or its corresponding type declarations.
```

— a tooling failure, reported against the author's file, while their real mistakes went undiagnosed
because the component types had collapsed to an error type. So the packaging follows the imports
from `index.ts` and carries the whole closure, and the service's own checkout-mode discovery walks
it by the same rules (`crates/workspace-service/src/build/tools.rs`, `api_closure`):

- relative specifiers only — a package specifier is not part of the API's own closure;
- `./x.js` resolves to `./x.ts`, which is what `moduleResolution: "bundler"` does with the
  specifier TypeScript emits;
- nothing outside the checkout, and at most 1024 files.

A declaration that resolves to no file stops the packaging, and on a checkout it fails the build
before any check runs, naming the file.

The checker itself is walked the same way. A checkout's fingerprint used to hash `assemble.ts` and
`paths.ts` by name, which held only while those were the two files the checker imported; one added
import would have left every cached answer valid against a checker that had changed. It now hashes
whatever the checker reaches — five small files today — so the fingerprint keeps up by itself. A
packaged SDK has no such question: the bundle is one file and its bytes are in the id.

## Identity

`sdkId` is the SHA-256 of the SDK's own canonical file listing — every path with its digest and its
length, sorted. Nothing else: no build time, no machine name, no version string of its own.

The listing carries every file's path, content hash and length, so the id is a digest over the
**bytes of the whole SDK** — but only because the listing is required to be complete. On its own,
a digest of a listing says nothing about a file the listing leaves out, and an emptied listing
given a consistent id would have verified against nothing at all: the checker itself could have
been replaced and every build would still have reported `typecheck: "checked"`. So the service
refuses an SDK holding a file its manifest does not name, and refuses a listing that names nothing
for a role the editor cannot work without — the checker bundle, `checker/pnpm-workspace.yaml`,
`node`, `esbuild`, at least one `checker/packages/api/**.ts` declaration, `packages/ui/package.json`
and at least one `packages/ui/src/**.ts` source.

No unkeyed digest inside a pack can defend that pack against whoever can rewrite it; this needs
write access to the SDK directory or control of `ARGUS_SDK`. What the two rules buy is that such a
rewrite has to replace the whole SDK rather than one file, and the id — which every build records
as `toolchain.apiSha256` — changes when it does.

That has three consequences worth stating:

- **Two packing runs over the same inputs give the same id.** `scripts/sdk/pack.mjs` writes no
  timestamps and no absolute paths, and it runs esbuild in the checkout root with relative paths so
  the bundled checker's own bytes do not depend on where it was built.
- **One changed declaration is a different SDK.** The id is the typecheck fingerprint, so a
  regenerated component field invalidates the checker image and every cached answer. Source that
  was accepted against the old declarations is checked again against the new ones.
- **A manifest cannot borrow another SDK's identity.** The service recomputes the id from the
  listing and refuses a manifest whose `sdkId` is not the digest of its own files.
- **A manifest cannot attest to less than the SDK.** `sdk.json` is the one file not in its own
  listing; every other file in the tree must be named by it, and the roles above must each be
  named, or the SDK is refused before anything is hashed.

## How the service finds and trusts one

In order (`crates/workspace-service/src/build/sdk.rs`):

1. `ARGUS_SDK`, or `--sdk` on `argus-workspace-service serve`. An SDK named by hand is the one that
   was asked for: if it does not verify, that is the answer, not a reason to look elsewhere.
2. `ARGUS_TOOLCHAIN_ROOT` or `--toolchain-root`, the developer override, which selects
   checkout-based tools instead. Same manifest and fingerprint rules apply to what it finds.
3. `<exe dir>/sdk`, then `<exe dir>/../sdk` — the installed layout, `bin/` and `sdk/` as siblings.
4. A checkout above the project.

`@argus/ui` is looked for under the same two places (`ARGUS_UI_PACKAGE` first, then the project's
own `tsconfig.json` paths, then `node_modules/@argus/ui` or `packages/ui` walking up from the
project, this executable and the working directory, with `sdk/` searched under the last two). One
SDK therefore answers for both: the checker the service runs, and the package the describer
bundles with.

A project's own files are never searched, and `PATH` is never searched.

Before it is used, the tree is walked to check that the manifest names all of it, the listing is
checked for each role, and then every file the manifest names is hashed and compared. The walk is
outside the stamp fast path below, because a file ADDED to the SDK changes no listed file's size
or modification time and the stamp would not see it. A full pass over ~143 MB
costs about a tenth of a second, and the verdict is stamped in the user's cache directory
(`%LOCALAPPDATA%\Argus\cache`, or `$XDG_CACHE_HOME/argus`) against each file's size and modification
time, so later opens skip the hashing. **Nothing is ever written inside the SDK**: it may be
read-only, and on a shared machine it should be.

Five things are refused, each with its own repair, and never with "turn checking off":

| What | The message names | The repair |
|---|---|---|
| Nothing at the path | the path | install the SDK, or set `ARGUS_SDK` |
| A manifest that is not one, or an id that is not the digest of its listing | what is wrong with it | re-run the installer |
| A file in the tree the listing does not name, or a listing naming nothing for a role | up to eight unlisted paths, or the roles it names nothing for | re-run the installer |
| A file that is absent, truncated or altered, the runtime included | up to eight paths that disagree | re-run the installer |
| An SDK for another Talos release, or another `<os>-<arch>` | both strings | install the SDK from this release, packed for this machine |

A build that cannot check does not reach Ready. It fails with `build.toolchain-unavailable`, and no
line of the author's code is blamed for it. What was already Ready stays Ready: breaking the SDK
under a running editor fails the next build and takes nothing away from what is running.

A project with no TypeScript in it records `"typecheck": "not-needed"`. It is never reported as
having passed a check it did not need, and its JavaScript still gets the sandbox, import and
module-load checks.

## Packing one

```sh
node scripts/sdk/pack.mjs --out dist/sdk
```

It takes the checker from `packages/harness/src/assemble.ts`, the compiler and its declarations from
the checkout's `typescript`, the API closure from `packages/api/src/index.ts`, the authoring front
end from `packages/ui` (its `package.json`, `README.md`, `src/**` and `theme/**`), esbuild from the
version this workspace resolved, and — unless `--node` says otherwise — the Node runtime that is
running the script. Then it writes the manifest and runs the **offline self-test**, which is both
halves of what an installed editor does, with `PATH` emptied and the SDK's own binaries:

- the SDK's own node runs the SDK's own checker on one behaviour that must pass and one that must
  not. An SDK that answers "fine" to everything is worse than one that will not start;
- the SDK's own esbuild bundles a two-scenario `.tsx` screen against the packed `@argus/ui` —
  through the aliases its `exports` map produces, importing a component from
  `@argus/ui/components` — and the SDK's own node evaluates the bundle and reads the description
  back. A pack short one source file fails here, rather than on the machine it was installed on.

The packing fails if any answer is wrong.

| Option | What it is for |
|---|---|
| `--node`, `--esbuild` | the native binaries for the platform being packed; **required** when it is not this machine |
| `--platform` | the `<os>-<arch>` the manifest records; defaults to this machine's |
| `--self-test-only` | re-run the self-test against an SDK already packed |
| `--link` | hard-link the two large binaries instead of copying them — tests and local runs only |

### One name for a platform

An SDK is built for one platform at a time, because the runtime and esbuild in it are native
binaries. That platform is written, compared and printed as **`<os>-<arch>`** — `windows-x64`,
`linux-x64`, `macos-arm64` — by the packer, the manifest, the service and the installer alike.

The arch half uses Node's and esbuild's spelling (`x64`, `arm64`, `ia32`) rather than Rust's,
because the packer is a Node script reading esbuild's per-platform packages. The service maps its
own `x86_64` onto `x64`, so both sides of the comparison are produced by one rule.

This is not decoration. The two sides used to disagree: the packer wrote the platform as an
`{os, arch}` object and the service compared the operating system alone, so an SDK packed as
`windows-x64` was refused on Windows with *"it was built for windows-x64; this is windows"* and a
real install ended with no SDK at all. `--platform` now refuses anything outside the nine tokens,
naming them, before a byte is packed, and a manifest whose platform is not such a string is a
manifest error rather than something to wave through. That is why the schema is 2.

### The two binaries, and why they are never guessed

Packing for this machine takes the runtime the packer is already running on and the esbuild this
workspace resolved. Packing for any **other** machine must name both, and the packer refuses
rather than substitute:

```sh
node scripts/sdk/pack.mjs --out dist/sdk-win --platform windows-x64 \
  --node C:\path\to\node.exe --esbuild path\to\@esbuild\win32-x64\esbuild.exe
```

Leave out `--node` and it stops: *"packing for windows-x64 on linux-x64 needs `--node` with that
platform's node binary: this script's own runtime is a linux-x64 one and would be copied into the
pack."* That sentence exists because the opposite happened. `process.execPath` is always *a* node,
so nothing failed, and a Windows SDK was written holding a Linux `node` under the name `node`.

Naming a path is not enough either, because a file name proves nothing about a binary. Both files
are read before anything is packed and refused unless the header matches the target:

| Target | Header | Architecture read from |
|---|---|---|
| `windows-*` | `MZ` then `PE\0\0` | `IMAGE_FILE_HEADER.Machine` |
| `linux-*` | `\x7fELF` | `e_machine` |
| `macos-*` | Mach-O, or a universal binary | `cputype` |

So a Linux `node` handed to a Windows pack is refused with *"is not a PE (Windows) executable …
It looks like ELF (Linux)"*, and an arm64 binary in an x64 pack is refused by architecture. Nothing
is written when either check fails.

**Getting a Windows runtime on another machine.** Take it from an installed Node (`node.exe` from
`C:\Program Files\nodejs`), or download the pinned release and check it against the published
`SHASUMS256.txt` before pointing `--node` at it:

```sh
curl -fSLO https://nodejs.org/dist/v22.17.1/win-x64/node.exe
curl -fSL  https://nodejs.org/dist/v22.17.1/SHASUMS256.txt | grep 'win-x64/node.exe' | sed 's|win-x64/||' | sha256sum -c -
```

The version that ends up in the pack is recorded in `sdk.json` either way, so what was shipped is
never in question.

Cross-packing runs the self-test nowhere: it prints `self-test NOT RUN` and says to run
`--self-test-only` on the target machine. It is never reported as passed.

Size, for the current release: 191 files, 142.9 MiB on `linux-x64` and about 104.2 MiB on
`windows-x64`, nearly all of it the Node runtime. `@argus/ui` is 85 of those files and 324 KiB of
them, the same on every platform.

## Checking a machine

```sh
argus-workspace-service sdk --check
argus-workspace-service sdk --sdk "C:\Users\me\AppData\Local\Argus\sdk" --check
```

It prints what this machine checks with — SDK or checkout, its identity, its versions, the
declaration files it carries — and with `--check` it runs a good and a bad behaviour through the
real checker and times them. It exits non-zero when nothing here can check, so an installer or a
support script can read the exit code while a person reads the JSON.

```json
{
  "mode": "sdk",
  "ok": true,
  "sdk": { "id": "43aeebb0…", "versions": { "argus": "0.1.0", "typescript": "5.9.3" } },
  "check": { "coldMs": 523, "warmMs": 534, "good": [], "bad": ["bad.ts:3:7: TS2322: …"] }
}
```

## Installing one

```powershell
scripts\windows\install-editor.ps1 -Exe target\release\argus-editor.exe -Sdk dist\sdk
```

The SDK is copied to `<Install>\sdk.incoming`, verified there with the service that was just
installed, and only then swapped into `<Install>\sdk`. An upgrade that is interrupted, refused or
unverifiable leaves the SDK that was already installed in place and usable; a swap that fails puts
the old one back rather than leaving an install with no SDK.

Without `-Sdk` and with no SDK already installed, the installer warns, and the editor will not
check TypeScript: a build containing TypeScript **fails**, naming the missing SDK. It does not run
unchecked.

## The check being off

The editor never turns the check off. It used to add `--no-typecheck` whenever it could not see a
checkout, which on an installed editor was always.

The developer bypass is `ARGUS_EDITOR_NO_TYPECHECK=1`, which the editor passes on as
`--no-typecheck` and announces in its log. A build made that way records `"typecheck": "skipped"`
and a limitation saying how many modules were not checked. It is visibly unchecked output and
cannot stand in for a checked build.

Every Ready build's `build.json` records what checked it:

```json
"toolchain": {
  "apiSha256": "43aeebb0…",
  "sdk": { "root": "…/sdk", "id": "43aeebb0…", "versions": { … },
           "apiFiles": ["checker/packages/api/src/index.ts"] }
}
```

so a diagnostic can be traced to the exact declarations it was made against.

