agents.yaml 0.2.3 → 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();
@@ -164,6 +166,102 @@ function isNotFound(error) {
164
166
  return error instanceof Error && "code" in error && error.code === "ENOENT";
165
167
  }
166
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
167
265
  //#region src/discover.ts
168
266
  const skippedDirectories = /* @__PURE__ */ new Set([
169
267
  ".git",
@@ -263,98 +361,54 @@ function isPackageJson(value) {
263
361
  }
264
362
  //#endregion
265
363
  //#region src/run.ts
266
- const helpText = `agents
267
-
268
- Usage:
269
- agents
270
- agents init [--force]
271
- agents discover [--json] [--include-dot-directories]
272
- agents add <path...>
273
- agents remove <path...>
274
- agents validate [--json]
275
-
276
- agents.yaml is a curated table of contents for promoted AGENTS.md guidance.`;
277
364
  async function run(argv) {
278
- 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"));
279
381
  const root = cwd();
280
- switch (parsed.command) {
281
- case void 0:
382
+ switch (inputs.case) {
383
+ case "":
282
384
  await interactive(root);
283
385
  return;
284
386
  case "help":
285
- console.log(helpText);
387
+ console.log(help(agents.definition));
286
388
  return;
287
389
  case "version":
288
- console.log("0.1.0");
390
+ console.log(packageVersion());
289
391
  return;
290
392
  case "init":
291
- await commandInit(root, parsed.flags.get("force") === true);
393
+ await commandInit(root, inputs.opts.force);
292
394
  return;
293
395
  case "discover":
294
396
  await commandDiscover(root, {
295
- json: parsed.flags.get("json") === true,
296
- includeDotDirectories: parsed.flags.get("include-dot-directories") === true
397
+ json: inputs.opts.json,
398
+ includeDotDirectories: inputs.opts["include-dot-directories"]
297
399
  });
298
400
  return;
299
- case "add":
300
- await commandAdd(root, parsed.values);
401
+ case "add/$...paths":
402
+ await commandAdd(root, inputs.params.paths);
301
403
  return;
302
- case "remove":
303
- await commandRemove(root, parsed.values);
404
+ case "remove/$...paths":
405
+ await commandRemove(root, inputs.params.paths);
304
406
  return;
305
407
  case "validate":
306
- await commandValidate(root, parsed.flags.get("json") === true);
408
+ await commandValidate(root, inputs.opts.json);
307
409
  return;
308
410
  }
309
411
  }
310
- function parseArgs(argv) {
311
- const flags = /* @__PURE__ */ new Map();
312
- const values = [];
313
- let command;
314
- for (let index = 0; index < argv.length; index += 1) {
315
- const arg = argv[index];
316
- if (!arg) continue;
317
- if (arg === "--help" || arg === "-h") {
318
- command = "help";
319
- continue;
320
- }
321
- if (arg === "--version" || arg === "-v") {
322
- command = "version";
323
- continue;
324
- }
325
- if (arg.startsWith("--")) {
326
- const [rawName, inlineValue] = arg.slice(2).split("=", 2);
327
- if (!rawName) continue;
328
- if (inlineValue !== void 0) {
329
- flags.set(rawName, inlineValue);
330
- continue;
331
- }
332
- flags.set(rawName, true);
333
- continue;
334
- }
335
- if (!command && isCommand(arg)) {
336
- command = arg;
337
- continue;
338
- }
339
- values.push(arg);
340
- }
341
- return {
342
- command,
343
- values,
344
- flags
345
- };
346
- }
347
- function isCommand(value) {
348
- return [
349
- "add",
350
- "discover",
351
- "help",
352
- "init",
353
- "remove",
354
- "validate",
355
- "version"
356
- ].includes(value);
357
- }
358
412
  async function commandInit(root, force) {
359
413
  clack.intro("agents init");
360
414
  const result = await initProject(root, { force });
@@ -395,6 +449,7 @@ async function commandRemove(root, paths) {
395
449
  }
396
450
  async function commandValidate(root, json) {
397
451
  const result = await validateAgentsFile(root);
452
+ if (!result.ok) process.exitCode = 1;
398
453
  if (json) {
399
454
  console.log(JSON.stringify(result, null, 2));
400
455
  return;
@@ -403,7 +458,6 @@ async function commandValidate(root, json) {
403
458
  if (result.errors.length > 0) clack.note(result.errors.join("\n"), "Errors");
404
459
  if (result.warnings.length > 0) clack.note(result.warnings.join("\n"), "Warnings");
405
460
  clack.outro(result.ok ? "agents.yaml is valid." : "agents.yaml needs attention.");
406
- if (!result.ok) process.exitCode = 1;
407
461
  }
408
462
  async function interactive(root) {
409
463
  clack.intro("agents");
@@ -484,7 +538,7 @@ function styleDocumentOption(option, state) {
484
538
  }
485
539
  //#endregion
486
540
  //#region src/index.ts
487
- run(process.argv.slice(2)).catch((error) => {
541
+ run(process.argv).catch((error) => {
488
542
  const message = error instanceof Error ? error.message : String(error);
489
543
  console.error(`agents: ${message}`);
490
544
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "agents.yaml",
3
- "version": "0.2.3",
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
20
  "@clack/core": "1.5.1",
21
21
  "@clack/prompts": "1.8.1",
22
+ "comline": "0.8.1",
22
23
  "yaml": "2.9.1",
23
24
  "zod": "4.6.5"
24
25
  },
25
26
  "devDependencies": {
26
- "@types/node": "25.9.6",
27
+ "@types/node": "26.6.1",
27
28
  "typescript": "7.0.2"
28
29
  },
29
30
  "engines": {
30
- "node": "26.8.2",
31
- "pnpm": "12.4.1"
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> {
package/dist/index.d.mts DELETED
@@ -1 +0,0 @@
1
- export {}