@duyquangnvx/create-agent-cli 0.4.0 → 0.6.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.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Creates a new Agent CLI repository wired to @duyquangnvx/agent-cli.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "devDependencies": {
22
- "@duyquangnvx/agent-cli": "0.4.0",
22
+ "@duyquangnvx/agent-cli": "0.6.0",
23
23
  "tsdown": "0.23.0",
24
24
  "zod": "4.6.7"
25
25
  },
@@ -23,10 +23,10 @@ Greenfield with no deployments or users: change code directly. Breaking changes
23
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
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
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 })`; the Runtime adds `--limit`, `--cursor` and `--fields`.
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
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.
28
28
  - A change a user of `{{binName}}` would notice gets an entry under `## Unreleased` in `CHANGELOG.md`, in the format its opening paragraph states.
29
29
 
30
30
  ## Agent skills
31
31
 
32
- `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`.
32
+ `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.
@@ -2,6 +2,14 @@
2
2
 
3
3
  {{description}}
4
4
 
5
+ ## Install
6
+
7
+ Install the skill first, so that the coding agent loading it can install and check the CLI. In Claude Code, type each line in the prompt:
8
+
9
+ {{skillInstall}}
10
+
11
+ `! npm install --global {{packageName}}@latest`
12
+
5
13
  Built on [`@duyquangnvx/agent-cli`](https://www.npmjs.com/package/@duyquangnvx/agent-cli): declare Commands in `src/cli.ts` and test them through `runCli`, as `test/cli.test.ts` does.
6
14
 
7
15
  ```sh
@@ -17,7 +17,7 @@ metadata:
17
17
 
18
18
  !`{{binName}} --version 2>&1 || echo "{{binName}}: not installed"`
19
19
 
20
- The line above should show 0.0.0, the version this skill describes. If it says {{binName}} is not installed or shows another version, tell the user before going on.
20
+ The line above should show 0.0.0, the version this skill describes. If it says {{binName}} is not installed or shows another version, read [setup](references/setup.md) before anything else.
21
21
 
22
22
  ## When to use
23
23
 
@@ -41,11 +41,13 @@ The line above should show 0.0.0, the version this skill describes. If it says {
41
41
  2. If it states a cost, ask the user before going on.
42
42
  3. Run it again without `--dry-run`, adding `--yes` once the user approved the cost.
43
43
 
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
+
44
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.
45
47
 
46
48
  ## Recovering from errors
47
49
 
48
- Errors go to stderr and end with a `hint:` line naming the next step. Under `--json` they are `{ errors: [{ code, message, hint }] }`. Match the first row that fits:
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":"..."}` while it runs, then `{ errors: [{ code, message, hint }] }` as the last line. Match the first row that fits:
49
51
 
50
52
  | Code or exit | What to do |
51
53
  |---|---|
@@ -0,0 +1,47 @@
1
+ # Setup
2
+
3
+ Checking, installing and updating {{binName}}, and letting it work inside Claude Code's sandbox. Using it is in [SKILL.md](../SKILL.md).
4
+
5
+ Work through a section in order and stop at a step that fails. Ask the user to do what only they can do, such as typing an install line, and wait until they say it is done.
6
+
7
+ <!-- Add a section for each step {{binName}} needs before its first run, such as a sign-in or an API key, and a trial run if a cheap Command makes one. -->
8
+
9
+ ## Check
10
+
11
+ Run `{{binName}} doctor --json`. It checks the setup without changing anything and prints `{ rows, next }`.
12
+
13
+ - Each row has a `check`, a `state` (`ok`, `problem` or `skipped`) and a `message` saying what it found. A row that has a `fix` names the command that fixes it.
14
+ - `next` lists every fix in order. Hand the user each one to type with `!`, then run `doctor` again.
15
+ - 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. If the session does not show it, ask the user to type `/reload-skills`, and failing that to restart Claude Code.
16
+
17
+ `{{binName}} --version` names on its second line the `CHANGELOG.md` installed with the CLI. When the user asks what changed, read it from the top, newest first, down to the version in question.
18
+
19
+ ## Install
20
+
21
+ This installs the CLI. The user has installed this skill already.
22
+
23
+ 1. Run `node --version` and compare it with the range `npm view {{packageName}} engines.node` prints, which `doctor`'s `node` row also checks once the CLI is installed. Under nvm every Node version has its own global packages, so {{binName}} lands under the Node this shell runs. If that Node is too old, or is not the one the user's terminal runs, tell the user and let them choose before going on.
24
+ 2. Hand the user the install line to type, as [Sandbox](#sandbox) says:
25
+
26
+ `! npm install --global {{packageName}}@latest`
27
+
28
+ Never run `npx {{binName}}`: that runs whatever npm package holds the name.
29
+ 3. If `{{binName}} --version` then shows an older version than this skill describes, npm answered from a stale cache. Hand the user the same line with `--cache "$(mktemp -d)"` appended.
30
+ 4. Finish with [Check](#check). {{binName}} is ready when `doctor` exits 0 with nothing in `next`: a row that only advises, such as `install`, leaves the exit code at 0.
31
+
32
+ ## Update
33
+
34
+ 1. Run `{{binName}} update --dry-run` to see the version it would install. If it fails to reach the registry, the sandbox refused the host, as [Sandbox](#sandbox) says: go on to the next step.
35
+ 2. Ask the user to type `! {{binName}} update`; never run it yourself. It installs the latest release and prints what changed since the version it replaced: under `--json`, `upgrades` lists each release's notes, oldest first. Do each upgrade step those notes give, oldest first.
36
+ 3. Hand the user each line in `next` to type with `!`: they bring this skill to the version `update` installed, or to the latest release when it installed nothing, as `doctor`'s `skill` row would. Load this skill again after, as [Check](#check) says.
37
+ 4. Confirm the new version with `{{binName}} --version`.
38
+
39
+ ## Sandbox
40
+
41
+ Claude Code's sandbox never lets a command write npm's global folder, `~/.claude/skills` or `~/.agents/skills`, so the install line, `{{binName}} update` and `npx skills` fail inside it. Hand the user the line to type with `!` in this session, which runs it outside the sandbox under the Node this shell runs.
42
+
43
+ `doctor`'s `registry` row, `update` and `npm view` ask the registry {{binName}} is published to for its latest release. If the sandbox refuses that host, `doctor` still passes with only that row failing, and `update --dry-run` fails. If the user wants it to work inside the sandbox, give them this block for their Claude Code settings, naming the registry's host, and let them decide:
44
+
45
+ ```json
46
+ { "sandbox": { "network": { "allowedDomains": ["registry.npmjs.org"] } } }
47
+ ```
@@ -1,4 +1,4 @@
1
- import { agentCli, cliError } from "@duyquangnvx/agent-cli";
1
+ import { agentCli, cliError, orStdin } from "@duyquangnvx/agent-cli";
2
2
  import * as z from "zod";
3
3
  import manifest from "../package.json" with { type: "json" };
4
4
  import { build } from "./build.js";
@@ -28,12 +28,16 @@ const greet = defineCommand({
28
28
  const card = defineCommand({
29
29
  name: "card",
30
30
  description: "Write a greeting card to <name>.txt",
31
+ help:
32
+ `A card in English is free; one in another language costs ${PRICE_PER_CARD} and needs --yes. ` +
33
+ "Give - as the message to read it from stdin: `{{binName}} card Ana - < note.txt`.",
31
34
  examples: [
32
35
  { command: "{{binName}} card Ana", note: "write Ana.txt with an English greeting" },
33
36
  {
34
37
  command: "{{binName}} card Ana --language es --dry-run",
35
38
  note: "show the file it would write and what it costs",
36
39
  },
40
+ { command: "{{binName}} card Ana --from Ben --from Cy", note: "sign the card from Ben and Cy" },
37
41
  ],
38
42
  effect: "write",
39
43
  args: z.object({
@@ -41,14 +45,24 @@ const card = defineCommand({
41
45
  .string()
42
46
  .regex(/^[\w-]+$/, "letters, digits, - and _ only")
43
47
  .describe("who the card is for; also the file's name"),
48
+ message: orStdin(z.string().min(1))
49
+ .optional()
50
+ .describe("a message to write under the greeting; - reads it from stdin"),
44
51
  }),
45
52
  options: z.object({
46
53
  language: z
47
54
  .string()
48
55
  .default(FREE_LANGUAGE)
49
56
  .describe("the greeting's language code, such as en, es or fr"),
57
+ from: z.array(z.string().min(1)).default([]).describe("who signs the card; repeat for each"),
58
+ }),
59
+ plan: ({ name, message, language, from }) => ({
60
+ name,
61
+ message,
62
+ language,
63
+ from,
64
+ file: `${name}.txt`,
50
65
  }),
51
- plan: ({ name, language }) => ({ name, language, file: `${name}.txt` }),
52
66
  outputs: (plan) => [plan.file],
53
67
  cost: (plan) =>
54
68
  plan.language === FREE_LANGUAGE
@@ -62,7 +76,12 @@ const card = defineCommand({
62
76
  hint: `run \`{{binName}} card ${plan.name} --language ${FREE_LANGUAGE}\` for an English card`,
63
77
  });
64
78
  }
65
- await ctx.files.write(plan.file, `${greeting}\n`);
79
+ const parts = [
80
+ greeting,
81
+ ...(plan.message === undefined ? [] : [plan.message.trimEnd()]),
82
+ ...(plan.from.length === 0 ? [] : [`From ${plan.from.join(", ")}`]),
83
+ ];
84
+ await ctx.files.write(plan.file, `${parts.join("\n\n")}\n`);
66
85
  return { file: plan.file, greeting };
67
86
  },
68
87
  render: ({ file }) => `Wrote ${file}`,
@@ -72,6 +91,7 @@ const card = defineCommand({
72
91
  export const cli = defineCli({
73
92
  name: "{{binName}}",
74
93
  version: manifest.version,
94
+ description: manifest.description,
75
95
  build,
76
96
  install: {
77
97
  mode: "global",
@@ -1,6 +1,10 @@
1
- import { resolve } from "node:path";
1
+ import { mkdtemp, readFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { type Files, runCli } from "@duyquangnvx/agent-cli";
3
6
  import { describe, expect, test } from "vitest";
7
+ import manifest from "../package.json" with { type: "json" };
4
8
  import { cli } from "../src/cli.ts";
5
9
  import type { Greetings } from "../src/ports.ts";
6
10
 
@@ -26,6 +30,10 @@ function fakeFiles(existing: readonly string[] = []) {
26
30
  written.delete(path);
27
31
  },
28
32
  mkdir: async () => {},
33
+ stat: async (path) => {
34
+ const text = written.get(path);
35
+ return text === undefined ? undefined : { size: text.length, id: path };
36
+ },
29
37
  };
30
38
  return { files, written };
31
39
  }
@@ -42,8 +50,13 @@ function fakeGreetings() {
42
50
  return { greetings, calls };
43
51
  }
44
52
 
45
- function run(argv: string[], files = fakeFiles().files, greetings = fakeGreetings().greetings) {
46
- return runCli(cli, argv, { env: {}, cwd: CWD, files, ports: { greetings } });
53
+ function run(
54
+ argv: string[],
55
+ files = fakeFiles().files,
56
+ greetings = fakeGreetings().greetings,
57
+ stdin?: string,
58
+ ) {
59
+ return runCli(cli, argv, { env: {}, cwd: CWD, files, ports: { greetings }, stdin });
47
60
  }
48
61
 
49
62
  describe("greet", () => {
@@ -78,6 +91,35 @@ describe("card", () => {
78
91
  expect(written).toEqual(new Map([[resolve(CWD, "Ana.txt"), "Hi Ana\n"]]));
79
92
  });
80
93
 
94
+ test("- reads a message for under the greeting from stdin", async () => {
95
+ const { files, written } = fakeFiles();
96
+
97
+ const result = await run(
98
+ ["card", "Ana", "-"],
99
+ files,
100
+ undefined,
101
+ "Happy birthday!\nSee you soon.\n",
102
+ );
103
+
104
+ expect(result.exitCode).toBe(0);
105
+ expect(written.get(resolve(CWD, "Ana.txt"))).toBe("Hi Ana\n\nHappy birthday!\nSee you soon.\n");
106
+ });
107
+
108
+ test("--from, repeated, signs the card from each name", async () => {
109
+ const { files, written } = fakeFiles();
110
+
111
+ const result = await run(["card", "Ana", "--from", "Ben", "--from", "Cy"], files);
112
+
113
+ expect(result.exitCode).toBe(0);
114
+ expect(written.get(resolve(CWD, "Ana.txt"))).toBe("Hi Ana\n\nFrom Ben, Cy\n");
115
+ });
116
+
117
+ test("--help ends its options with a paragraph on cost and stdin", async () => {
118
+ const result = await run(["card", "--help"]);
119
+
120
+ expect(result.stdout).toMatch(/Options:[\s\S]*\n\nA card in English is free; /);
121
+ });
122
+
81
123
  test("refuses to overwrite an existing card without --force", async () => {
82
124
  const { files, written } = fakeFiles(["Ana.txt"]);
83
125
 
@@ -117,3 +159,48 @@ describe("card", () => {
117
159
  });
118
160
  });
119
161
  });
162
+
163
+ describe("install lines", () => {
164
+ const root = fileURLToPath(new URL("..", import.meta.url));
165
+
166
+ /** Each doctor row's fix, run from this checkout with no npm on PATH and no skill installed. */
167
+ async function doctorFixes(): Promise<Record<string, string | undefined>> {
168
+ const home = await mkdtemp(join(tmpdir(), "{{binName}}-home-"));
169
+ const result = await runCli(cli, ["doctor", "--json"], {
170
+ env: { HOME: home, PATH: "" },
171
+ cwd: root,
172
+ });
173
+ const { rows } = JSON.parse(result.stdout) as { rows: { check: string; fix?: string }[] };
174
+ return Object.fromEntries(rows.map(({ check, fix }) => [check, fix]));
175
+ }
176
+
177
+ async function text(path: string): Promise<string> {
178
+ return readFile(join(root, path), "utf8");
179
+ }
180
+
181
+ test("the setup reference and the README install the CLI with doctor's install line", async () => {
182
+ const { install } = await doctorFixes();
183
+
184
+ expect(install).toBeDefined();
185
+ expect(await text("skills/{{binName}}/references/setup.md")).toContain(`\`! ${install}\``);
186
+ expect(await text("README.md")).toContain(`\`! ${install}\``);
187
+ });
188
+
189
+ test.skipIf(!("repository" in manifest))(
190
+ "the README installs the skill with doctor's skill line",
191
+ async () => {
192
+ const { skill } = await doctorFixes();
193
+
194
+ expect(skill).toBeDefined();
195
+ expect(await text("README.md")).toContain(`\`! ${skill}\``);
196
+ },
197
+ );
198
+
199
+ test("the setup reference names the registry's host for the sandbox to allow", async () => {
200
+ const { host } = new URL(manifest.publishConfig.registry);
201
+
202
+ expect(await text("skills/{{binName}}/references/setup.md")).toContain(
203
+ `"allowedDomains": ["${host}"]`,
204
+ );
205
+ });
206
+ });