agents.yaml 0.2.2 → 0.3.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/AGENTS.md CHANGED
@@ -21,3 +21,7 @@ The CLI can help discover package and local `AGENTS.md` files, add selected path
21
21
  Discovery only considers direct dependencies under a project's `node_modules`; nested dependency `AGENTS.md` files are not automatically activated.
22
22
 
23
23
  Discovery skips dot-prefixed directories by default. Use `agents discover --include-dot-directories` when hidden project directories should be scanned too.
24
+
25
+ The CLI uses Comline for command definitions, options, variadic path arguments, help, and shell completion. Clack handles the interactive flow. Pass full runtime argv to `run`; Comline removes the runtime and entry-point arguments.
26
+
27
+ `add` and `remove` require one or more paths. Use `--` before dash-prefixed paths. Unknown or misplaced options fail before command execution. Both text and JSON validation set a nonzero exit status when the index is invalid.
package/README.md CHANGED
@@ -19,14 +19,35 @@ agents init
19
19
  agents discover
20
20
  agents discover --include-dot-directories
21
21
  agents add ./node_modules/react/AGENTS.md
22
+ agents add ./react/AGENTS.md "./my package/AGENTS.md"
23
+ agents remove ./react/AGENTS.md "./my package/AGENTS.md"
22
24
  agents validate
25
+ agents validate --json
26
+ agents --help
27
+ agents --version
23
28
  ```
24
29
 
25
30
  Run `agents` with no command for the interactive flow.
26
31
 
27
- Discovery skips dot-prefixed directories by default so local caches and tool
28
- state do not dominate scan time. Use `--include-dot-directories` when you need
29
- to search those directories too.
32
+ `add` and `remove` accept one or more paths. Use `--` before paths that start with a dash. Unknown commands and flags, flags used with the wrong command, and missing required paths produce an error before the command runs.
33
+
34
+ Boolean switches accept `true`, `false`, `1`, and `0`, either with `=` or as the next argument. Repeated switches use the last value. Validation exits with status 1 for an invalid document index, including with `--json`.
35
+
36
+ Discovery skips dot-prefixed directories by default so local caches and tool state do not dominate scan time. Use `--include-dot-directories` when you need to search those directories too.
37
+
38
+ ## Shell Completion
39
+
40
+ Generate a completion script with `agents completion <target>`, or install it into your shell's configured completion directory:
41
+
42
+ ```sh
43
+ agents completion install bash
44
+ agents completion install zsh
45
+ agents completion install fish
46
+ agents completion install nushell
47
+ agents completion install carapace
48
+ ```
49
+
50
+ Choose the target you use, then open a new shell. Completion setup requires the target shell's completion system to be enabled; setup errors explain any missing requirements. Commands and flags complete automatically. `add` completes filesystem paths, and `remove` suggests paths listed in `agents.yaml`, including after the first path.
30
51
 
31
52
  ## Benchmark
32
53
 
@@ -34,12 +55,7 @@ to search those directories too.
34
55
  pnpm --filter agents.yaml bench
35
56
  ```
36
57
 
37
- The benchmark creates a temporary discovery fixture, compares default discovery
38
- against `--include-dot-directories`, prints median/min/max timings, and removes
39
- the fixture when it exits. Fixture size can be tuned with
40
- `AGENTS_BENCH_HIDDEN_DIRS`, `AGENTS_BENCH_FILES_PER_HIDDEN_DIR`,
41
- `AGENTS_BENCH_VISIBLE_PACKAGES`, `AGENTS_BENCH_ITERATIONS`, and
42
- `AGENTS_BENCH_WARMUPS`.
58
+ The benchmark creates a temporary discovery fixture, compares default discovery against `--include-dot-directories`, prints median/min/max timings, and removes the fixture when it exits. Fixture size can be tuned with `AGENTS_BENCH_HIDDEN_DIRS`, `AGENTS_BENCH_FILES_PER_HIDDEN_DIR`, `AGENTS_BENCH_VISIBLE_PACKAGES`, `AGENTS_BENCH_ITERATIONS`, and `AGENTS_BENCH_WARMUPS`.
43
59
 
44
60
  ## File Format
45
61
 
package/dist/index.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { MultiSelectPrompt } from "@clack/core";
3
3
  import * as clack from "@clack/prompts";
4
+ import { cli, completionResponse, help, interpretArguments, optional, options, parseBooleanOption, required } from "comline";
4
5
  import { styleText } from "node:util";
5
6
  import { access, opendir, readFile, writeFile } from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import * as YAML from "yaml";
8
9
  import { z } from "zod";
10
+ import { readFileSync } from "node:fs";
9
11
  //#region src/paths.ts
10
12
  function cwd() {
11
13
  return process.cwd();
@@ -147,7 +149,8 @@ async function initProject(root, options) {
147
149
  messages.push("AGENTS.md already mentions agents.yaml");
148
150
  return { messages };
149
151
  }
150
- await writeFile(projectAgentsPath, source.trimEnd().length === 0 ? `# Project Instructions\n\n${breadcrumb}\n` : `${source.trimEnd()}\n\n${breadcrumb}\n`, "utf8");
152
+ const next = source.trimEnd().length === 0 ? `# Project Instructions\n\n${breadcrumb}\n` : `${source.trimEnd()}\n\n${breadcrumb}\n`;
153
+ await writeFile(projectAgentsPath, next, "utf8");
151
154
  messages.push("updated AGENTS.md");
152
155
  } catch (error) {
153
156
  if (!isNotFound(error)) throw error;
@@ -163,8 +166,104 @@ function isNotFound(error) {
163
166
  return error instanceof Error && "code" in error && error.code === "ENOENT";
164
167
  }
165
168
  //#endregion
169
+ //#region src/cli.ts
170
+ function parseBooleanSwitch(value) {
171
+ const last = value.split(",").at(-1) ?? "";
172
+ if (![
173
+ "",
174
+ "true",
175
+ "false",
176
+ "0",
177
+ "1"
178
+ ].includes(last)) throw new Error(`Expected a boolean switch, received ${JSON.stringify(last)}`);
179
+ return parseBooleanOption(last);
180
+ }
181
+ function booleanSwitch(description, example) {
182
+ return {
183
+ description,
184
+ example,
185
+ parse: parseBooleanSwitch,
186
+ required: false
187
+ };
188
+ }
189
+ const commonSchema = z.object({
190
+ help: z.boolean().default(false),
191
+ version: z.boolean().default(false)
192
+ });
193
+ const commonOptions = {
194
+ help: {
195
+ ...booleanSwitch("Show usage", "--help"),
196
+ flag: "h"
197
+ },
198
+ version: {
199
+ ...booleanSwitch("Show the installed version", "--version"),
200
+ flag: "v"
201
+ }
202
+ };
203
+ const jsonOption = booleanSwitch("Print JSON", "--json");
204
+ function common(description) {
205
+ return options(description, commonSchema, commonOptions);
206
+ }
207
+ const agents = cli({
208
+ cliName: "agents",
209
+ cliDescription: "Discover and curate agent-readable documentation in agents.yaml.",
210
+ routes: optional({
211
+ init: null,
212
+ discover: null,
213
+ add: required({ "$...paths": null }),
214
+ remove: required({ "$...paths": null }),
215
+ validate: null,
216
+ help: null,
217
+ version: null
218
+ }),
219
+ routeOptions: {
220
+ "": common("Choose an action interactively"),
221
+ init: options("Initialize breadcrumb files", commonSchema.extend({ force: z.boolean().default(false) }), {
222
+ ...commonOptions,
223
+ force: booleanSwitch("Append the breadcrumb even if already mentioned", "--force")
224
+ }),
225
+ discover: options("Discover supplemental AGENTS.md files", commonSchema.extend({
226
+ json: z.boolean().default(false),
227
+ "include-dot-directories": z.boolean().default(false)
228
+ }), {
229
+ ...commonOptions,
230
+ json: jsonOption,
231
+ "include-dot-directories": booleanSwitch("Search hidden directories", "--include-dot-directories")
232
+ }),
233
+ "add/$...paths": common("Promote one or more AGENTS.md files"),
234
+ "remove/$...paths": common("Remove one or more promoted paths"),
235
+ validate: options("Validate agents.yaml", commonSchema.extend({ json: z.boolean().default(false) }), {
236
+ ...commonOptions,
237
+ json: jsonOption
238
+ }),
239
+ help: common("Show usage"),
240
+ version: common("Show the installed version")
241
+ },
242
+ positionalCompletions: {
243
+ "add/$...paths": { fileSystem: "files" },
244
+ "remove/$...paths": { provide: async () => {
245
+ const { documents } = await loadAgentsFile(cwd());
246
+ return documents.map(({ path, description }) => ({
247
+ value: path,
248
+ ...description ? { description } : {}
249
+ }));
250
+ } }
251
+ }
252
+ });
253
+ function earlyCommand(argv) {
254
+ const { options: occurrences } = interpretArguments(agents.definition, argv.slice(2));
255
+ for (const key of ["help", "version"]) {
256
+ const occurrence = occurrences.findLast((option) => option.key === key);
257
+ if (occurrence && parseBooleanSwitch(occurrence.value)) return key;
258
+ }
259
+ }
260
+ function packageVersion() {
261
+ const source = readFileSync(new URL("../package.json", import.meta.url), "utf8");
262
+ return z.object({ version: z.string() }).parse(JSON.parse(source)).version;
263
+ }
264
+ //#endregion
166
265
  //#region src/discover.ts
167
- const skippedDirectories = new Set([
266
+ const skippedDirectories = /* @__PURE__ */ new Set([
168
267
  ".git",
169
268
  ".hg",
170
269
  ".svn",
@@ -262,98 +361,54 @@ function isPackageJson(value) {
262
361
  }
263
362
  //#endregion
264
363
  //#region src/run.ts
265
- const helpText = `agents
266
-
267
- Usage:
268
- agents
269
- agents init [--force]
270
- agents discover [--json] [--include-dot-directories]
271
- agents add <path...>
272
- agents remove <path...>
273
- agents validate [--json]
274
-
275
- agents.yaml is a curated table of contents for promoted AGENTS.md guidance.`;
276
364
  async function run(argv) {
277
- const parsed = parseArgs(argv);
365
+ const completion = await completionResponse(agents.definition, argv);
366
+ if (completion !== void 0) {
367
+ process.stdout.write(completion);
368
+ return;
369
+ }
370
+ const early = earlyCommand(argv);
371
+ if (early === "help") {
372
+ console.log(help(agents.definition));
373
+ return;
374
+ }
375
+ if (early === "version") {
376
+ console.log(packageVersion());
377
+ return;
378
+ }
379
+ const { inputs, warnings } = agents(argv);
380
+ if (warnings.length > 0) throw new Error(warnings.map((warning) => warning.message).join("\n"));
278
381
  const root = cwd();
279
- switch (parsed.command) {
280
- case void 0:
382
+ switch (inputs.case) {
383
+ case "":
281
384
  await interactive(root);
282
385
  return;
283
386
  case "help":
284
- console.log(helpText);
387
+ console.log(help(agents.definition));
285
388
  return;
286
389
  case "version":
287
- console.log("0.1.0");
390
+ console.log(packageVersion());
288
391
  return;
289
392
  case "init":
290
- await commandInit(root, parsed.flags.get("force") === true);
393
+ await commandInit(root, inputs.opts.force);
291
394
  return;
292
395
  case "discover":
293
396
  await commandDiscover(root, {
294
- json: parsed.flags.get("json") === true,
295
- includeDotDirectories: parsed.flags.get("include-dot-directories") === true
397
+ json: inputs.opts.json,
398
+ includeDotDirectories: inputs.opts["include-dot-directories"]
296
399
  });
297
400
  return;
298
- case "add":
299
- await commandAdd(root, parsed.values);
401
+ case "add/$...paths":
402
+ await commandAdd(root, inputs.params.paths);
300
403
  return;
301
- case "remove":
302
- await commandRemove(root, parsed.values);
404
+ case "remove/$...paths":
405
+ await commandRemove(root, inputs.params.paths);
303
406
  return;
304
407
  case "validate":
305
- await commandValidate(root, parsed.flags.get("json") === true);
408
+ await commandValidate(root, inputs.opts.json);
306
409
  return;
307
410
  }
308
411
  }
309
- function parseArgs(argv) {
310
- const flags = /* @__PURE__ */ new Map();
311
- const values = [];
312
- let command;
313
- for (let index = 0; index < argv.length; index += 1) {
314
- const arg = argv[index];
315
- if (!arg) continue;
316
- if (arg === "--help" || arg === "-h") {
317
- command = "help";
318
- continue;
319
- }
320
- if (arg === "--version" || arg === "-v") {
321
- command = "version";
322
- continue;
323
- }
324
- if (arg.startsWith("--")) {
325
- const [rawName, inlineValue] = arg.slice(2).split("=", 2);
326
- if (!rawName) continue;
327
- if (inlineValue !== void 0) {
328
- flags.set(rawName, inlineValue);
329
- continue;
330
- }
331
- flags.set(rawName, true);
332
- continue;
333
- }
334
- if (!command && isCommand(arg)) {
335
- command = arg;
336
- continue;
337
- }
338
- values.push(arg);
339
- }
340
- return {
341
- command,
342
- values,
343
- flags
344
- };
345
- }
346
- function isCommand(value) {
347
- return [
348
- "add",
349
- "discover",
350
- "help",
351
- "init",
352
- "remove",
353
- "validate",
354
- "version"
355
- ].includes(value);
356
- }
357
412
  async function commandInit(root, force) {
358
413
  clack.intro("agents init");
359
414
  const result = await initProject(root, { force });
@@ -394,6 +449,7 @@ async function commandRemove(root, paths) {
394
449
  }
395
450
  async function commandValidate(root, json) {
396
451
  const result = await validateAgentsFile(root);
452
+ if (!result.ok) process.exitCode = 1;
397
453
  if (json) {
398
454
  console.log(JSON.stringify(result, null, 2));
399
455
  return;
@@ -402,7 +458,6 @@ async function commandValidate(root, json) {
402
458
  if (result.errors.length > 0) clack.note(result.errors.join("\n"), "Errors");
403
459
  if (result.warnings.length > 0) clack.note(result.warnings.join("\n"), "Warnings");
404
460
  clack.outro(result.ok ? "agents.yaml is valid." : "agents.yaml needs attention.");
405
- if (!result.ok) process.exitCode = 1;
406
461
  }
407
462
  async function interactive(root) {
408
463
  clack.intro("agents");
@@ -451,8 +506,8 @@ async function interactive(root) {
451
506
  }
452
507
  await commandAdd(root, selected);
453
508
  }
454
- function chooseDocumentsToEnable(options) {
455
- return new MultiSelectPrompt({
509
+ async function chooseDocumentsToEnable(options) {
510
+ const selected = await new MultiSelectPrompt({
456
511
  options,
457
512
  required: false,
458
513
  render() {
@@ -474,6 +529,7 @@ ${styleText("cyan", clack.S_BAR_END)}
474
529
  `;
475
530
  }
476
531
  }).prompt();
532
+ return typeof selected === "symbol" ? clack.CANCEL_SYMBOL : selected;
477
533
  }
478
534
  function styleDocumentOption(option, state) {
479
535
  if (option.disabled) return `${styleText("gray", clack.S_CHECKBOX_INACTIVE)} ${styleText(["strikethrough", "gray"], option.label)}`;
@@ -482,7 +538,7 @@ function styleDocumentOption(option, state) {
482
538
  }
483
539
  //#endregion
484
540
  //#region src/index.ts
485
- run(process.argv.slice(2)).catch((error) => {
541
+ run(process.argv).catch((error) => {
486
542
  const message = error instanceof Error ? error.message : String(error);
487
543
  console.error(`agents: ${message}`);
488
544
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "agents.yaml",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "A CLI for discovering and curating agent-readable documentation in agents.yaml.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/jeremybanka/agents.yaml.git"
8
8
  },
9
+ "type": "module",
9
10
  "bin": {
10
11
  "agents": "./dist/index.mjs"
11
12
  },
@@ -15,20 +16,20 @@
15
16
  "README.md",
16
17
  "AGENTS.md"
17
18
  ],
18
- "type": "module",
19
19
  "dependencies": {
20
- "@clack/core": "1.4.1",
21
- "@clack/prompts": "1.5.1",
22
- "yaml": "2.9.0",
23
- "zod": "4.4.3"
20
+ "@clack/core": "1.5.1",
21
+ "@clack/prompts": "1.8.1",
22
+ "comline": "0.8.1",
23
+ "yaml": "2.9.1",
24
+ "zod": "4.6.5"
24
25
  },
25
26
  "devDependencies": {
26
- "@types/node": "25.9.2",
27
- "typescript": "6.0.3"
27
+ "@types/node": "26.6.1",
28
+ "typescript": "7.0.2"
28
29
  },
29
30
  "engines": {
30
- "node": "26.3.0",
31
- "pnpm": "11.5.2"
31
+ "node": "26.9.0",
32
+ "pnpm": "12.4.2"
32
33
  },
33
34
  "scripts": {
34
35
  "bench": "node src/discover.bench.ts",
package/src/cli.ts ADDED
@@ -0,0 +1,140 @@
1
+ import {
2
+ cli,
3
+ interpretArguments,
4
+ optional,
5
+ options,
6
+ parseBooleanOption,
7
+ required,
8
+ type CliOption,
9
+ } from "comline"
10
+ import { readFileSync } from "node:fs"
11
+ import { z } from "zod"
12
+ import { loadAgentsFile } from "./agents-file.ts"
13
+ import { cwd } from "./paths.ts"
14
+
15
+ function parseBooleanSwitch(value: string): boolean {
16
+ // Comline joins repeated occurrences with commas. The last switch wins.
17
+ const last = value.split(",").at(-1) ?? ""
18
+ if (!["", "true", "false", "0", "1"].includes(last)) {
19
+ throw new Error(
20
+ `Expected a boolean switch, received ${JSON.stringify(last)}`,
21
+ )
22
+ }
23
+ return parseBooleanOption(last)
24
+ }
25
+
26
+ function booleanSwitch(
27
+ description: string,
28
+ example: string,
29
+ ): CliOption<boolean> {
30
+ return { description, example, parse: parseBooleanSwitch, required: false }
31
+ }
32
+
33
+ const commonSchema = z.object({
34
+ help: z.boolean().default(false),
35
+ version: z.boolean().default(false),
36
+ })
37
+
38
+ const commonOptions = {
39
+ help: {
40
+ ...booleanSwitch("Show usage", "--help"),
41
+ flag: "h",
42
+ },
43
+ version: {
44
+ ...booleanSwitch("Show the installed version", "--version"),
45
+ flag: "v",
46
+ },
47
+ } satisfies Record<string, CliOption<boolean>>
48
+
49
+ const jsonOption = booleanSwitch("Print JSON", "--json")
50
+
51
+ function common(description: string) {
52
+ return options(description, commonSchema, commonOptions)
53
+ }
54
+
55
+ export const agents = cli({
56
+ cliName: "agents",
57
+ cliDescription:
58
+ "Discover and curate agent-readable documentation in agents.yaml.",
59
+ routes: optional({
60
+ init: null,
61
+ discover: null,
62
+ add: required({ "$...paths": null }),
63
+ remove: required({ "$...paths": null }),
64
+ validate: null,
65
+ help: null,
66
+ version: null,
67
+ }),
68
+ routeOptions: {
69
+ "": common("Choose an action interactively"),
70
+ init: options(
71
+ "Initialize breadcrumb files",
72
+ commonSchema.extend({ force: z.boolean().default(false) }),
73
+ {
74
+ ...commonOptions,
75
+ force: booleanSwitch(
76
+ "Append the breadcrumb even if already mentioned",
77
+ "--force",
78
+ ),
79
+ },
80
+ ),
81
+ discover: options(
82
+ "Discover supplemental AGENTS.md files",
83
+ commonSchema.extend({
84
+ json: z.boolean().default(false),
85
+ "include-dot-directories": z.boolean().default(false),
86
+ }),
87
+ {
88
+ ...commonOptions,
89
+ json: jsonOption,
90
+ "include-dot-directories": booleanSwitch(
91
+ "Search hidden directories",
92
+ "--include-dot-directories",
93
+ ),
94
+ },
95
+ ),
96
+ "add/$...paths": common("Promote one or more AGENTS.md files"),
97
+ "remove/$...paths": common("Remove one or more promoted paths"),
98
+ validate: options(
99
+ "Validate agents.yaml",
100
+ commonSchema.extend({ json: z.boolean().default(false) }),
101
+ { ...commonOptions, json: jsonOption },
102
+ ),
103
+ help: common("Show usage"),
104
+ version: common("Show the installed version"),
105
+ },
106
+ positionalCompletions: {
107
+ "add/$...paths": { fileSystem: "files" },
108
+ "remove/$...paths": {
109
+ provide: async () => {
110
+ const { documents } = await loadAgentsFile(cwd())
111
+ return documents.map(({ path, description }) => ({
112
+ value: path,
113
+ ...(description ? { description } : {}),
114
+ }))
115
+ },
116
+ },
117
+ },
118
+ })
119
+
120
+ export function earlyCommand(argv: string[]): "help" | "version" | undefined {
121
+ // Interpret before validation so `add --help` does not require a path.
122
+ // Using Comline's occurrences also respects grouped flags and literal `--`.
123
+ const { options: occurrences } = interpretArguments(
124
+ agents.definition,
125
+ argv.slice(2),
126
+ )
127
+ for (const key of ["help", "version"] as const) {
128
+ const occurrence = occurrences.findLast((option) => option.key === key)
129
+ if (occurrence && parseBooleanSwitch(occurrence.value)) return key
130
+ }
131
+ return undefined
132
+ }
133
+
134
+ export function packageVersion(): string {
135
+ const source = readFileSync(
136
+ new URL("../package.json", import.meta.url),
137
+ "utf8",
138
+ )
139
+ return z.object({ version: z.string() }).parse(JSON.parse(source)).version
140
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { run } from "./run.ts"
4
4
 
5
- run(process.argv.slice(2)).catch((error: unknown) => {
5
+ run(process.argv).catch((error: unknown) => {
6
6
  const message = error instanceof Error ? error.message : String(error)
7
7
  console.error(`agents: ${message}`)
8
8
  process.exitCode = 1
@@ -0,0 +1,226 @@
1
+ import * as clack from "@clack/prompts"
2
+ import { spawnSync } from "node:child_process"
3
+ import {
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ readdir,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises"
11
+ import { tmpdir } from "node:os"
12
+ import path from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
15
+ import { loadAgentsFile, saveAgentsFile } from "./agents-file.ts"
16
+ import { agents } from "./cli.ts"
17
+ import { run } from "./run.ts"
18
+
19
+ vi.mock("@clack/prompts", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("@clack/prompts")>()),
21
+ intro: vi.fn(),
22
+ select: vi.fn(),
23
+ cancel: vi.fn(),
24
+ }))
25
+
26
+ const entry = fileURLToPath(new URL("./index.ts", import.meta.url))
27
+ let root: string
28
+
29
+ beforeEach(async () => {
30
+ root = await mkdtemp(path.join(tmpdir(), "agents-cli-"))
31
+ })
32
+
33
+ afterEach(async () => {
34
+ vi.clearAllMocks()
35
+ await rm(root, { recursive: true, force: true })
36
+ })
37
+
38
+ function invoke(...args: string[]) {
39
+ const result = spawnSync(process.execPath, [entry, ...args], {
40
+ cwd: root,
41
+ encoding: "utf8",
42
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
43
+ timeout: 10_000,
44
+ })
45
+ if (result.error) throw result.error
46
+ return result
47
+ }
48
+
49
+ describe("agents command-line interface", () => {
50
+ it("keeps the no-command interactive flow and cancellation", async () => {
51
+ vi.mocked(clack.select).mockResolvedValueOnce(clack.CANCEL_SYMBOL)
52
+ await run([process.execPath, entry])
53
+ expect(clack.select).toHaveBeenCalledWith(
54
+ expect.objectContaining({ message: "What would you like to do?" }),
55
+ )
56
+ expect(clack.cancel).toHaveBeenCalledWith("Cancelled.")
57
+ })
58
+
59
+ it("adds and removes multiple paths without splitting special characters", async () => {
60
+ const paths = ["./space name/AGENTS.md", "./comma,equals=/AGENTS.md"]
61
+ for (const document of paths) {
62
+ await mkdir(path.dirname(path.join(root, document)), { recursive: true })
63
+ await writeFile(path.join(root, document), "# Instructions\n")
64
+ }
65
+
66
+ expect(invoke("init").status).toBe(0)
67
+ expect(invoke("add", ...paths).status).toBe(0)
68
+ const added = await loadAgentsFile(root)
69
+ expect(added.documents.map((document) => document.path).sort()).toEqual(
70
+ paths.toSorted(),
71
+ )
72
+ expect(invoke("remove", ...paths).status).toBe(0)
73
+ expect((await loadAgentsFile(root)).documents).toEqual([])
74
+ })
75
+
76
+ it("treats help and version spellings after -- as literal paths", async () => {
77
+ const paths = ["--help", "-v", "-dash/AGENTS.md"]
78
+ expect(invoke("add", "--", ...paths).status).toBe(0)
79
+ expect(
80
+ (await loadAgentsFile(root)).documents
81
+ .map((document) => document.path)
82
+ .sort(),
83
+ ).toEqual(paths.map((document) => `./${document}`).sort())
84
+ expect(invoke("remove", "--", ...paths).status).toBe(0)
85
+ expect((await loadAgentsFile(root)).documents).toEqual([])
86
+ })
87
+
88
+ it.each([
89
+ ["add"],
90
+ ["remove"],
91
+ ["discovr"],
92
+ ["--unknown"],
93
+ ["init", "--json"],
94
+ ["init", "--force=maybe"],
95
+ ["add", "./AGENTS.md", "--typo"],
96
+ ["validate", "--json", "--force"],
97
+ ])(
98
+ "rejects invalid usage without executing a command: %j",
99
+ async (...args) => {
100
+ const result = invoke(...args)
101
+ expect(result.status).toBe(1)
102
+ expect(result.stdout).toBe("")
103
+ expect(result.stderr).toContain("agents:")
104
+ expect(await readdir(root)).toEqual([])
105
+ },
106
+ )
107
+
108
+ it.each([
109
+ ["help"],
110
+ ["--help"],
111
+ ["-h"],
112
+ ["add", "--help"],
113
+ ["remove", "-h"],
114
+ ["add", "-vh"],
115
+ ])("shows generated help without requiring paths: %j", async (...args) => {
116
+ // Help must work even in a project with an invalid document index.
117
+ await writeFile(path.join(root, "agents.yaml"), "not: [valid yaml")
118
+ const result = invoke(...args)
119
+ expect(result.status).toBe(0)
120
+ expect(result.stderr).toBe("")
121
+ expect(result.stdout).toContain("add <paths...>")
122
+ expect(result.stdout).toContain("remove <paths...>")
123
+ expect(result.stdout).toContain("--include-dot-directories")
124
+ })
125
+
126
+ it.each([["version"], ["--version"], ["-v"], ["add", "--version"]])(
127
+ "prints the package version: %j",
128
+ async (...args) => {
129
+ const manifest = JSON.parse(
130
+ await readFile(new URL("../package.json", import.meta.url), "utf8"),
131
+ ) as { version: string }
132
+ const result = invoke(...args)
133
+ expect(result.status).toBe(0)
134
+ expect(result.stderr).toBe("")
135
+ expect(result.stdout).toBe(`${manifest.version}\n`)
136
+ },
137
+ )
138
+
139
+ it("honors explicit booleans and the last repeated switch", async () => {
140
+ expect(invoke("init").status).toBe(0)
141
+ const breadcrumb = await readFile(path.join(root, "AGENTS.md"), "utf8")
142
+ expect(
143
+ invoke("init", "--force", "--force=false", "--force=false").status,
144
+ ).toBe(0)
145
+ expect(await readFile(path.join(root, "AGENTS.md"), "utf8")).toBe(
146
+ breadcrumb,
147
+ )
148
+ expect(invoke("init", "--force=false", "--force").status).toBe(0)
149
+ expect(
150
+ (await readFile(path.join(root, "AGENTS.md"), "utf8")).match(/Consult/g),
151
+ ).toHaveLength(2)
152
+ })
153
+
154
+ it("discovers hidden directories on request and keeps JSON output clean", async () => {
155
+ await mkdir(path.join(root, ".hidden"))
156
+ await writeFile(
157
+ path.join(root, ".hidden", "AGENTS.md"),
158
+ "# Hidden instructions\n",
159
+ )
160
+ const ordinary = invoke("discover", "--json")
161
+ expect(ordinary.status).toBe(0)
162
+ expect(JSON.parse(ordinary.stdout)).toEqual([])
163
+ const hidden = invoke("--json", "discover", "--include-dot-directories")
164
+ expect(hidden.status).toBe(0)
165
+ expect(hidden.stderr).toBe("")
166
+ expect(JSON.parse(hidden.stdout)).toEqual([{ path: "./.hidden/AGENTS.md" }])
167
+ })
168
+
169
+ it("sets the validation exit status in both text and JSON modes", async () => {
170
+ expect(invoke("init").status).toBe(0)
171
+ const valid = invoke("validate", "--json")
172
+ expect(valid.status).toBe(0)
173
+ expect(JSON.parse(valid.stdout)).toMatchObject({ ok: true, errors: [] })
174
+ await saveAgentsFile(root, {
175
+ version: 1,
176
+ documents: [{ path: "./missing/AGENTS.md" }],
177
+ })
178
+ const invalid = invoke("validate", "--json")
179
+ expect(invalid.status).toBe(1)
180
+ expect(invalid.stderr).toBe("")
181
+ expect(JSON.parse(invalid.stdout)).toMatchObject({
182
+ ok: false,
183
+ errors: [expect.stringContaining("file does not exist")],
184
+ })
185
+ expect(invoke("validate").status).toBe(1)
186
+ })
187
+
188
+ it("generates completion scripts without loading the document index or changing files", async () => {
189
+ await writeFile(path.join(root, "agents.yaml"), "not: [valid yaml")
190
+ const result = invoke("completion", "bash")
191
+ expect(result.status).toBe(0)
192
+ expect(result.stderr).toBe("")
193
+ expect(result.stdout).toContain("_comline")
194
+ expect(result.stdout).not.toContain("What would you like to do?")
195
+ expect(await readdir(root)).toEqual(["agents.yaml"])
196
+ expect(await readFile(path.join(root, "agents.yaml"), "utf8")).toBe(
197
+ "not: [valid yaml",
198
+ )
199
+ })
200
+
201
+ it("completes listed paths after each remove argument without removing documents", async () => {
202
+ const file = {
203
+ version: 1 as const,
204
+ documents: [
205
+ { path: "./one/AGENTS.md" },
206
+ { path: "./two/AGENTS.md", description: "Second document" },
207
+ ],
208
+ }
209
+ await saveAgentsFile(root, file)
210
+ const result = invoke("__complete", "remove", "./one/AGENTS.md", "./t")
211
+ expect(result.status).toBe(0)
212
+ expect(result.stderr).toBe("")
213
+ expect(result.stdout).toContain("./two/AGENTS.md\tSecond document")
214
+ expect(await loadAgentsFile(root)).toEqual(file)
215
+ })
216
+
217
+ it("offers filesystem completion for every add argument", async () => {
218
+ for (const words of [
219
+ ["add", ""],
220
+ ["add", "./one/AGENTS.md", ""],
221
+ ]) {
222
+ const result = await agents.complete({ words })
223
+ expect(result.fileSystem).toBe("files")
224
+ }
225
+ })
226
+ })
package/src/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { MultiSelectPrompt } from "@clack/core"
2
2
  import * as clack from "@clack/prompts"
3
+ import { completionResponse, help } from "comline"
3
4
  import { styleText } from "node:util"
4
5
  import {
5
6
  addDocuments,
@@ -8,132 +9,70 @@ import {
8
9
  removeDocuments,
9
10
  validateAgentsFile,
10
11
  } from "./agents-file.ts"
12
+ import { agents, earlyCommand, packageVersion } from "./cli.ts"
11
13
  import { describeAgentDocument, discoverAgentDocuments } from "./discover.ts"
12
14
  import { cwd, formatProjectPath, resolveFromRoot } from "./paths.ts"
13
15
 
14
- type Command =
15
- | "add"
16
- | "discover"
17
- | "help"
18
- | "init"
19
- | "remove"
20
- | "validate"
21
- | "version"
22
-
23
- type ParsedArgs = {
24
- command: Command | undefined
25
- values: string[]
26
- flags: Map<string, string | boolean>
27
- }
28
-
29
16
  type DocumentOption = {
30
17
  value: string
31
18
  label: string
32
19
  disabled?: boolean
33
20
  }
34
21
 
35
- const helpText = `agents
36
-
37
- Usage:
38
- agents
39
- agents init [--force]
40
- agents discover [--json] [--include-dot-directories]
41
- agents add <path...>
42
- agents remove <path...>
43
- agents validate [--json]
22
+ export async function run(argv: string[]): Promise<void> {
23
+ const completion = await completionResponse(agents.definition, argv)
24
+ if (completion !== undefined) {
25
+ process.stdout.write(completion)
26
+ return
27
+ }
44
28
 
45
- agents.yaml is a curated table of contents for promoted AGENTS.md guidance.`
29
+ const early = earlyCommand(argv)
30
+ if (early === "help") {
31
+ console.log(help(agents.definition))
32
+ return
33
+ }
34
+ if (early === "version") {
35
+ console.log(packageVersion())
36
+ return
37
+ }
46
38
 
47
- export async function run(argv: string[]): Promise<void> {
48
- const parsed = parseArgs(argv)
39
+ const { inputs, warnings } = agents(argv)
40
+ if (warnings.length > 0) {
41
+ throw new Error(warnings.map((warning) => warning.message).join("\n"))
42
+ }
49
43
  const root = cwd()
50
44
 
51
- switch (parsed.command) {
52
- case undefined:
45
+ switch (inputs.case) {
46
+ case "":
53
47
  await interactive(root)
54
48
  return
55
49
  case "help":
56
- console.log(helpText)
50
+ console.log(help(agents.definition))
57
51
  return
58
52
  case "version":
59
- console.log("0.1.0")
53
+ console.log(packageVersion())
60
54
  return
61
55
  case "init":
62
- await commandInit(root, parsed.flags.get("force") === true)
56
+ await commandInit(root, inputs.opts.force)
63
57
  return
64
58
  case "discover":
65
59
  await commandDiscover(root, {
66
- json: parsed.flags.get("json") === true,
67
- includeDotDirectories:
68
- parsed.flags.get("include-dot-directories") === true,
60
+ json: inputs.opts.json,
61
+ includeDotDirectories: inputs.opts["include-dot-directories"],
69
62
  })
70
63
  return
71
- case "add":
72
- await commandAdd(root, parsed.values)
64
+ case "add/$...paths":
65
+ await commandAdd(root, inputs.params.paths)
73
66
  return
74
- case "remove":
75
- await commandRemove(root, parsed.values)
67
+ case "remove/$...paths":
68
+ await commandRemove(root, inputs.params.paths)
76
69
  return
77
70
  case "validate":
78
- await commandValidate(root, parsed.flags.get("json") === true)
71
+ await commandValidate(root, inputs.opts.json)
79
72
  return
80
73
  }
81
74
  }
82
75
 
83
- function parseArgs(argv: string[]): ParsedArgs {
84
- const flags = new Map<string, string | boolean>()
85
- const values: string[] = []
86
- let command: Command | undefined
87
-
88
- for (let index = 0; index < argv.length; index += 1) {
89
- const arg = argv[index]
90
- if (!arg) continue
91
-
92
- if (arg === "--help" || arg === "-h") {
93
- command = "help"
94
- continue
95
- }
96
-
97
- if (arg === "--version" || arg === "-v") {
98
- command = "version"
99
- continue
100
- }
101
-
102
- if (arg.startsWith("--")) {
103
- const [rawName, inlineValue] = arg.slice(2).split("=", 2)
104
- if (!rawName) continue
105
- if (inlineValue !== undefined) {
106
- flags.set(rawName, inlineValue)
107
- continue
108
- }
109
-
110
- flags.set(rawName, true)
111
- continue
112
- }
113
-
114
- if (!command && isCommand(arg)) {
115
- command = arg
116
- continue
117
- }
118
-
119
- values.push(arg)
120
- }
121
-
122
- return { command, values, flags }
123
- }
124
-
125
- function isCommand(value: string): value is Command {
126
- return [
127
- "add",
128
- "discover",
129
- "help",
130
- "init",
131
- "remove",
132
- "validate",
133
- "version",
134
- ].includes(value)
135
- }
136
-
137
76
  async function commandInit(root: string, force: boolean): Promise<void> {
138
77
  clack.intro("agents init")
139
78
  const result = await initProject(root, { force })
@@ -216,6 +155,9 @@ async function commandRemove(root: string, paths: string[]): Promise<void> {
216
155
 
217
156
  async function commandValidate(root: string, json: boolean): Promise<void> {
218
157
  const result = await validateAgentsFile(root)
158
+ if (!result.ok) {
159
+ process.exitCode = 1
160
+ }
219
161
  if (json) {
220
162
  console.log(JSON.stringify(result, null, 2))
221
163
  return
@@ -232,9 +174,6 @@ async function commandValidate(root: string, json: boolean): Promise<void> {
232
174
  clack.outro(
233
175
  result.ok ? "agents.yaml is valid." : "agents.yaml needs attention.",
234
176
  )
235
- if (!result.ok) {
236
- process.exitCode = 1
237
- }
238
177
  }
239
178
 
240
179
  async function interactive(root: string): Promise<void> {
@@ -290,10 +229,10 @@ async function interactive(root: string): Promise<void> {
290
229
  await commandAdd(root, selected)
291
230
  }
292
231
 
293
- function chooseDocumentsToEnable(
232
+ async function chooseDocumentsToEnable(
294
233
  options: DocumentOption[],
295
- ): Promise<string[] | symbol | undefined> {
296
- return new MultiSelectPrompt<DocumentOption>({
234
+ ): Promise<string[] | typeof clack.CANCEL_SYMBOL | undefined> {
235
+ const selected = await new MultiSelectPrompt<DocumentOption>({
297
236
  options,
298
237
  required: false,
299
238
  render() {
@@ -317,6 +256,8 @@ ${styleText("cyan", clack.S_BAR_END)}
317
256
  `
318
257
  },
319
258
  }).prompt()
259
+
260
+ return typeof selected === "symbol" ? clack.CANCEL_SYMBOL : selected
320
261
  }
321
262
 
322
263
  function styleDocumentOption(
package/dist/index.d.mts DELETED
@@ -1 +0,0 @@
1
- export { };