@duyquangnvx/create-agent-cli 0.17.0 → 0.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duyquangnvx/create-agent-cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Creates a new Agent CLI repository wired to @duyquangnvx/agent-cli.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -13,6 +13,7 @@
13
13
  "dist",
14
14
  "partials",
15
15
  "template",
16
+ "agents-section.md",
16
17
  "CHANGELOG.md"
17
18
  ],
18
19
  "publishConfig": {
@@ -20,11 +21,11 @@
20
21
  "access": "public"
21
22
  },
22
23
  "devDependencies": {
23
- "@duyquangnvx/agent-cli": "0.17.0",
24
+ "@duyquangnvx/agent-cli": "0.19.0",
24
25
  "tsdown": "0.23.0",
25
26
  "zod": "4.6.7"
26
27
  },
27
28
  "scripts": {
28
- "build": "tsdown"
29
+ "build": "node -e \"require('node:fs').copyFileSync('../agent-cli/agents-section.md', 'agents-section.md')\" && tsdown"
29
30
  }
30
31
  }
@@ -1 +1 @@
1
- The `skill` row names every installed copy of this skill against the installed CLI. A missing copy, or one that states no version, is installed afresh with its `npx -y skills add` fix, since `npx skills update` skips a copy it did not install. After a fix that runs the `skills` installer, load this skill again.
1
+ The `skill` row names every installed copy of this skill against the installed CLI. A missing copy, or one that states no version, is installed afresh with the row's fix, since `npx skills update` skips a copy it did not install. After a fix that runs the `skills` installer, load this skill again.
@@ -18,15 +18,12 @@ Greenfield with no deployments or users: change code directly. Breaking changes
18
18
 
19
19
  ## Coding standards
20
20
 
21
- - Every Command is declared in `src/cli.ts` with the `defineCommand` that `agentCli<Ports>()` returns: a description, realistic examples, Zod schemas for args and options (with `.describe()` on each field), and its Effect.
22
- - A Command never prints. A `read` Command returns data from `run` and turns it into short text in `render`. An `act` Command, which changes the state of a program already running rather than files or services, does the same with no Plan. A `write` or `destructive` Command returns a Plan from `plan`, with no side effects, and performs it in `apply`; the Runtime adds `--dry-run`, and `--confirm` for a destructive one. A `serve` Command returns data from `start` and keeps running until `ctx.signal` aborts.
23
- - A Command that writes files names them in `outputs(plan)`, and the Runtime refuses to overwrite one without `--force`. A Command that costs money states it in `cost(plan)`, a sentence or `undefined` when cheap, and the Runtime refuses a costly run without `--yes`.
24
- - A Command reaches a remote service through a port: an interface in `src/ports.ts`, built by the `ports` factory passed to `defineCli` and read as `ctx.ports`. It writes files through `ctx.files` and runs programs through `ctx.spawn`, so a test replaces each of them.
25
- - Every error code is declared once in a `defineErrors` table passed to `defineCli({ errors })`, with the Exit class that must act on it and an optional `details` schema, and thrown through `cliError(errors, code, { message, hint })`, whose `hint` names the command to run next. An undeclared code, and anything else thrown, becomes an internal error (exit 70), whose stack trace `{{debugVariable}}=1` shows.
26
- - A Command that lists many items declares its row schema as `rows` and returns `paginate(items, { limit, cursor })`, or `page(items, next)` with the source's own cursor when the source pages itself; the Runtime adds `--limit`, `--cursor` and `--fields`.
27
- - Tests drive the CLI through `runCli`, replacing ports, files and the clock in its options, and assert stdout, stderr, the exit code and what the fakes recorded, as `test/cli.test.ts` does.
21
+ Follow the rules in `~/.claude/rules/harness/`. Biome enforces the mechanical ones.
22
+
28
23
  - A change a user of `{{binName}}` would notice gets an entry under `## Unreleased` in `CHANGELOG.md`, in the format its opening paragraph states.
29
24
 
30
25
  ## Agent skills
31
26
 
32
27
  `skills/{{binName}}/SKILL.md` is the agent skill: when to use `{{binName}}`, its workflows and how to recover from its errors. Update it with any change to those, and leave flag details to `--help`. Its `references/setup.md` installs, checks and updates `{{binName}}`; `test/cli.test.ts` fails when its install line, or the README's, differs from what `doctor` gives.
28
+
29
+ {{agentsSection}}
@@ -29,7 +29,7 @@
29
29
  "@biomejs/biome": "2.5.15",
30
30
  "@duyquangnvx/agent-cli": "{{runtimeVersion}}",
31
31
  "@duyquangnvx/harness": "0.3.1",
32
- "@duyquangnvx/release": "0.4.0",
32
+ "@duyquangnvx/release": "0.5.0",
33
33
  "@types/node": "^22.19.0",
34
34
  "tsdown": "0.23.0",
35
35
  "typescript": "7.0.2",
@@ -1,6 +1,6 @@
1
1
  import { release } from "@duyquangnvx/release";
2
2
 
3
- process.exitCode = release({
3
+ process.exitCode = await release({
4
4
  skill: "skills/{{binName}}/SKILL.md",
5
5
  smoke: { cli: true },
6
6
  });
@@ -43,17 +43,17 @@ The line above should show 0.0.0, the version this skill describes. If it says {
43
43
 
44
44
  To put a message under the greeting, pass `-` after the name and pipe the message in with a heredoc, so its quotes and newlines reach the card intact.
45
45
 
46
- Pass `--json` whenever you parse the output: stdout then holds only the data. A Command that changes something takes `--dry-run`, which prints its Plan, the files it writes and its cost, and changes nothing: run it first when unsure. A destructive Command also needs `--confirm=<target>`, and its error's hint gives the exact command.
46
+ Pass `--json` whenever you parse the output: stdout then holds only the data. A Command that changes something takes `--dry-run`, which prints its Plan, the files it writes and its cost, and changes nothing: run it first when unsure. A destructive Command also needs `--confirm=<target>`, and its error's Next step gives the exact command.
47
47
 
48
48
  ## Recovering from errors
49
49
 
50
- Errors go to stderr and end with a `hint:` line naming the next step. Under `--json`, every stderr line is one JSON object: `{"progress":"..."}` or `{"notice":"..."}` while it runs, then `{ errors: [{ code, message, hint }] }` as the last line. Match the first row that fits:
50
+ Errors go to stderr and end with a `hint:` line, then a `run:` line for each command to run next, in order. Under `--json`, every stderr line is one JSON object: `{"progress":"..."}` or `{"notice":"..."}` while it runs, then `{ errors: [{ code, message, hint, next }] }` as the last line, `next` holding those commands when there are any. Match the first row that fits:
51
51
 
52
52
  | Code or exit | What to do |
53
53
  |---|---|
54
54
  | `OUTPUT_EXISTS` | The run would overwrite a file. Ask the user before adding `--force`. |
55
55
  | `NOT_CONFIRMED` | The run costs money. Tell the user the cost from the message; add `--yes` only once they approve it. |
56
- | `LANGUAGE_UNSUPPORTED` | The greetings service lacks that language. Run the hint's English card, or ask the user for another language. |
56
+ | `LANGUAGE_UNSUPPORTED` | The greetings service lacks that language. Run the Next step's English card, or ask the user for another language. |
57
57
  | exit 1 | You called it wrong. Fix what the message and hint point at, then run it again. |
58
58
  | exit 2 | Something on this machine needs a person. Tell the user what the hint says and ask them to fix it. |
59
59
  | exit 3 | The source it talks to is broken. Tell the user; running it again won't help. |
@@ -1,25 +1,23 @@
1
- import { agentCli, cliError, orStdin } from "@duyquangnvx/agent-cli";
1
+ import { agentCli, orStdin } from "@duyquangnvx/agent-cli";
2
2
  import * as z from "zod";
3
3
  import manifest from "../package.json" with { type: "json" };
4
- import { build } from "./build.js";
5
4
  import { errors } from "./errors.js";
6
- import { type Ports, ports } from "./ports.js";
5
+ import { ports } from "./ports.js";
7
6
 
8
- const { defineCli, defineCommand } = agentCli<Ports>();
7
+ const { defineCli, defineCommand } = agentCli({ errors, ports });
9
8
 
10
9
  const FREE_LANGUAGE = "en";
11
10
  const PRICE_PER_CARD = "$0.01";
12
11
 
13
12
  const greet = defineCommand({
14
- name: "greet",
15
13
  description: "Greet someone by name",
16
14
  examples: [
17
15
  {
18
- command: "{{runAs}} greet Ana",
16
+ command: "{{binName}} greet Ana",
19
17
  note: "say hello to Ana",
20
18
  },
21
19
  {
22
- command: "{{runAs}} greet Ana --json",
20
+ command: "{{binName}} greet Ana --json",
23
21
  note: "get the greeting as JSON",
24
22
  },
25
23
  ],
@@ -28,30 +26,29 @@ const greet = defineCommand({
28
26
  name: z.string().min(1).describe("who to greet"),
29
27
  }),
30
28
  run: ({ name }) => ({ greeting: `Hello, ${name}!` }),
31
- render: ({ greeting }) => greeting,
32
29
  });
33
30
 
34
31
  const card = defineCommand({
35
- name: "card",
36
32
  description: "Write a greeting card to <name>.txt",
37
33
  help:
38
34
  `A card in English is free; one in another language costs ${PRICE_PER_CARD} and needs --yes. ` +
39
35
  "Give - as the message to read it from stdin: `{{runAs}} card Ana - < note.txt`.",
40
36
  examples: [
41
37
  {
42
- command: "{{runAs}} card Ana",
38
+ command: "{{binName}} card Ana",
43
39
  note: "write Ana.txt with an English greeting",
44
40
  },
45
41
  {
46
- command: "{{runAs}} card Ana --language es --dry-run",
42
+ command: "{{binName}} card Ana --language es --dry-run",
47
43
  note: "show the file it would write and what it costs",
48
44
  },
49
45
  {
50
- command: "{{runAs}} card Ana --from Ben --from Cy",
46
+ command: "{{binName}} card Ana --from Ben --from Cy",
51
47
  note: "sign the card from Ben and Cy",
52
48
  },
53
49
  ],
54
50
  effect: "write",
51
+ errors: ["LANGUAGE_UNSUPPORTED"],
55
52
  args: z.object({
56
53
  name: z
57
54
  .string()
@@ -83,9 +80,10 @@ const card = defineCommand({
83
80
  apply: async (plan, ctx) => {
84
81
  const greeting = await ctx.ports.greetings.greet(plan.name, plan.language);
85
82
  if (greeting === undefined) {
86
- throw cliError(errors, "LANGUAGE_UNSUPPORTED", {
83
+ throw ctx.fail("LANGUAGE_UNSUPPORTED", {
87
84
  message: `the greetings service has no ${plan.language} greeting`,
88
- hint: `run \`{{runAs}} card ${plan.name} --language ${FREE_LANGUAGE}\` for an English card`,
85
+ hint: "run the Next step for an English card",
86
+ next: [["card", plan.name, "--language", FREE_LANGUAGE]],
89
87
  });
90
88
  }
91
89
  const parts = [
@@ -101,17 +99,7 @@ const card = defineCommand({
101
99
  });
102
100
 
103
101
  export const cli = defineCli({
104
- name: "{{binName}}",
105
- version: manifest.version,
106
- description: manifest.description,
107
- build,
108
- install: {
109
- mode: "global",
110
- package: manifest.name,
111
- registry: manifest.publishConfig.registry,
112
- },
113
- engines: manifest.engines,
114
- errors,
115
- ports,
102
+ manifest,
103
+ install: "global",
116
104
  commands: { greet, card },
117
105
  });
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { type Files, runCli } from "@duyquangnvx/agent-cli";
6
+ import { checkExamples } from "@duyquangnvx/agent-cli/testing";
6
7
  import { describe, expect, test } from "vitest";
7
8
  import manifest from "../package.json" with { type: "json" };
8
9
  import { cli } from "../src/cli.ts";
@@ -53,8 +54,11 @@ function fakeGreetings() {
53
54
  return { greetings, calls };
54
55
  }
55
56
 
56
- function hintOf(stderr: string): string | undefined {
57
- return stderr.split("\n").find((line) => line.startsWith("hint: "));
57
+ function nextOf(stderr: string): string[] {
58
+ return stderr
59
+ .split("\n")
60
+ .filter((line) => line.startsWith("run: "))
61
+ .map((line) => line.slice("run: ".length));
58
62
  }
59
63
 
60
64
  function run(
@@ -63,14 +67,18 @@ function run(
63
67
  greetings = fakeGreetings().greetings,
64
68
  stdin?: string,
65
69
  ) {
66
- return runCli(cli, argv, { env: {}, cwd: CWD, files, ports: { greetings }, stdin });
70
+ return runCli(cli, argv, { cwd: CWD, files, ports: { greetings }, stdin });
67
71
  }
68
72
 
73
+ test("every example parses, names a Command and passes its schemas", async () => {
74
+ await checkExamples(cli);
75
+ });
76
+
69
77
  describe("greet", () => {
70
78
  test("prints the greeting", async () => {
71
79
  const result = await run(["greet", "Ana"]);
72
80
 
73
- expect(result).toEqual({ stdout: "Hello, Ana!\n", stderr: "", exitCode: 0 });
81
+ expect(result).toEqual({ stdout: 'greeting: "Hello, Ana!"\n', stderr: "", exitCode: 0 });
74
82
  });
75
83
 
76
84
  test("prints the data as JSON with --json", async () => {
@@ -133,7 +141,7 @@ describe("card", () => {
133
141
  const refused = await run(["card", "Ana"], files);
134
142
 
135
143
  expect(refused.exitCode).toBe(1);
136
- expect(hintOf(refused.stderr)).toContain(`\`${RUN_AS} card Ana --force\``);
144
+ expect(nextOf(refused.stderr)).toEqual([`${RUN_AS} card Ana --force`]);
137
145
  expect(written.size).toBe(0);
138
146
 
139
147
  const forced = await run(["card", "Ana", "--force"], files);
@@ -148,7 +156,7 @@ describe("card", () => {
148
156
  const refused = await run(["card", "Ana", "--language", "es"], undefined, greetings);
149
157
 
150
158
  expect(refused.exitCode).toBe(2);
151
- expect(hintOf(refused.stderr)).toContain(`\`${RUN_AS} card Ana --language es --yes\``);
159
+ expect(nextOf(refused.stderr)).toEqual([`${RUN_AS} card Ana --language es --yes`]);
152
160
  expect(calls).toEqual([]);
153
161
 
154
162
  const approved = await run(["card", "Ana", "--language", "es", "--yes"], undefined, greetings);
@@ -170,8 +178,8 @@ describe("card", () => {
170
178
  describe("install lines", () => {
171
179
  const root = fileURLToPath(new URL("..", import.meta.url));
172
180
 
173
- /** Each doctor row's fix, run from a pnpm project lacking the CLI, with no npm on PATH and no skill installed. */
174
- async function doctorFixes(): Promise<Record<string, string | undefined>> {
181
+ /** Each doctor row, run from a pnpm project lacking the CLI, with no npm on PATH and no skill installed. */
182
+ async function doctorRows(): Promise<Record<string, { message: string; fix?: string }>> {
175
183
  const home = await mkdtemp(join(tmpdir(), "{{binName}}-home-"));
176
184
  const project = await mkdtemp(join(tmpdir(), "{{binName}}-project-"));
177
185
  await writeFile(join(project, "package.json"), "{}\n");
@@ -181,8 +189,10 @@ describe("install lines", () => {
181
189
  cwd: project,
182
190
  bin: DOCTOR_BIN === "" ? undefined : join(root, DOCTOR_BIN),
183
191
  });
184
- const { rows } = JSON.parse(result.stdout) as { rows: { check: string; fix?: string }[] };
185
- return Object.fromEntries(rows.map(({ check, fix }) => [check, fix]));
192
+ const { rows } = JSON.parse(result.stdout) as {
193
+ rows: { check: string; message: string; fix?: string }[];
194
+ };
195
+ return Object.fromEntries(rows.map((row) => [row.check, row]));
186
196
  }
187
197
 
188
198
  async function text(path: string): Promise<string> {
@@ -190,7 +200,7 @@ describe("install lines", () => {
190
200
  }
191
201
 
192
202
  test("the setup reference and the README install the CLI with doctor's install line", async () => {
193
- const { install } = await doctorFixes();
203
+ const install = (await doctorRows()).install?.fix;
194
204
 
195
205
  expect(install).toBeDefined();
196
206
  expect(await text("skills/{{binName}}/references/setup.md")).toContain(`\`! ${install}\``);
@@ -200,10 +210,10 @@ describe("install lines", () => {
200
210
  test.skipIf(cli.skill === undefined)(
201
211
  "the README installs the skill with doctor's skill line",
202
212
  async () => {
203
- const { skill } = await doctorFixes();
213
+ const fix = (await doctorRows()).skill?.fix;
204
214
 
205
- expect(skill).toBeDefined();
206
- expect(await text("README.md")).toContain(`\`! ${skill}\``);
215
+ expect(fix).toBeDefined();
216
+ expect(await text("README.md")).toContain(`\`! ${fix}\``);
207
217
  },
208
218
  );
209
219
 
@@ -29,5 +29,5 @@ export default defineConfig({
29
29
  fixedExtension: false,
30
30
  // The bin bundles the Runtime and zod, so only this CLI's own release changes its behaviour.
31
31
  deps: { onlyBundle: false },
32
- define: { __BUILD__: JSON.stringify(build) },
32
+ define: { __AGENT_CLI_BUILD__: JSON.stringify(build) },
33
33
  });
@@ -1,6 +0,0 @@
1
- import type { Cli } from "@duyquangnvx/agent-cli";
2
-
3
- // tsdown.config.ts stamps this into the bundle; the source run unbundled, as in tests, has none.
4
- declare const __BUILD__: Cli["build"];
5
-
6
- export const build: Cli["build"] = typeof __BUILD__ === "undefined" ? undefined : __BUILD__;