@duyquangnvx/create-agent-cli 0.2.1 → 0.4.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.2.1",
3
+ "version": "0.4.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.2.1",
22
+ "@duyquangnvx/agent-cli": "0.4.0",
23
23
  "tsdown": "0.23.0",
24
24
  "zod": "4.6.7"
25
25
  },
@@ -18,11 +18,13 @@ 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 `defineCommand`: a description, realistic examples, Zod schemas for args and options (with `.describe()` on each field), and its Effect.
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
22
  - A Command never prints. A `read` Command returns data from `run` and turns it into short text in `render`. 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.
23
- - A failure the caller can act on is a `CliError` with a `code`, a `message`, a `hint` naming the command to run next, and `retryable`. Anything else becomes an internal error, whose stack trace `{{debugVariable}}=1` shows.
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.
24
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`.
25
- - Tests drive the CLI through `runCli` and assert only stdout, stderr and the exit code, as `test/cli.test.ts` does.
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.
26
28
  - A change a user of `{{binName}}` would notice gets an entry under `## Unreleased` in `CHANGELOG.md`, in the format its opening paragraph states.
27
29
 
28
30
  ## Agent skills
@@ -24,6 +24,7 @@ The line above should show 0.0.0, the version this skill describes. If it says {
24
24
  <!-- The tasks {{binName}} is for, and the nearby tasks it is not for. -->
25
25
 
26
26
  - Greeting someone by name.
27
+ - Writing a greeting card to a file, in English for free or in another language at a cost.
27
28
 
28
29
  ## Workflows
29
30
 
@@ -34,17 +35,28 @@ The line above should show 0.0.0, the version this skill describes. If it says {
34
35
  1. Run `{{binName}} greet <name> --json`.
35
36
  2. Read `greeting` from the JSON on stdout.
36
37
 
37
- 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 and changes nothing: run it first when unsure. A destructive Command also needs `--confirm=<target>`, and its error's hint gives the exact command.
38
+ ### Write a card
39
+
40
+ 1. Run `{{binName}} card <name> --language <code> --dry-run` to see the file it writes and what it costs.
41
+ 2. If it states a cost, ask the user before going on.
42
+ 3. Run it again without `--dry-run`, adding `--yes` once the user approved the cost.
43
+
44
+ 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.
38
45
 
39
46
  ## Recovering from errors
40
47
 
41
- Errors go to stderr and end with a `hint:` line naming the next step. Under `--json` they are one object with `code`, `message`, `hint` and `retryable`. Match the first row that fits:
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:
42
49
 
43
50
  | Code or exit | What to do |
44
51
  |---|---|
45
- | exit 2 | You called it wrong. Fix the input the message names, as the hint says, and run it again. |
46
- | `INTERNAL` | A bug in {{binName}}. Run it once more with `{{debugVariable}}=1` for the stack trace, then give the user stderr verbatim. |
47
- | exit 1, `retryable: true` | Run it again once. |
48
- | exit 1 | Fix what the message and hint point at, then run it again. Ask the user when the fix is their choice. |
49
-
50
- <!-- Add a row for each CliError code {{binName}} throws, saying what to do about it. -->
52
+ | `OUTPUT_EXISTS` | The run would overwrite a file. Ask the user before adding `--force`. |
53
+ | `NOT_CONFIRMED` | The run costs money. Tell the user the cost from the message; add `--yes` only once they approve it. |
54
+ | `LANGUAGE_UNSUPPORTED` | The greetings service lacks that language. Run the hint's English card, or ask the user for another language. |
55
+ | exit 1 | You called it wrong. Fix what the message and hint point at, then run it again. |
56
+ | exit 2 | Something on this machine needs a person. Tell the user what the hint says and ask them to fix it. |
57
+ | exit 3 | The source it talks to is broken. Tell the user; running it again won't help. |
58
+ | exit 75 | Nobody needs to act. Wait, then run it again, as the hint says. |
59
+ | exit 70, `INTERNAL` | A bug in {{binName}}. Run it once more with `{{debugVariable}}=1` for the stack trace, then give the user stderr verbatim. |
60
+ | exit 130 or 143 | It was interrupted. Run it again only if the user still wants it. |
61
+
62
+ <!-- Add a row above the exit rows for each CliError code {{binName}} throws, saying what to do about it. -->
@@ -1,7 +1,14 @@
1
- import { defineCli, defineCommand } from "@duyquangnvx/agent-cli";
1
+ import { agentCli, cliError } 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";
5
+ import { errors } from "./errors.js";
6
+ import { type Ports, ports } from "./ports.js";
7
+
8
+ const { defineCli, defineCommand } = agentCli<Ports>();
9
+
10
+ const FREE_LANGUAGE = "en";
11
+ const PRICE_PER_CARD = "$0.01";
5
12
 
6
13
  const greet = defineCommand({
7
14
  name: "greet",
@@ -18,6 +25,50 @@ const greet = defineCommand({
18
25
  render: ({ greeting }) => greeting,
19
26
  });
20
27
 
28
+ const card = defineCommand({
29
+ name: "card",
30
+ description: "Write a greeting card to <name>.txt",
31
+ examples: [
32
+ { command: "{{binName}} card Ana", note: "write Ana.txt with an English greeting" },
33
+ {
34
+ command: "{{binName}} card Ana --language es --dry-run",
35
+ note: "show the file it would write and what it costs",
36
+ },
37
+ ],
38
+ effect: "write",
39
+ args: z.object({
40
+ name: z
41
+ .string()
42
+ .regex(/^[\w-]+$/, "letters, digits, - and _ only")
43
+ .describe("who the card is for; also the file's name"),
44
+ }),
45
+ options: z.object({
46
+ language: z
47
+ .string()
48
+ .default(FREE_LANGUAGE)
49
+ .describe("the greeting's language code, such as en, es or fr"),
50
+ }),
51
+ plan: ({ name, language }) => ({ name, language, file: `${name}.txt` }),
52
+ outputs: (plan) => [plan.file],
53
+ cost: (plan) =>
54
+ plan.language === FREE_LANGUAGE
55
+ ? undefined
56
+ : `A card in ${plan.language} costs ${PRICE_PER_CARD} on the greetings service.`,
57
+ apply: async (plan, ctx) => {
58
+ const greeting = await ctx.ports.greetings.greet(plan.name, plan.language);
59
+ if (greeting === undefined) {
60
+ throw cliError(errors, "LANGUAGE_UNSUPPORTED", {
61
+ message: `the greetings service has no ${plan.language} greeting`,
62
+ hint: `run \`{{binName}} card ${plan.name} --language ${FREE_LANGUAGE}\` for an English card`,
63
+ });
64
+ }
65
+ await ctx.files.write(plan.file, `${greeting}\n`);
66
+ return { file: plan.file, greeting };
67
+ },
68
+ render: ({ file }) => `Wrote ${file}`,
69
+ renderPlan: (plan) => `Would write ${plan.file}, a card for ${plan.name} in ${plan.language}`,
70
+ });
71
+
21
72
  export const cli = defineCli({
22
73
  name: "{{binName}}",
23
74
  version: manifest.version,
@@ -28,5 +79,7 @@ export const cli = defineCli({
28
79
  registry: manifest.publishConfig.registry,
29
80
  },
30
81
  engines: manifest.engines,
31
- commands: { greet },
82
+ errors,
83
+ ports,
84
+ commands: { greet, card },
32
85
  });
@@ -0,0 +1,5 @@
1
+ import { defineErrors } from "@duyquangnvx/agent-cli";
2
+
3
+ export const errors = defineErrors({
4
+ LANGUAGE_UNSUPPORTED: { class: "caller" },
5
+ });
@@ -0,0 +1,23 @@
1
+ /** A greetings service, billed per greeting outside English. */
2
+ export interface Greetings {
3
+ /** The greeting for `name` in `language`, or undefined when the service lacks that language. */
4
+ greet(name: string, language: string): Promise<string | undefined>;
5
+ }
6
+
7
+ export interface Ports {
8
+ readonly greetings: Greetings;
9
+ }
10
+
11
+ const HELLO: Readonly<Record<string, string>> = { en: "Hello", es: "Hola", fr: "Bonjour" };
12
+
13
+ // Stands in for a remote service: replace it with that service's client.
14
+ const localGreetings: Greetings = {
15
+ greet: async (name, language) => {
16
+ const hello = HELLO[language];
17
+ return hello === undefined ? undefined : `${hello}, ${name}!`;
18
+ },
19
+ };
20
+
21
+ export function ports(): Ports {
22
+ return { greetings: localGreetings };
23
+ }
@@ -1,27 +1,119 @@
1
- import { runCli } from "@duyquangnvx/agent-cli";
1
+ import { resolve } from "node:path";
2
+ import { type Files, runCli } from "@duyquangnvx/agent-cli";
2
3
  import { describe, expect, test } from "vitest";
3
4
  import { cli } from "../src/cli.ts";
5
+ import type { Greetings } from "../src/ports.ts";
4
6
 
5
- const options = { env: {}, cwd: "." };
7
+ const CWD = "/work";
8
+
9
+ /** A file system holding `existing` (paths under CWD) that records each write. */
10
+ function fakeFiles(existing: readonly string[] = []) {
11
+ const written = new Map<string, string>();
12
+ const files: Files = {
13
+ exists: async (path) => existing.some((name) => resolve(CWD, name) === path),
14
+ write: async (path, data) => {
15
+ written.set(path, String(data));
16
+ },
17
+ read: async (path) => {
18
+ const text = written.get(path);
19
+ if (text === undefined) {
20
+ throw Object.assign(new Error(`ENOENT: no such file, open '${path}'`), { code: "ENOENT" });
21
+ }
22
+ return new TextEncoder().encode(text);
23
+ },
24
+ list: async () => [],
25
+ remove: async (path) => {
26
+ written.delete(path);
27
+ },
28
+ mkdir: async () => {},
29
+ };
30
+ return { files, written };
31
+ }
32
+
33
+ /** The greetings service, faked: it knows English and Spanish and records every call. */
34
+ function fakeGreetings() {
35
+ const calls: string[] = [];
36
+ const greetings: Greetings = {
37
+ greet: async (name, language) => {
38
+ calls.push(`${name}/${language}`);
39
+ return { en: `Hi ${name}`, es: `Hola ${name}` }[language];
40
+ },
41
+ };
42
+ return { greetings, calls };
43
+ }
44
+
45
+ function run(argv: string[], files = fakeFiles().files, greetings = fakeGreetings().greetings) {
46
+ return runCli(cli, argv, { env: {}, cwd: CWD, files, ports: { greetings } });
47
+ }
6
48
 
7
49
  describe("greet", () => {
8
50
  test("prints the greeting", async () => {
9
- const result = await runCli(cli, ["greet", "Ana"], options);
51
+ const result = await run(["greet", "Ana"]);
10
52
 
11
53
  expect(result).toEqual({ stdout: "Hello, Ana!\n", stderr: "", exitCode: 0 });
12
54
  });
13
55
 
14
56
  test("prints the data as JSON with --json", async () => {
15
- const result = await runCli(cli, ["greet", "Ana", "--json"], options);
57
+ const result = await run(["greet", "Ana", "--json"]);
16
58
 
17
59
  expect(JSON.parse(result.stdout)).toEqual({ greeting: "Hello, Ana!" });
18
60
  });
19
61
 
20
62
  test("a missing name is a usage error", async () => {
21
- const result = await runCli(cli, ["greet"], options);
63
+ const result = await run(["greet"]);
22
64
 
23
- expect(result.exitCode).toBe(2);
65
+ expect(result.exitCode).toBe(1);
24
66
  expect(result.stdout).toBe("");
25
67
  expect(result.stderr).toMatch(/<name>: required/);
26
68
  });
27
69
  });
70
+
71
+ describe("card", () => {
72
+ test("writes the service's greeting to <name>.txt", async () => {
73
+ const { files, written } = fakeFiles();
74
+
75
+ const result = await run(["card", "Ana"], files);
76
+
77
+ expect(result).toEqual({ stdout: "Wrote Ana.txt\n", stderr: "", exitCode: 0 });
78
+ expect(written).toEqual(new Map([[resolve(CWD, "Ana.txt"), "Hi Ana\n"]]));
79
+ });
80
+
81
+ test("refuses to overwrite an existing card without --force", async () => {
82
+ const { files, written } = fakeFiles(["Ana.txt"]);
83
+
84
+ const refused = await run(["card", "Ana"], files);
85
+
86
+ expect(refused.exitCode).toBe(1);
87
+ expect(refused.stderr).toMatch(/hint: .*`{{binName}} card Ana --force`/);
88
+ expect(written.size).toBe(0);
89
+
90
+ const forced = await run(["card", "Ana", "--force"], files);
91
+
92
+ expect(forced.exitCode).toBe(0);
93
+ expect(written.get(resolve(CWD, "Ana.txt"))).toBe("Hi Ana\n");
94
+ });
95
+
96
+ test("a card in another language costs, so it needs --yes before calling the service", async () => {
97
+ const { greetings, calls } = fakeGreetings();
98
+
99
+ const refused = await run(["card", "Ana", "--language", "es"], undefined, greetings);
100
+
101
+ expect(refused.exitCode).toBe(2);
102
+ expect(refused.stderr).toMatch(/hint: .*`{{binName}} card Ana --language es --yes`/);
103
+ expect(calls).toEqual([]);
104
+
105
+ const approved = await run(["card", "Ana", "--language", "es", "--yes"], undefined, greetings);
106
+
107
+ expect(approved).toEqual({ stdout: "Wrote Ana.txt\n", stderr: "", exitCode: 0 });
108
+ expect(calls).toEqual(["Ana/es"]);
109
+ });
110
+
111
+ test("a language the service does not know is LANGUAGE_UNSUPPORTED", async () => {
112
+ const result = await run(["card", "Ana", "--language", "xx", "--yes", "--json"]);
113
+
114
+ expect(result.exitCode).toBe(1);
115
+ expect(JSON.parse(result.stderr)).toMatchObject({
116
+ errors: [{ code: "LANGUAGE_UNSUPPORTED" }],
117
+ });
118
+ });
119
+ });
@@ -17,6 +17,7 @@ function git(args: readonly string[]): string {
17
17
  const build = {
18
18
  sha: git(["rev-parse", "--short=7", "HEAD"]),
19
19
  dirty: git(["status", "--porcelain"]) !== "",
20
+ builtAt: new Date().toISOString(),
20
21
  };
21
22
 
22
23
  export default defineConfig({