Imported from SumitKumar-17/sandkiln (
packages/cli/AGENTS.md). Install upstream withnpx skills add SumitKumar-17/sandkiln --skill cli. Copyright stays with the author.
AGENTS.md — kiln (CLI)
Read the root AGENTS.md first for project-wide conventions. This file
is scoped to this one package.
What this package is
kiln: a thin commander-based CLI wrapping packages/sdk (the JS/TS
SDK) — for manual testing, agentic workflows, and debugging without
writing code. It should contain essentially no logic of its own beyond
argument parsing and formatting output; every actual operation delegates
straight to the SDK.
Files
src/index.ts— the CLI.sandbox create|get-or-create|by-name|ls|rm| exec|read|write|preview|pty|exec-stream|logs|snapshot|resume|fork| chmod|chown|mkdir|rename|cp|symlink|readlink|truncate|ls-dirandimage create|ls|rm,drive create|ls|rm,pool create|ls|rmsubcommands, each a thin call intoSandbox/Sandbox.attach()/Image/Drive/Pool.pool create <id>has no "claim" counterpart at all — claiming a warm instance is entirely transparent, done by a plainsandbox createwhose--image/--vcpu/--memmatch a configured pool (seepackages/sdk/AGENTS.md'spool.tsentry).pty <id>opensSandbox.pty()'sWebSocketand pumps bytes between it and the local terminal: raw mode (process.stdin.setRawMode(true)) so every keystroke, including Ctrl+C, goes straight to the remote shell instead of Node intercepting it. A real live-tested gotcha here: the process used to hang for a full 10 seconds after the remote shell exited before this was traced to a guest-side bug (seesandkiln-guest-agent'spty.rsand its ownAGENTS.md) rather than anything in this file — the WebSocket'scloseevent genuinely never fired until the guest actually tore down its side of the connection, so no amount of client-side cleanup here could have fixed it. Worth remembering if a similar hang ever shows up again: check whether the server side is actually closing the connection before assuming it's a local handle leak.exec-stream <id> <command> [args...]starts a background command (Sandbox.execStream) then immediately attaches and follows it (followLogs, shared withlogs);logs <id> [session-id]lists sessions with no id, or attaches/follows an existing one with one — both print a replay-then-live-tail of the command's output and resolve the process's own exit code from the daemon's bracketed[process exited with code N]notice (parsed out of the plain-text noticesroutes_logs.rssends alongside real output, not a separate structured message), which becomes this CLI invocation's own exit code.resume/fork/get-or-create/by-namecall the SDK's staticSandbox.resume/Sandbox.fork/Sandbox.getOrCreate/Sandbox.byNamedirectly (none acts on an already-existing handle —resume/forktake a snapshot id,by-name/get-or-createa name, not a sandbox id, so there's no existing handle to attach to);imagesubcommands callImage's static methods the same way, since an image has no instance handle at all.sandbox create --image <id>boots from a registered image instead of the daemon's default rootfs.rmdefaults to the SDK'sstop()persist-by-default behavior and reports whether the sandbox was preserved (with its snapshot id) or destroyed;--destroyopts into full destruction (stop({ keep: false })).--base-url/--tokenare global options that fall through to the SDK's own env var resolution when unset — don't reimplement that resolution here, just passundefinedthrough. Every action handler catches its own errors and reports them on stderr with a non-zero exit (handleApiError/fail);program.parseAsync(...)at the bottom has a.catch()backstop so nothing escapes as a raw stack trace.previewis the one subcommand that makes no network call at all — it just printsSandbox.previewUrl()'s pure result, same reasoning as why that SDK method itself does no round-trip; port-range validation lives in the SDK (Sandbox.previewUrlthrowsRangeError), not duplicated here, matching this package's own "essentially no logic of its own" rule.src/format.ts— the pure logic pulled out ofindex.tsspecifically so it's unit-testable without importing the CLI's top-level commander wiring (which parsesprocess.argvas a side effect of module load):parseTag(the--tag key=valueparser — throws commander'sInvalidArgumentError, not a plainError, so a bad--tagvalue gets the same cleanerror: ...stderr message as everything else instead of an unhandled-exception stack trace),formatSandboxList(thesandbox lsoutput formatter, including the empty-list case), andformatImageList(theimage lsequivalent).
Testing
node:test + node:assert, no added dependency — matches the project's
convention of pulling pure logic out of framework plumbing so it's
testable (see root AGENTS.md's auth::token_matches precedent) rather
than skipping tests because the rest of the file needs a live daemon.
test/format.test.jscoverssrc/format.ts.- Tests import compiled output (
dist/format.js), not TS source directly —format.tsis built as its owntsupentry (no shebang banner) specifically so it can be imported standalone.npm testrunspretest(npm run build) first, so it's always testing current code. - Run:
npm run test -w sandkiln-cli(orcd packages/cli && npm test).
The bug that already happened here — read before touching command registration
program.command("sandbox") already registers and attaches the
command to program — don't also call program.addCommand(sandbox)
afterward. This exact mistake ("cannot add command 'sandbox' as
already have command 'sandbox'") crashed every single subcommand on the
first live run, because commander throws at module load time when a
duplicate registration happens, not just when the specific broken
subcommand is invoked. If you're restructuring how subcommands are
built, be aware Command#command() and Command#addCommand() are two
different ways to attach a command — use one, not both, for the same
command object.
Building and verifying
npm run typecheck -w sandkiln-cli
npm run build -w sandkiln-cli
npm run test -w sandkiln-cli
Requires sandkiln (the SDK) to already be built — it's a real
workspace dependency resolved through packages/sdk/dist/, not source.
If typecheck fails with "Cannot find module 'sandkiln'", build the SDK
first (npm run build -w sandkiln), don't assume this package is broken.
Typecheck/build alone don't prove a command works — every subcommand
here needs live verification against a real daemon (same port-forward
pattern as packages/sdk/AGENTS.md describes, but running
node packages/cli/dist/index.js sandbox <subcommand> directly instead
of a throwaway script). This is exactly how the duplicate-command bug
above was caught — it didn't show up in typecheck or build, only when
actually run.
Non-obvious things specific to this package
- Ships as an ESM bundle with a shebang banner (
tsup.config.ts'sbanner: { js: "#!/usr/bin/env node" }), not ESM+CJS like the SDK — a CLI binary doesn't need dual-format support the way a library does.tsup.config.tsbuilds two entries:index.ts(the shebanged executable) andformat.ts(no banner, built standalone purely so tests can import it) — thebinfield inpackage.jsononly ever points atdist/index.js. cp(a single unifiedsandbox:path-style copy command, as originally sketched inROADMAP.md) was deliberately simplified to explicitread/writesubcommands instead — less magic path-prefix parsing for a first version. If you're tempted to add a unifiedcp, that's a legitimate improvement, just don't assume the roadmap's original wording is the final word on the exact command shape.- Published on npm as
sandkiln-cli, notkiln. The short namekilnis already taken by a completely unrelated, pre-existing package (node-kiln, "Provides Kiln API functionality," owned by a third party since before this project existed — confirmed vianpm view kiln repository, points atboneskull/node-kiln, nothing to do with sandkiln; a publish attempt under that name gets a real403 Forbiddenfrom npm). The installed command is stillkiln— only the npm package name differs from the binary name, which npm'sbinfield supports directly (package.json'sbin.kilnpoints atdist/index.jsregardless of whatnamesays). Install withnpm install -g sandkiln-cli, then just runkiln .... - Published with
npm publish --provenancefrom CI (.github/workflows/publish-cli.yml), not manually — mirrorspublish-sdk.yml's trigger (workflow_dispatchor acli-v*.*.*tag) and itsNPM_TOKENrequirement. Buildssandkilnfrom source first (this package depends on it as a real workspace dependency, same ordering constraintci.ymlalready documents), so a CLI release can be tagged independently of, and doesn't require, a fresh SDK release.