@duyquangnvx/agent-cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -2
  3. package/agents-section.md +5 -0
  4. package/dist/agent-cli.d.ts +69 -11
  5. package/dist/agent-cli.js +69 -10
  6. package/dist/agent-cli.js.map +1 -1
  7. package/dist/built-ins/agents-md.d.ts +9 -9
  8. package/dist/built-ins/agents-md.js +8 -8
  9. package/dist/built-ins/agents-md.js.map +1 -1
  10. package/dist/built-ins/doctor-rows.js +5 -3
  11. package/dist/built-ins/doctor-rows.js.map +1 -1
  12. package/dist/built-ins/doctor.d.ts +1 -1
  13. package/dist/built-ins/doctor.js +16 -15
  14. package/dist/built-ins/doctor.js.map +1 -1
  15. package/dist/built-ins/install/dependency.js +1 -1
  16. package/dist/built-ins/install/dependency.js.map +1 -1
  17. package/dist/built-ins/install/global-npm.js +6 -4
  18. package/dist/built-ins/install/global-npm.js.map +1 -1
  19. package/dist/built-ins/npm.d.ts +3 -2
  20. package/dist/built-ins/npm.js +16 -9
  21. package/dist/built-ins/npm.js.map +1 -1
  22. package/dist/{project-root.js → built-ins/project-root.js} +1 -1
  23. package/dist/built-ins/project-root.js.map +1 -0
  24. package/dist/built-ins/skill/hub.d.ts +3 -2
  25. package/dist/built-ins/skill/hub.js +43 -16
  26. package/dist/built-ins/skill/hub.js.map +1 -1
  27. package/dist/built-ins/skill/install-command.d.ts +3 -4
  28. package/dist/built-ins/skill/install-command.js +20 -21
  29. package/dist/built-ins/skill/install-command.js.map +1 -1
  30. package/dist/built-ins/skill/packaged.js +5 -2
  31. package/dist/built-ins/skill/packaged.js.map +1 -1
  32. package/dist/built-ins/skill/skill.d.ts +5 -2
  33. package/dist/built-ins/skill/skill.js +1 -1
  34. package/dist/built-ins/skill/skill.js.map +1 -1
  35. package/dist/built-ins/update.d.ts +1 -1
  36. package/dist/built-ins/update.js +4 -9
  37. package/dist/built-ins/update.js.map +1 -1
  38. package/dist/check-examples.d.ts +6 -0
  39. package/dist/check-examples.js +53 -0
  40. package/dist/check-examples.js.map +1 -0
  41. package/dist/check-next-steps.d.ts +10 -0
  42. package/dist/check-next-steps.js +47 -0
  43. package/dist/check-next-steps.js.map +1 -0
  44. package/dist/command-errors.d.ts +8 -0
  45. package/dist/command-errors.js +25 -0
  46. package/dist/command-errors.js.map +1 -0
  47. package/dist/{flags.d.ts → command-line/flags.d.ts} +4 -4
  48. package/dist/{flags.js → command-line/flags.js} +7 -10
  49. package/dist/command-line/flags.js.map +1 -0
  50. package/dist/{input.d.ts → command-line/input.d.ts} +1 -1
  51. package/dist/{input.js → command-line/input.js} +1 -1
  52. package/dist/command-line/input.js.map +1 -0
  53. package/dist/{invocation.d.ts → command-line/invocation.d.ts} +6 -3
  54. package/dist/{invocation.js → command-line/invocation.js} +9 -5
  55. package/dist/command-line/invocation.js.map +1 -0
  56. package/dist/{parse-input.d.ts → command-line/parse-input.d.ts} +2 -2
  57. package/dist/{parse-input.js → command-line/parse-input.js} +3 -3
  58. package/dist/command-line/parse-input.js.map +1 -0
  59. package/dist/command-line/route-line.d.ts +15 -0
  60. package/dist/command-line/route-line.js +18 -0
  61. package/dist/command-line/route-line.js.map +1 -0
  62. package/dist/{route.d.ts → command-line/route.d.ts} +2 -2
  63. package/dist/{route.js → command-line/route.js} +1 -1
  64. package/dist/command-line/route.js.map +1 -0
  65. package/dist/command-line/shell-words.d.ts +12 -0
  66. package/dist/command-line/shell-words.js +83 -0
  67. package/dist/command-line/shell-words.js.map +1 -0
  68. package/dist/command-line/suggest.js.map +1 -0
  69. package/dist/command-line/tokenize.js.map +1 -0
  70. package/dist/{usage.d.ts → command-line/usage.d.ts} +1 -1
  71. package/dist/{usage.js → command-line/usage.js} +2 -2
  72. package/dist/command-line/usage.js.map +1 -0
  73. package/dist/command-spec.d.ts +13 -4
  74. package/dist/command-spec.js +22 -11
  75. package/dist/command-spec.js.map +1 -1
  76. package/dist/{agent.d.ts → context/agent.d.ts} +1 -1
  77. package/dist/context/agent.js.map +1 -0
  78. package/dist/{confirm.d.ts → context/confirm.d.ts} +3 -3
  79. package/dist/{confirm.js → context/confirm.js} +9 -11
  80. package/dist/context/confirm.js.map +1 -0
  81. package/dist/{cost.d.ts → context/cost.d.ts} +3 -3
  82. package/dist/context/cost.js +16 -0
  83. package/dist/context/cost.js.map +1 -0
  84. package/dist/context/declared.d.ts +8 -0
  85. package/dist/context/declared.js +10 -0
  86. package/dist/context/declared.js.map +1 -0
  87. package/dist/{errno.d.ts → context/errno.d.ts} +4 -3
  88. package/dist/{errno.js → context/errno.js} +14 -7
  89. package/dist/context/errno.js.map +1 -0
  90. package/dist/{files.js → context/files.js} +1 -1
  91. package/dist/context/files.js.map +1 -0
  92. package/dist/{interrupts.d.ts → context/interrupts.d.ts} +14 -10
  93. package/dist/{interrupts.js → context/interrupts.js} +30 -27
  94. package/dist/context/interrupts.js.map +1 -0
  95. package/dist/{operator.d.ts → context/operator.d.ts} +4 -3
  96. package/dist/{operator.js → context/operator.js} +4 -3
  97. package/dist/context/operator.js.map +1 -0
  98. package/dist/{outputs.d.ts → context/outputs.d.ts} +6 -5
  99. package/dist/{outputs.js → context/outputs.js} +10 -9
  100. package/dist/context/outputs.js.map +1 -0
  101. package/dist/{project.d.ts → context/project.d.ts} +4 -3
  102. package/dist/{project.js → context/project.js} +8 -7
  103. package/dist/context/project.js.map +1 -0
  104. package/dist/{secrets.d.ts → context/secrets.d.ts} +8 -1
  105. package/dist/context/secrets.js +85 -0
  106. package/dist/context/secrets.js.map +1 -0
  107. package/dist/{side-task.d.ts → context/side-task.d.ts} +2 -2
  108. package/dist/{side-task.js → context/side-task.js} +1 -1
  109. package/dist/context/side-task.js.map +1 -0
  110. package/dist/{spawn.d.ts → context/spawn.d.ts} +4 -3
  111. package/dist/{spawn.js → context/spawn.js} +7 -6
  112. package/dist/context/spawn.js.map +1 -0
  113. package/dist/{spill.d.ts → context/spill.d.ts} +4 -3
  114. package/dist/{spill.js → context/spill.js} +4 -4
  115. package/dist/context/spill.js.map +1 -0
  116. package/dist/{stdin.d.ts → context/stdin.d.ts} +2 -2
  117. package/dist/{stdin.js → context/stdin.js} +2 -2
  118. package/dist/context/stdin.js.map +1 -0
  119. package/dist/{tools.d.ts → context/tools.d.ts} +4 -3
  120. package/dist/{tools.js → context/tools.js} +8 -10
  121. package/dist/context/tools.js.map +1 -0
  122. package/dist/context.d.ts +51 -27
  123. package/dist/define-command.d.ts +47 -0
  124. package/dist/define-command.js +25 -0
  125. package/dist/define-command.js.map +1 -0
  126. package/dist/define.d.ts +72 -63
  127. package/dist/define.js +0 -14
  128. package/dist/define.js.map +1 -1
  129. package/dist/error-table.d.ts +9 -6
  130. package/dist/error-table.js +6 -6
  131. package/dist/error-table.js.map +1 -1
  132. package/dist/errors.d.ts +15 -1
  133. package/dist/errors.js +24 -6
  134. package/dist/errors.js.map +1 -1
  135. package/dist/execute.d.ts +8 -7
  136. package/dist/execute.js +51 -22
  137. package/dist/execute.js.map +1 -1
  138. package/dist/exit-classes.d.ts +1 -2
  139. package/dist/exit-classes.js +41 -48
  140. package/dist/exit-classes.js.map +1 -1
  141. package/dist/help-json.d.ts +4 -3
  142. package/dist/help-json.js +33 -11
  143. package/dist/help-json.js.map +1 -1
  144. package/dist/help.js.map +1 -1
  145. package/dist/index.d.ts +19 -26
  146. package/dist/index.js +6 -18
  147. package/dist/index.js.map +1 -1
  148. package/dist/list.d.ts +6 -5
  149. package/dist/list.js +17 -11
  150. package/dist/list.js.map +1 -1
  151. package/dist/next-step.d.ts +23 -0
  152. package/dist/next-step.js +20 -0
  153. package/dist/next-step.js.map +1 -0
  154. package/dist/output.d.ts +11 -5
  155. package/dist/output.js +25 -11
  156. package/dist/output.js.map +1 -1
  157. package/dist/root-spec.d.ts +5 -0
  158. package/dist/root-spec.js +15 -0
  159. package/dist/root-spec.js.map +1 -0
  160. package/dist/run-cli.d.ts +2 -2
  161. package/dist/run-cli.js +12 -11
  162. package/dist/run-cli.js.map +1 -1
  163. package/dist/run-command.d.ts +4 -4
  164. package/dist/run-command.js +66 -37
  165. package/dist/run-command.js.map +1 -1
  166. package/dist/run-options.d.ts +11 -4
  167. package/dist/run-process.js +22 -7
  168. package/dist/run-process.js.map +1 -1
  169. package/dist/runtime-codes.d.ts +33 -0
  170. package/dist/runtime-codes.js +50 -0
  171. package/dist/runtime-codes.js.map +1 -0
  172. package/dist/style.js +1 -1
  173. package/dist/style.js.map +1 -1
  174. package/dist/testing.d.ts +27 -0
  175. package/dist/testing.js +66 -0
  176. package/dist/testing.js.map +1 -0
  177. package/dist/version.d.ts +4 -1
  178. package/dist/version.js +6 -1
  179. package/dist/version.js.map +1 -1
  180. package/docs/README.md +51 -0
  181. package/docs/example/archive.test.ts +280 -0
  182. package/docs/example/archive.ts +160 -0
  183. package/docs/example/bin.test.ts +118 -0
  184. package/docs/example/bin.ts +5 -0
  185. package/docs/example/bundle.test.ts +57 -0
  186. package/docs/example/bundle.ts +57 -0
  187. package/docs/example/cli.test.ts +428 -0
  188. package/docs/example/cli.ts +191 -0
  189. package/docs/example/commit.test.ts +78 -0
  190. package/docs/example/commit.ts +26 -0
  191. package/docs/example/define.ts +16 -0
  192. package/docs/example/errors.ts +17 -0
  193. package/docs/example/export.test.ts +64 -0
  194. package/docs/example/export.ts +32 -0
  195. package/docs/example/fake-ports.ts +14 -0
  196. package/docs/example/folder.ts +48 -0
  197. package/docs/example/import.test.ts +53 -0
  198. package/docs/example/import.ts +50 -0
  199. package/docs/example/package.json +21 -0
  200. package/docs/example/ports.ts +105 -0
  201. package/docs/example/serve.test.ts +49 -0
  202. package/docs/example/serve.ts +40 -0
  203. package/docs/example/store.ts +62 -0
  204. package/docs/example/tsdown.config.ts +33 -0
  205. package/docs/getting-started.md +19 -0
  206. package/docs/guides/commands.md +35 -0
  207. package/docs/guides/doctor.md +52 -0
  208. package/docs/guides/effects-and-plans.md +44 -0
  209. package/docs/guides/errors.md +57 -0
  210. package/docs/guides/lists.md +18 -0
  211. package/docs/guides/local-state.md +28 -0
  212. package/docs/guides/long-runs.md +109 -0
  213. package/docs/guides/remote-services.md +124 -0
  214. package/docs/guides/shipping.md +61 -0
  215. package/docs/guides/testing.md +69 -0
  216. package/docs/principles.md +39 -0
  217. package/docs/reference.md +25 -0
  218. package/package.json +12 -1
  219. package/dist/agent.js.map +0 -1
  220. package/dist/confirm.js.map +0 -1
  221. package/dist/cost.js +0 -16
  222. package/dist/cost.js.map +0 -1
  223. package/dist/errno.js.map +0 -1
  224. package/dist/files.js.map +0 -1
  225. package/dist/flags.js.map +0 -1
  226. package/dist/input.js.map +0 -1
  227. package/dist/interrupts.js.map +0 -1
  228. package/dist/invocation.js.map +0 -1
  229. package/dist/operator.js.map +0 -1
  230. package/dist/outputs.js.map +0 -1
  231. package/dist/parse-input.js.map +0 -1
  232. package/dist/project-root.js.map +0 -1
  233. package/dist/project.js.map +0 -1
  234. package/dist/route.js.map +0 -1
  235. package/dist/secrets.js +0 -31
  236. package/dist/secrets.js.map +0 -1
  237. package/dist/side-task.js.map +0 -1
  238. package/dist/spawn.js.map +0 -1
  239. package/dist/spill.js.map +0 -1
  240. package/dist/stdin.js.map +0 -1
  241. package/dist/suggest.js.map +0 -1
  242. package/dist/tokenize.js.map +0 -1
  243. package/dist/tools.js.map +0 -1
  244. package/dist/usage.js.map +0 -1
  245. /package/dist/{project-root.d.ts → built-ins/project-root.d.ts} +0 -0
  246. /package/dist/{suggest.d.ts → command-line/suggest.d.ts} +0 -0
  247. /package/dist/{suggest.js → command-line/suggest.js} +0 -0
  248. /package/dist/{tokenize.d.ts → command-line/tokenize.d.ts} +0 -0
  249. /package/dist/{tokenize.js → command-line/tokenize.js} +0 -0
  250. /package/dist/{agent.js → context/agent.js} +0 -0
  251. /package/dist/{files.d.ts → context/files.d.ts} +0 -0
@@ -0,0 +1,62 @@
1
+ import { join } from "node:path";
2
+ import type { CliError, Context } from "@duyquangnvx/agent-cli";
3
+ import * as z from "zod";
4
+ import { cliError } from "./define.js";
5
+
6
+ export const linkSchema = z.object({
7
+ id: z.number().int(),
8
+ url: z.string(),
9
+ tags: z.array(z.string()),
10
+ snapshot: z.string().optional(),
11
+ });
12
+
13
+ export type Link = z.infer<typeof linkSchema>;
14
+
15
+ /** A run's files and the folder their paths resolve against. */
16
+ export type RunFiles = Pick<Context, "cwd" | "files">;
17
+
18
+ export const LINKS_FILE = "links.json";
19
+
20
+ export function linksPath(cwd: string): string {
21
+ return join(cwd, LINKS_FILE);
22
+ }
23
+
24
+ // File content is a boundary: parse it, and turn a bad file into an error the caller can act on.
25
+ export async function readLinks(ctx: RunFiles): Promise<Link[]> {
26
+ if (!(await ctx.files.exists(LINKS_FILE))) {
27
+ return [];
28
+ }
29
+ const text = new TextDecoder().decode(await ctx.files.read(LINKS_FILE));
30
+ try {
31
+ return z.array(linkSchema).parse(JSON.parse(text));
32
+ } catch (error) {
33
+ throw cliError(
34
+ "CORRUPT_DATA",
35
+ {
36
+ message: `${linksPath(ctx.cwd)} is not valid links data`,
37
+ hint: `fix it or delete it to start with no links`,
38
+ },
39
+ { cause: error },
40
+ );
41
+ }
42
+ }
43
+
44
+ export async function writeLinks(ctx: RunFiles, links: readonly Link[]): Promise<void> {
45
+ await ctx.files.write(LINKS_FILE, `${JSON.stringify(links, null, 2)}\n`);
46
+ }
47
+
48
+ function notFound(id: number): CliError {
49
+ return cliError("LINK_NOT_FOUND", {
50
+ message: `no link with id ${id}`,
51
+ hint: "list the links to see their ids",
52
+ next: [["list"]],
53
+ });
54
+ }
55
+
56
+ export async function linkWithId(ctx: RunFiles, id: number): Promise<Link> {
57
+ const link = (await readLinks(ctx)).find((candidate) => candidate.id === id);
58
+ if (link === undefined) {
59
+ throw notFound(id);
60
+ }
61
+ return link;
62
+ }
@@ -0,0 +1,33 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { defineConfig } from "tsdown";
3
+
4
+ function git(args: readonly string[]): string {
5
+ try {
6
+ return execFileSync("git", args, {
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ }).trim();
10
+ } catch (error) {
11
+ throw new Error("the build stamps the commit it is made from: commit to git first", {
12
+ cause: error,
13
+ });
14
+ }
15
+ }
16
+
17
+ const dirty = git(["status", "--porcelain"]) !== "";
18
+ const build = {
19
+ sha: git(["rev-parse", "--short=7", "HEAD"]),
20
+ dirty,
21
+ // A clean build stamps its commit's time, so a release that rebuilds a commit gets the same bundle.
22
+ builtAt: new Date(dirty ? Date.now() : git(["log", "-1", "--format=%cI", "HEAD"])).toISOString(),
23
+ };
24
+
25
+ export default defineConfig({
26
+ entry: ["bin.ts"],
27
+ platform: "node",
28
+ format: "esm",
29
+ fixedExtension: false,
30
+ // The bin bundles the Runtime and zod, so only this CLI's own release changes its behaviour.
31
+ deps: { onlyBundle: false },
32
+ define: { __AGENT_CLI_BUILD__: JSON.stringify(build) },
33
+ });
@@ -0,0 +1,19 @@
1
+ # Getting started
2
+
3
+ ```sh
4
+ pnpm create @duyquangnvx/agent-cli <name> --description "One line saying what it does" --skill-hub-git https://github.com/<owner>/skills.git
5
+ ```
6
+
7
+ The Scaffolder creates `./<name>`, a working Agent CLI whose example Commands you replace; run it with `--dry-run` first to list every file it would write. `--skill-hub-git` names the git URL of a Skill Hub each release commits the CLI skill to: it becomes `skill.source` in `defineCli`, `skillHub: { git }` in `scripts/release.ts` and the skill's install line in the README. `--skill-hub-api` with `--skill-page` names an API Skill Hub that gives each user their own install line, and the skill's page on it: `skillHub: { api }`, `skill.page`, and a README line naming the page. `--dependency` instead starts a Dependency install with a Packaged skill, which the CLI's `skill install` copies into a project ([`guides/shipping.md`](guides/shipping.md)).
8
+
9
+ Its `defineCli` takes the CLI's `package.json` as `manifest`, so help, `--version` and `doctor` read the name, version, description and engines from there, and `--version` prints the build stamp `tsdown.config.ts` defines as `__AGENT_CLI_BUILD__`. Its `install: "global"` declares a Global install; a CLI each project pins as a devDependency declares a Dependency install instead ([`guides/shipping.md`](guides/shipping.md)).
10
+
11
+ The repo declares its Commands in `src/cli.ts`, its Error table in `src/errors.ts`, its ports in `src/ports.ts` and the bin in `src/bin.ts`; `test/cli.test.ts` drives the CLI through `runCli`, and `skills/<name>/SKILL.md` is the CLI's own skill. Its `AGENTS.md` ends with the Runtime's Agents section, which points every session at these docs.
12
+
13
+ The repo is started when:
14
+
15
+ - the skill's home is set: a Skill Hub, or a Packaged skill;
16
+ - the examples (`greet`, `card` and `LANGUAGE_UNSUPPORTED`) are gone from code, tests and `skills/<name>/SKILL.md`;
17
+ - every real Command is [done](README.md#a-command-is-done-when);
18
+ - that skill is rewritten around the real Commands, as its comments say.
19
+
@@ -0,0 +1,35 @@
1
+ # Commands
2
+
3
+ Call `agentCli({ ports, project, errors })` once and share the `defineCommand`, `defineCli` and `cliError` it returns, as [`example/define.ts`](../example/define.ts) does: `ctx.ports` is the type the `ports` factory returns, and `ctx.project.settings` the output of the Project file's schema, which `doctor` reports in its `project` row. A CLI without ports leaves `ports` out. List each Command in `defineCli({ commands })` under the word the caller types; nest objects for groups (`commands: { voices: { list } }` gives `tool voices list`). A Command returns data and never prints:
4
+
5
+ - `description`, one line, and `examples` with realistic full command lines starting with the CLI's name: help and `--help --json` lead with them, the name replaced by the Run-as. Put what one line cannot hold in `help`.
6
+ - `.describe()` on every field of `args` and `options`: the descriptions become help.
7
+ - `output`, the schema of the data, so `--help --json` publishes it. `run` must return the schema's input, and what prints is its parse: data it rejects fails the run as a bug (exit 70). A List Command declares `rows` instead, below.
8
+ - The data names things the way the next call needs them: an id the next Command takes beside a name a person recognises, and a file by its absolute path (`resolve(ctx.cwd, file)`), since the caller's next shell may start in another folder.
9
+ - `errors`, the codes of the Error table it throws ([`errors.md`](errors.md)), and `secrets`, each key it reads with `get` saying where to obtain it. `--help --json` publishes both, so an agent learns before the first call how a Command fails and which keys to ask the operator for:
10
+
11
+ ```ts
12
+ errors: ["LINK_NOT_FOUND", "CORRUPT_DATA", "UNREACHABLE"],
13
+ secrets: [{ name: "LINKS_TOKEN", get: "https://links.example.com/settings/tokens" }],
14
+ ```
15
+
16
+ - `render`, optional, turns the data into short text; without it, text mode prints the data as [TOON](https://github.com/toon-format/toon). `links check` leaves it out; write one when a sentence reads better than the fields, as `links add` does. Its `style` is for emphasis, never for meaning, since it colours only for a person at a terminal.
17
+
18
+ Rarer cases, each documented in the types:
19
+
20
+ - `orStdin` on an arg, for text too long or too quoted for one shell word, as `links import` takes it.
21
+ - A `z.array(...)` arg or option, for repeated values.
22
+ - `operator: true`, for a step only a person at a terminal can take, such as a sign-in through the browser.
23
+ - `hidden: true`, for a Command the CLI runs on itself, such as a daemon ([`local-state.md`](local-state.md)).
24
+
25
+ Data over 30,000 characters spills to a file, so make a list that may grow a List Command, which `--limit` sizes instead.
26
+
27
+ Reach the outside through `ctx`, so tests control it:
28
+
29
+ - the working directory and environment as `ctx.cwd` and `ctx.env`;
30
+ - the file system through `ctx.files`, whose failures name the path; a `node:fs` failure is a bug (exit 70). Pass `mode` to its `write` or `mkdir` for a file or folder holding tokens. Catch a file code from `runtimeCodes` to say what to do instead, as [`example/bundle.ts`](../example/bundle.ts) turns `ALREADY_EXISTS` into its own `BUNDLE_EXISTS`;
31
+ - a key or token through `ctx.secret(name)`, for a name in the Command's `secrets`. The Runtime reads each declared Secret from the environment, then the CLI's `.env` at `ctx.envFile` (`~/.config/<name>/.env`, or under `$XDG_CONFIG_HOME`), an empty value counting as unset, and fails the run with `SECRET_MISSING` before any work, `--dry-run` included, when one is unset; so `ctx.secret` returns a `Secret`, never `undefined`. Messages name its `origin` and keep its `value` out of the agent's context. `doctor` reports every declared Secret; a key it should check that no Command requires goes in `defineCli({ secrets })`;
32
+ - a program through `ctx.spawn`, listed in the Command's `tools`, so a missing one fails before any work and `doctor` checks it. Its `stdin` option feeds the program, from an async iterable for input too large to hold, and its `onLine` option receives each line the program writes as it comes;
33
+ - `ctx.progress` while a long step runs, since a Coding agent may kill a CLI that stays silent;
34
+ - `ctx.notice` for something the caller should know beside the data, such as that a copy it relies on is behind. A fact the next step acts on belongs in the data instead.
35
+
@@ -0,0 +1,52 @@
1
+ # `doctor` rows
2
+
3
+ Every multi-Command CLI has a built-in `doctor` that reports one row per check: `build`, `node`, the CLI's own rows, one per declared tool, `install`, `registry` and `skill`. Declare a tool the whole CLI relies on in `defineCli({ tools: [...] })` so `doctor` checks it without attaching it to one Command; no Command refuses for it unless it lists the tool in its own `tools`. Add a row for each outside system the CLI depends on, in `defineCli({ doctor: [...] })`, probing it through the port method a Command calls rather than a health endpoint, so a green row means the Command's own request works:
4
+
5
+ ```ts
6
+ const store: DoctorCheck<Ports, Settings> = {
7
+ name: "store",
8
+ run: async (ctx) => {
9
+ const links = await readLinks(ctx);
10
+ return { state: "ok", message: `${linksPath(ctx.cwd)} holds ${links.length} links` };
11
+ },
12
+ };
13
+ ```
14
+
15
+ A row reads the project's settings as `ctx.project`, `undefined` when there is no Project file, as a Command does. When the file is unusable, reading `ctx.project` skips the row, since the `project` row already reports the file, so a row needs no branch for it:
16
+
17
+ ```ts
18
+ const tag: DoctorCheck<Ports, Settings> = {
19
+ name: "tag",
20
+ run: (ctx) => {
21
+ const tag = ctx.project?.settings.tag;
22
+ return {
23
+ state: "ok",
24
+ message:
25
+ tag === undefined
26
+ ? "`links add` leaves a link untagged unless given --tag"
27
+ : `\`links add\` tags a link ${tag} unless given --tag`,
28
+ };
29
+ },
30
+ };
31
+ ```
32
+
33
+ A row returns one of:
34
+
35
+ - `{ state: "ok", message, fix? }`;
36
+ - `{ state: "problem", message, fix?, code? }`, where `fix` is the command that fixes the problem, which `doctor` gives as a Next step before its own rerun, and `code` is one of the Error table's or the Runtime's;
37
+ - `{ state: "skipped", message }` for a row not checked, such as "not checked: the bridge is down", so one cause shows up as one problem. It never fails `doctor`.
38
+
39
+ When any row is a problem, `doctor` fails with one error per problem row, its `code` or `DOCTOR_PROBLEMS` (exit 2) without one, so it exits by the class that must act first as any Command does: an expired session 3, a network failure 75. Give a problem the code of its real cause. A row that throws becomes its own problem, with a `CliError`'s message and hint as the message and its code as `code`. Each row runs under its own deadline, 10 s unless it sets `deadlineMs`, and `ctx.signal` aborts at that deadline, so pass it on. A row with `advisory: true` shows its problem and `fix` but never fails `doctor`.
40
+
41
+ The Runtime's `secrets` row checks every Secret the CLI and its Commands declare, naming where each came from and never its value, so no row of yours reads a key.
42
+
43
+ The `install` and `registry` rows follow `defineCli({ install })`, and are `skipped` without it. Both are advisory: a project may stay on an older release on purpose.
44
+
45
+ - **Global install:** `install` checks that the CLI runs from npm's global folder, else gives the global install line; `registry` compares the build with the registry's latest and gives `<cli> update`.
46
+ - **Dependency install:** `install` checks that the copy that runs is the one the project at the working directory installed. It is `skipped` with `registry` when the CLI runs from source or the project links the package from its workspace, which decides its version. Otherwise another copy than the project's gets `<run-as> doctor`, which runs the project's copy; a project that has not installed the package gets its install line (`pnpm install`, `npm install`) when a `package.json` at or above the working directory lists it, else its add line, and a copy whose `package.json` is unreadable gets the install line too; yarn and bun get no line. With no copy installed, npx refuses before the CLI starts, so these fixes reach only a CLI run from another copy. `registry` compares the build with the latest release npm finds from the project root and gives `pnpm up --latest …` or `npm install …@latest`, naming the `lockstep` packages; for yarn and bun it names the version to move to instead.
47
+
48
+ The `skill` row follows `defineCli({ skill })` ([`shipping.md`](shipping.md)):
49
+
50
+ - **Skill Hub, `{ source }`:** the row is always ok and names every installed copy against the running CLI; its fix installs the skill from the hub. Without `skill` it gives `skills update` for an older copy and nothing to install a missing one.
51
+ - **Skill Hub, `{ page }`:** as for `{ source }`, but a copy `skills update` cannot reach gets no fix and the message names the page instead; what each copy gets is in the `skill` JSDoc of `defineCli`.
52
+ - **Packaged skill, `{ packaged }`:** the row is advisory, and the Runtime also gives a Notice of its problem and, when it declares `agents`, an `agents` row; what each reports is in the `skill` JSDoc of `defineCli`. Do not check the Skill copy or the Agents section in your own rows or Notices.
@@ -0,0 +1,44 @@
1
+ # Effects and Plans
2
+
3
+ Name Commands so their leading words separate reads from changes (`links list`, `links remove`): a Coding agent's permission rules match command-line prefixes, so the operator can allow every read and still be asked about each change. A read Command takes no flag that changes anything. A write Command is _idempotent_: a second run after a success changes nothing, as `links archive` skips links already archived.
4
+
5
+ An `act` Command changes the state of a program already running, such as a tap on a game in a browser tab: state that ends with that program. State that outlives it, such as a file or a remote service, is `write` or `destructive`. An Effect says what the Command itself changes, so a Command that runs the caller's script, such as an `eval`, is `act`, and its `help` says what the script can reach. Running an `act` Command again may act again.
6
+
7
+ A `read` or `act` Command has `run(input, ctx)` and no Plan. A `write` or `destructive` Command splits the work in two, and the Runtime adds `--dry-run`, which prints the Plan and stops, through `renderPlan` when declared, else as `Would run <command>:` and the Plan in TOON indented under it:
8
+
9
+ ```ts
10
+ plan: async ({ url, tag }, ctx) => {
11
+ const links = await readLinks(ctx);
12
+ const id = Math.max(0, ...links.map((link) => link.id)) + 1;
13
+ const tags = [tag ?? ctx.project?.settings.tag].filter((name) => name !== undefined);
14
+ return { add: { id, url, tags } };
15
+ },
16
+ apply: async (plan, ctx) => {
17
+ const links = await readLinks(ctx);
18
+ await ctx.runToEnd("saving the links", () => writeLinks(ctx, [...links, plan.add]));
19
+ return plan.add;
20
+ },
21
+ render: (link) => `Saved link ${link.id}: ${link.url}`,
22
+ renderPlan: (plan) => `Would save link ${plan.add.id}: ${plan.add.url}`,
23
+ ```
24
+
25
+ `plan` reads and validates only: every side effect lives in `apply`, so a dry run is always safe. Keep the Plan to paths, hashes and numbers and produce bytes in `apply`.
26
+
27
+ A Command that writes files declares `outputs(plan)`, which earns it `--force` and refuses to replace an existing output without it. Write them in `apply` through `ctx.files` or a port's `files`. Refuse an output that is also an input by comparing `ctx.files.stat` ids; `--force` never covers that.
28
+
29
+ ```ts
30
+ outputs: (plan) => [plan.file],
31
+ apply: async (plan, ctx) => {
32
+ await ctx.files.write(plan.file, markdownOf(plan.links));
33
+ return { file: resolve(ctx.cwd, plan.file), count: plan.links.length };
34
+ },
35
+ ```
36
+
37
+ A `destructive` Command has `confirm(plan)`, the value the caller repeats as `--confirm`. Pick something the caller can see in the Plan, such as a name or URL:
38
+
39
+ ```ts
40
+ confirm: (plan) => plan.remove.url,
41
+ ```
42
+
43
+ When what `--force` replaces is not a list of files, such as a whole folder, declare `force` with a sentence saying what it replaces, and make the Plan say it too. A Plan that costs money declares `cost` (Paid calls in [`remote-services.md`](remote-services.md)). A `serve` Command keeps running, such as a preview server ([`long-runs.md`](long-runs.md)).
44
+
@@ -0,0 +1,57 @@
1
+ # Errors
2
+
3
+ Declare every error code the CLI throws once, in its Error table: `defineErrors`, passed to `agentCli({ errors })`. Each code has the Exit class that must act on it (`caller`, `operator`, `source` or `wait`) and, optionally, a Zod schema for its `details`, the facts an agent acts on without parsing the message:
4
+
5
+ ```ts
6
+ export const errors = defineErrors({
7
+ LINK_NOT_FOUND: { class: "caller" },
8
+ CORRUPT_DATA: { class: "caller" },
9
+ BUNDLE_EXISTS: { class: "caller" },
10
+ UNREACHABLE: { class: "source" },
11
+ ARCHIVE_FAILED: {
12
+ class: "source",
13
+ details: z.object({ id: z.number(), url: z.string() }),
14
+ },
15
+ CAPTURE_UNANSWERED: {
16
+ class: "operator",
17
+ details: z.object({ id: z.number(), url: z.string() }),
18
+ },
19
+ });
20
+ ```
21
+
22
+ When a `details` field is worth reading in text mode too, such as a stack, name it in the code's `print` rather than repeating it in the message, which `--json` would then carry twice.
23
+
24
+ Each Command lists in `errors` the table codes it throws, its helpers' and ports' included, so a shared store's `CORRUPT_DATA` goes on every Command that reads the store. A run ending in a table code its Command does not declare, directly or as an `AggregateError` leaf, fails as a bug (exit 70) naming the code and the Command, so the Command's tests catch a missing one; `defineCli` throws on a code the table lacks. Runtime codes are never listed.
25
+
26
+ Inside a Command, throw `ctx.fail(code, init, options?)`, typed to the Command's `errors` and each code's `details`, so an undeclared code is a type error. A helper outside a Command, such as [`example/store.ts`](../example/store.ts), throws through the `cliError` that `agentCli` returns, typed by the whole table.
27
+
28
+ `next` lists the Next steps in order, each the words after the CLI's name, or `ctx.rerun({ remove?, add? })` for the caller's own line with flags changed; the Runtime starts each line with the Run-as and prints it on a `run:` line after the hint, and `--json` gives them as the error's `next`. The `hint` says whether and when to run them, and names no line itself. Set `retryAt` only when the source said when to come back:
29
+
30
+ ```ts
31
+ throw ctx.fail(
32
+ "UNREACHABLE",
33
+ {
34
+ message: `${link.url} did not answer`,
35
+ hint: "check the address, then try again",
36
+ next: [["check", String(id)]],
37
+ },
38
+ { cause: error },
39
+ );
40
+ ```
41
+
42
+ The exit code is the failure's Exit class: 1 the caller, 2 the operator, 3 someone at the source, 75 nobody but time (wait, then run again), 70 a bug, 130 or 143 an interrupt by SIGINT or SIGTERM. A `UsageError` exits 1, for input your schemas could not reject: give it the code `INVALID_INPUT`.
43
+
44
+ A code missing from the table, and anything else thrown, is a bug, exit 70, unless it wraps a missed deadline (Wrapping a failure in [`long-runs.md`](long-runs.md)). Its hint says to rerun under `<NAME>_DEBUG=1` for the stack trace and its `cause` chain, and to report it at package.json's `bugs` when declared.
45
+
46
+ Every Runtime code is on the exported `runtimeCodes`, such as `runtimeCodes.OUTPUT_EXISTS` or `runtimeCodes.TOOL_FAILED`: catch, return and assert codes through it. A Command throws a Runtime code only where it means what the Runtime means by it: `INVALID_INPUT`, a file code from `ctx.fileError`, `TOOL_MISSING` or `TOOL_FAILED`. The others, such as `UNKNOWN_COMMAND` or `DOCTOR_PROBLEMS`, name the Runtime's own checks, so a Command only catches or asserts them.
47
+
48
+ To report every problem a run found, throw an `AggregateError` of them, nested to group them if you like: each leaf prints with its hint, and the run exits with the first of 1, 2, 3, 75 that any of them has, or 70 when any is not a known `CliError`.
49
+
50
+ ```ts
51
+ if (failures.length > 0) {
52
+ throw new AggregateError(failures, `${failures.length} links were not archived`);
53
+ }
54
+ ```
55
+
56
+ Parse files, responses and other outside input with a Zod schema and turn a failure into a `CliError` with a fix in its hint, as [`example/store.ts`](../example/store.ts) does for a corrupt data file.
57
+
@@ -0,0 +1,18 @@
1
+ # Lists: pagination and `--fields`
2
+
3
+ A Command that lists many items is a List Command: a read Command that declares the schema of one row as `rows` in place of `output`. The Runtime adds `--limit`, `--cursor` and `--fields`, and each page gives the line for the next as a Next step. A list held whole in memory returns `paginate(items, { limit, cursor })`:
4
+
5
+ ```ts
6
+ options: z.object({
7
+ tag: z.string().optional().describe("only show links with this tag"),
8
+ }),
9
+ rows: linkSchema,
10
+ run: async ({ tag, limit, cursor }, ctx) => {
11
+ const links = await readLinks(ctx);
12
+ const matching = tag === undefined ? links : links.filter((link) => link.tags.includes(tag));
13
+ return paginate(matching, { limit, cursor });
14
+ },
15
+ ```
16
+
17
+ A source that pages itself, such as a remote API, returns `page(items, next)`, where `next` is the source's own cursor for the following page: ask the source for `limit` items from `cursor`, and pass its cursor through unchanged.
18
+
@@ -0,0 +1,28 @@
1
+ # Local state: a daemon or a store that outlives a run
2
+
3
+ ## Versions of what outlives a run
4
+
5
+ A daemon or a store outlives the CLI that made it, so the next run may be older or newer. Under nvm each Node version has its own global install, so two versions of the CLI run side by side.
6
+
7
+ - **Compare builds, not only protocols:** `version`, then `build.builtAt`, both from `PortsBase` (`build` is absent in a source run, which counts as oldest). A daemon does not stop on its own, so a fix that keeps the protocol would otherwise wait for a reboot.
8
+ - **A newer CLI replaces or rebuilds what is older; an older CLI refuses what is newer** with an `operator` code. Never downgrade: two CLIs that each "fix" the state flip it forever.
9
+ - **A shipped protocol only grows:** new messages or optional fields, never a change or removal, so every side can read `{ type: "hello", protocol, build }`.
10
+
11
+ ## A daemon in the CLI's own package
12
+
13
+ - **Start it as a `hidden` `serve` Command** through `ctx.spawnDetached(ctx.self[0], [...ctx.self.slice(1), "daemon"])`, which works from source and from the bundle alike and resolves with the `pid` once the Daemon has started. The Daemon outlives the run and Ctrl-C, and gets no stdio, so it logs to a file. In a test, `runCli({ spawnDetached })` records the call instead.
14
+ - **A Daemon that a newer one replaces stops its work, then calls `ctx.end()`**, which ends its run with exit 0.
15
+ - **Replace it by a protocol message, never a signal:** a Coding agent's sandbox blocks `kill`, and a daemon needs its permission to listen on a local port. The daemon decides while reading the client's `hello`, since an older daemon may refuse the rest of the handshake.
16
+ - **The lowest free port in a fixed list wins**, instead of a lock file a crash leaves stale.
17
+ - **Log a repeating refusal once per distinct cause**, or an outdated client's reconnects flood the log.
18
+ - **A bridge to it reports what it is doing** while the Command waits, through `PortsBase`'s `progress`.
19
+
20
+ ## A store that is a cache
21
+
22
+ - **Rebuild on a schema change; never migrate.** What must survive goes to a file the user named.
23
+ - **Rebuild in place,** re-reading the version inside the store's own transaction, rather than deleting the file: deleting fails on Windows while another run holds it, and on POSIX that run writes to a file nobody sees again.
24
+ - **Every Command that opens the store rebuilds it, read-only ones included**, and reports what it dropped in its data. A `doctor` row reports the store through a read-only connection and writes nothing; newer and corrupt are problems.
25
+ - **Classify the store's failures once**, for reads and writes alike: `ctx.fileError` for an errno; a store that cannot be opened or is read-only, `operator`; a full disk, `DISK_FULL`; a corrupt file, `operator`, whose hint says to move the file aside. Only on the read path does a refused query become `caller`; otherwise an agent rewrites a correct query forever.
26
+ - **Never delete a store the CLI thinks is corrupt:** rebuilding on a version change is the CLI's call, a damaged file the operator's.
27
+ - **Check `ctx.signal` before every store write**, so nothing new is written after Ctrl-C.
28
+ - **A lock wait that blocks the event loop also blocks Ctrl-C:** keep it short, and fail with a `wait` code when it runs out.
@@ -0,0 +1,109 @@
1
+ # Long runs: deadlines, Ctrl-C, jobs, queues, progress, servers and data that outlives a failure
2
+
3
+ ## Cancellation and deadlines
4
+
5
+ - Pass `ctx.signal` to `fetch` and the ports, so Ctrl-C stops the work.
6
+ - Run every call to a slow outside system under `ctx.deadline(ms)`, so the Command fails clearly instead of hanging the agent. A billed call that Ctrl-C must not cut short runs under `ctx.deadline(ms, { ignoreStop: true })` instead (Deadlines in [`remote-services.md`](remote-services.md)).
7
+ - Give a rerun of the caller's line as a Next step from `ctx.rerun`, as in `next: [ctx.rerun({ remove: ["--from"], add: ["--from", String(next)] })]`, never by hand.
8
+ - A run that could outlast the caller's tool timeout declares a `deadline`, whose `resume` returns the Next step that continues the work. Once that run deadline hits, the run ends with `DEADLINE_EXCEEDED` whatever the Command then throws, so `deliver` what is done and say in that data which work is still pending:
9
+
10
+ ```ts
11
+ deadline: {
12
+ ms: ARCHIVE_DEADLINE_MS,
13
+ // Archived links are skipped, so running the same command again continues the work.
14
+ resume: (_input, rerun) => rerun(),
15
+ },
16
+ ```
17
+
18
+ - Wrap a step that must not stop halfway, such as writing a file, in `ctx.runToEnd`.
19
+ - Poll through `ctx.poll`, which runs on `ctx.clock` so a test runs it instantly; pass `signal: ctx.deadline(ms)` to stop at a `--wait` deadline rather than one interval after it.
20
+
21
+ ```ts
22
+ capture = await ctx.poll(
23
+ async () => {
24
+ const status = await archive.status(job, ctx.signal);
25
+ return status.state === "pending" ? undefined : status;
26
+ },
27
+ { intervalMs: CAPTURE_POLL_MS },
28
+ );
29
+ ```
30
+
31
+ ## Work that outlives any deadline
32
+
33
+ When remote work runs longer than a caller can wait, split it into two Commands: a `write` Command that starts the job and returns its id, and a `read` Command that takes the id and an optional `--wait <seconds>`, polls under `ctx.deadline`, and while the job still runs fails with a `wait` code whose hint is its own command line. Rerunning the start Command with the same input finds the running job where the service allows, rather than starting another.
34
+
35
+ ## Servers
36
+
37
+ A `serve` Command keeps running, such as a preview server: `start(input, ctx)` returns data, which prints at once, and the run lasts until `ctx.signal` aborts. Stop the work on the signal:
38
+
39
+ ```ts
40
+ await new Promise<void>((resolve) => server.listen(port, HOST, resolve));
41
+ ctx.signal.addEventListener("abort", () => server.close(), { once: true });
42
+ return { url: `http://${HOST}:${port}/` };
43
+ ```
44
+
45
+ A serve Command may also end itself with `ctx.end()`, exit 0, such as a Daemon that a newer one replaces ([`local-state.md`](local-state.md)). `ctx.signal` does not abort then, so stop the work first. To start a server that outlives the run, spawn its `serve` Command with `ctx.spawnDetached`, never `ctx.spawn`, whose child the run waits for and stops on Ctrl-C.
46
+
47
+ ## Queues
48
+
49
+ A Command that works through a list of free work stops at the first fatal error and at Ctrl-C: give each item `AbortSignal.any([ctx.signal, stop.signal])` and abort `stop` from the first failure. Paid work lets every item finish instead (Paid calls in [`remote-services.md`](remote-services.md)).
50
+
51
+ - Write what a rerun resumes from (a manifest, the outputs list) before the queue starts, and `ctx.deliver` the shared list after each item, never a fresh copy per item, which turns quadratic.
52
+ - Call `signal.throwIfAborted()` after taking a concurrency slot, so every waiting item sees the abort.
53
+ - Start an item's error message with where it happened (`chapter 7, segment 31: …`).
54
+
55
+ ## Progress
56
+
57
+ - One `ctx.progress` line per event: no `\r` and no spinners, since stderr is often a log file.
58
+ - An ETA comes from the pace measured in this run, not a constant.
59
+ - A tool silent for minutes looks hung: ask it for periodic output (`ffmpeg -progress pipe:1 -stats_period 10`) and turn the lines `onLine` receives into one `ctx.progress` line per period. `onLine: ctx.progress` passes on lines that already read as progress.
60
+ - Each item's done line says what it took: time, and what it consumed (tokens, bytes); where a limit caps each call, the largest single call.
61
+ - A fact that belongs to the result goes in the data, never only in progress, which a caller reading stdout never sees.
62
+
63
+ ## Wrapping a failure
64
+
65
+ When you wrap an outside failure in your own `CliError`, let the abort reason through first, so an interrupt or a missed deadline keeps its own code, and then a `CliError` the port already classified:
66
+
67
+ ```ts
68
+ } catch (error) {
69
+ // An interrupt or a missed deadline already carries the right error.
70
+ if (ctx.signal.aborted) {
71
+ throw ctx.signal.reason;
72
+ }
73
+ if (error instanceof CliError) {
74
+ throw error;
75
+ }
76
+ throw archiveFailed(link, error instanceof Error ? error.message : String(error), error);
77
+ }
78
+ ```
79
+
80
+ Keep the original error as `cause` whenever you wrap one, as `archiveFailed` does, and make an adapter or SDK wrapper do the same: an error that is not a `CliError` and whose `cause` chain holds a `ctx.deadline`'s abort is reported as that `DEADLINE_EXCEEDED`. Any other, a `CliError` keeping the abort as `cause` included, is reported as it is, even after a deadline fired.
81
+
82
+ The Runtime's `INTERRUPTED` only names the caller's line to run again, unless it was stopped on purpose. When the Command knows more, such as what it finished or that a request was never sent, throw its own `CliError` with the code `INTERRUPTED` once `ctx.signal` aborted: it wins over the Runtime's and keeps exit 130 or 143. Compare `ctx.signal.reason` with `runtimeCodes.INTERRUPTED` and `runtimeCodes.DEADLINE_EXCEEDED`, so a missed deadline keeps its own error:
83
+
84
+ ```ts
85
+ // A missed run deadline keeps its own error, whose Next step is already the resume line.
86
+ function stoppedAfter(ctx: Context<Ports>, done: number, total: number): unknown {
87
+ const reason: unknown = ctx.signal.reason;
88
+ if (!(reason instanceof CliError && reason.code === runtimeCodes.INTERRUPTED)) {
89
+ return reason;
90
+ }
91
+ return new CliError({
92
+ code: runtimeCodes.INTERRUPTED,
93
+ message: `interrupted after archiving ${done} of ${total} links`,
94
+ hint: "unless it was stopped on purpose, run again to archive the rest",
95
+ next: [ctx.rerun()],
96
+ });
97
+ }
98
+ ```
99
+
100
+ ## Data that outlives a failure
101
+
102
+ Call `ctx.deliver` with the data done so far after each finished piece, so a run that then fails, hits a deadline or is interrupted still prints it and the caller keeps the finished work.
103
+
104
+ ```ts
105
+ const done = { id: link.id, snapshot: await snapshotOf(link, ctx) };
106
+ await ctx.runToEnd("saving the links", () => saveSnapshot(ctx, done));
107
+ archived = [...archived, done];
108
+ ctx.deliver(archived);
109
+ ```
@@ -0,0 +1,124 @@
1
+ # Remote services: ports, retries, work you cannot take back, paid calls, a Side task
2
+
3
+ ## Ports
4
+
5
+ A port is an outside system the CLI talks to, such as an HTTP API, behind an interface. `agentCli({ ports })` takes the factory that builds the real ones, and `ctx.ports` is the type it returns; `runCli`'s `ports` option replaces them in a test. The factory receives `PortsBase`, the same helpers a Command gets, so an adapter reads its key or a shipped file as a Command does. Each port method takes the signal of its call, `ctx.signal`, or a `ctx.deadline`, as `Web.status` does. A port reads and checks its key when called, not in the factory: what the factory throws fails every Command that uses any port, and a missing key for one port must not fail Commands that use another. [`example/ports.ts`](../example/ports.ts) declares three:
6
+
7
+ ```ts
8
+ export interface Ports {
9
+ readonly archive: Archive;
10
+ readonly web: Web;
11
+ readonly usage: Usage;
12
+ }
13
+ ```
14
+
15
+ ```ts
16
+ export function ports(): Ports {
17
+ return { archive: waybackArchive(), web, usage };
18
+ }
19
+ ```
20
+
21
+ A port's key comes from the Command that calls it: `links check` declares `LINKS_TOKEN` in its `secrets` and passes `ctx.secret("LINKS_TOKEN").value` to `Web.status`, so the Runtime checks the key before the run and `doctor` reports it.
22
+
23
+ The rules below shape the adapter and the Command around it.
24
+
25
+ ## Failures and their Exit class
26
+
27
+ Classify by who must act, through a schema over the response, never by its status alone:
28
+
29
+ - 400, 404, 409, 422 and other refusals of what was sent: `caller`. The message quotes the input that was refused, truncated to about 80 characters with invisible characters escaped (`JSON.stringify` leaves U+2028 and bidi controls raw), and the hint suspects the input before the service.
30
+ - A missing key, or one the service rejects (401): `operator`, the hint naming where the key came from (`secret.origin`, never its value, which must not reach an agent's context), or the `.env` to put it in, `envFile` from `ctx` or `PortsBase`.
31
+ - No access (403), a lost session, a response whose layout the schema does not know: `source`.
32
+ - 5xx, 408, 429, a network failure, a quota: `wait`, with `retryAt` from `Retry-After` or a quota's reset. A 429 that means out of credit is `operator`: someone tops it up. Each provider says which in its own body; an unrecognised 429 stays `wait`.
33
+ - A Coding agent's sandbox refuses a host with a proxy `403` carrying `X-Proxy-Error: blocked-by-allowlist`: `operator`, the hint naming the host to allow.
34
+
35
+ ## Polite retries
36
+
37
+ For a rate-limited API or a scraped site, the adapter retries one request, since it sees the status and `Retry-After`: it sleeps on `PortsBase`'s `clock` and reports each retry through its `progress`. An adapter that classifies a failure throws the `CliError` itself, which the Command passes through unchanged ([`long-runs.md`](long-runs.md) "Wrapping a failure"); one that does not, as [`example/ports.ts`](../example/ports.ts), throws a plain `Error` the Command wraps. Test the retries on the adapter alone, with a fake clock; a Command's tests fake the whole port. The Command owns what spans requests: concurrency, the minimum interval and the breaker.
38
+
39
+ - Concurrency, minimum interval, retry delays (such as `[1000, 2000, 4000]`) and a breaker threshold are named constants. No flag changes them: politeness is not the caller's choice.
40
+ - Retry only `wait`-class failures, and only a request safe to send twice (below): a 502 to a POST that starts a job is `wait`, but it is never resent. When `Retry-After` asks for no longer than the next delay, wait what it asks; when it asks for longer, or the delays run out, throw the `wait` code with `retryAt`. Sleep with the call's signal, so Ctrl-C ends a backoff.
41
+ - The breaker trips after a run of consecutive failed requests, counting requests, not attempts, permanent failures included. A tripped breaker stops the queue; items never sent are reported as not attempted, not as failed.
42
+
43
+ ## Work you cannot take back
44
+
45
+ A request that starts a job or bills may have reached the server although the client saw an error.
46
+
47
+ - **Resend only what provably did not arrive:** a failure before the connection opened, a GET, a status the provider documents as not billed, or a request the server deduplicates by an id the client generated.
48
+ - **An unanswered request is never resent:** a timeout waiting for headers, a socket dropped mid-request, a gateway 502, 504 or 408.
49
+ - When there is a job to fetch, it is still running: a `wait` code with the job id in `details` and the Command that fetches the result as its Next step.
50
+ - When there is nothing to fetch (a synchronous billed call), it is an `operator` code: whether to pay again is the operator's call. The hint says it may have been billed.
51
+ - **Generate a job's id on the client and `ctx.deliver` it before sending**, so an interrupt or a failure still prints it.
52
+ - **After a step that cannot be taken back, every Next step picks up what exists** (a fetch by id, or the run that skips finished outputs), never a plain rerun, which sends or bills twice. A Command's `deadline.resume` obeys the same rule.
53
+
54
+ ## Deadlines
55
+
56
+ Metadata lookups need a deadline too ([`long-runs.md`](long-runs.md)).
57
+
58
+ - **Layer them so the inner one fires first:** the server's limit < the adapter's timeout < the Command's `deadline.ms`, so the server answers and releases its lock before the client gives up. Cap input size so that a timeout can only mean "try again".
59
+ - **A deadline stops sending; it never cuts a billed call in flight.** Run a sent paid call inside `ctx.runToEnd`, bounded by `ctx.deadline(ms, { ignoreStop: true })` instead of `ctx.signal`: it aborts with `DEADLINE_EXCEEDED` once `ms` have passed, and neither Ctrl-C nor the run `deadline` aborts it. Check `ctx.signal.aborted` before each new call.
60
+ - Its adapter maps that timeout to its own `operator` code, whose hint says the call may have been billed, keeping the timeout as `cause`: a `CliError` is always reported by its own code. From [`example/archive.ts`](../example/archive.ts):
61
+
62
+ ```ts
63
+ try {
64
+ return await ctx.runToEnd("sending the capture", () =>
65
+ ctx.ports.archive.capture(link.url, ctx.deadline(CAPTURE_TIMEOUT_MS, { ignoreStop: true })),
66
+ );
67
+ } catch (error) {
68
+ if (!(error instanceof CliError && error.code === runtimeCodes.DEADLINE_EXCEEDED)) throw error;
69
+ throw cliError(
70
+ "CAPTURE_UNANSWERED",
71
+ {
72
+ message: `the Wayback Machine did not answer the capture of ${link.url} in ${CAPTURE_TIMEOUT_MS} ms`,
73
+ hint: "it may have used one of today's captures: ask your user before running again",
74
+ next: [["archive", "--yes"]],
75
+ details: { id: link.id, url: link.url },
76
+ },
77
+ { cause: error },
78
+ );
79
+ }
80
+ ```
81
+
82
+ ## Paid calls
83
+
84
+ Declare `cost(plan)`, a sentence stating the price, so a run that spends money needs `--yes`, the operator's call:
85
+
86
+ ```ts
87
+ cost: (plan) =>
88
+ plan.archive.length === 0
89
+ ? undefined
90
+ : `Archiving ${plan.archive.length} links uses ${plan.archive.length} of the Wayback Machine's daily captures.`,
91
+ ```
92
+
93
+ Around it:
94
+
95
+ - **A choice that decides the bill (a model, a route) has no default.** Without the option or a Project file setting, refuse in `plan` with a `caller` code whose `details` lists each choice and who it bills, so the agent asks its user. The result names who was billed.
96
+ - **Refuse every cheap local mistake before the call that bills:** credentials, input, then outputs (`outputs(plan)` does that last one).
97
+ - **A batch of paid calls lets every call finish:** cancelling siblings on the first failure discards work already billed. Write each result under a positional name (`<stem>-<i>.<ext>`) so the gaps show, then throw an `AggregateError` of the failures.
98
+ - **A result rejected after billing is still written**, and the run then fails. A hint never asks for a rerun to see it, which pays again.
99
+ - **Write a provenance record beside each billed output**, `<output>.json`: CLI version, time, command line, model, who was billed, the full prompt, each input's path and sha256, and the options sent. A rerun that finds an output whose record matches skips it, so the rerun fills only the gaps.
100
+ - **No silent fallback to another billed route:** the hint names it, and the user chooses.
101
+
102
+ ## Wrapping an SDK
103
+
104
+ - **The CLI checks shape; the library or server checks meaning.** Refuse ahead of the call only what would fail silently or bill for a discarded result, as a named constant citing its measurement. A copied table of server rules goes stale and refuses valid calls.
105
+ - **One repeatable `--option key=value`** (an option typed `z.array(z.string())`) passes an SDK's open-ended options. Report the merged object that was sent, since SDKs drop unknown keys silently, and reject the keys `__proto__`, `constructor` and `prototype`.
106
+ - **Classify a dependency's errors by their runtime shape through a schema**, not by its TypeScript types, which drift from what it throws.
107
+ - **Audit what the SDK's own retry resends**, and switch off any retry the rules above forbid.
108
+
109
+ ## LLM calls
110
+
111
+ - The SDK's `maxRetries` is the HTTP retry. A retry for a schema violation is a loop in the adapter outside the SDK call, so an HTTP retry never re-runs an attempt already billed.
112
+ - Pass `apiKey` from `ctx.secret`; an SDK that reads `process.env` skips the CLI's `.env`.
113
+
114
+ ## A Side task beside every run
115
+
116
+ Work that belongs to no Command, such as a usage count, goes in `defineCli({ sideTask })`, never in a Command: a Command cannot count a run a guard refuses, and the process exits once the output is written. The Side task starts once the input is parsed and runs beside the Command, so it adds no time to a fast run. The run waits for it at most `SIDE_TASK_WAIT_MS` (1 s) after the output is written, then aborts its `signal`. What it throws never reaches the caller, so test it through a fake port given to `runCli`, and read its failures under `<NAME>_DEBUG=1`. Return `undefined` to decline, such as for a dry run or an operator who opted out. [`example/cli.ts`](../example/cli.ts):
117
+
118
+ ```ts
119
+ // Counts only real runs, and only for an operator who named a collector.
120
+ sideTask: ({ command, dryRun, env, ports, signal }) => {
121
+ const url = env.LINKS_USAGE_URL;
122
+ return dryRun || url === undefined ? undefined : ports.usage.count(url, command, signal);
123
+ },
124
+ ```