agents.yaml 0.2.0 → 0.2.2

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
@@ -19,3 +19,5 @@ Agents should treat paths listed in `documents` as promoted supplemental guidanc
19
19
  The CLI can help discover package and local `AGENTS.md` files, add selected paths to `agents.yaml`, remove paths, initialize the root breadcrumb, and validate that referenced files still exist.
20
20
 
21
21
  Discovery only considers direct dependencies under a project's `node_modules`; nested dependency `AGENTS.md` files are not automatically activated.
22
+
23
+ Discovery skips dot-prefixed directories by default. Use `agents discover --include-dot-directories` when hidden project directories should be scanned too.
package/README.md CHANGED
@@ -17,12 +17,30 @@ pnpm run build
17
17
  ```sh
18
18
  agents init
19
19
  agents discover
20
+ agents discover --include-dot-directories
20
21
  agents add ./node_modules/react/AGENTS.md
21
22
  agents validate
22
23
  ```
23
24
 
24
25
  Run `agents` with no command for the interactive flow.
25
26
 
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.
30
+
31
+ ## Benchmark
32
+
33
+ ```sh
34
+ pnpm --filter agents.yaml bench
35
+ ```
36
+
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`.
43
+
26
44
  ## File Format
27
45
 
28
46
  ```yaml
package/dist/index.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { MultiSelectPrompt } from "@clack/core";
2
3
  import * as clack from "@clack/prompts";
4
+ import { styleText } from "node:util";
3
5
  import { access, opendir, readFile, writeFile } from "node:fs/promises";
4
6
  import path from "node:path";
5
7
  import * as YAML from "yaml";
@@ -13,8 +15,8 @@ function resolveFromRoot(root, input) {
13
15
  }
14
16
  function formatProjectPath(root, target) {
15
17
  const relative = path.relative(root, target).split(path.sep).join(path.posix.sep);
16
- if (relative.startsWith("..")) return target;
17
- return relative.startsWith(".") ? relative : `./${relative}`;
18
+ if (relative === ".." || relative.startsWith("../")) return target;
19
+ return relative.startsWith("./") ? relative : `./${relative}`;
18
20
  }
19
21
  //#endregion
20
22
  //#region src/agents-file.ts
@@ -172,16 +174,16 @@ const skippedDirectories = new Set([
172
174
  "dist",
173
175
  "build"
174
176
  ]);
175
- async function discoverAgentDocuments(root) {
177
+ async function discoverAgentDocuments(root, options = {}) {
176
178
  const found = [];
177
- await walk(root, root, found);
179
+ await walk(root, root, found, options);
178
180
  return found.filter((document) => document.path !== "./AGENTS.md").sort((left, right) => left.path.localeCompare(right.path));
179
181
  }
180
182
  async function describeAgentDocument(root, agentsDocumentPath) {
181
183
  const absolutePath = resolveFromRoot(root, agentsDocumentPath);
182
184
  return documentEntry(root, absolutePath, await readPackageDescription(path.dirname(absolutePath)));
183
185
  }
184
- async function walk(root, directory, found) {
186
+ async function walk(root, directory, found, options) {
185
187
  let handle;
186
188
  try {
187
189
  handle = await opendir(directory);
@@ -195,12 +197,16 @@ async function walk(root, directory, found) {
195
197
  await scanDirectNodeModules(root, absolutePath, found);
196
198
  continue;
197
199
  }
198
- if (!skippedDirectories.has(entry.name)) await walk(root, absolutePath, found);
200
+ if (!shouldSkipDirectory(entry.name, options)) await walk(root, absolutePath, found, options);
199
201
  continue;
200
202
  }
201
203
  if (entry.isFile() && entry.name === "AGENTS.md") found.push(await documentEntry(root, absolutePath, await readPackageDescription(path.dirname(absolutePath))));
202
204
  }
203
205
  }
206
+ function shouldSkipDirectory(name, options) {
207
+ if (skippedDirectories.has(name)) return true;
208
+ return !options.includeDotDirectories && name.startsWith(".");
209
+ }
204
210
  async function scanDirectNodeModules(root, nodeModulesPath, found) {
205
211
  let handle;
206
212
  try {
@@ -261,7 +267,7 @@ const helpText = `agents
261
267
  Usage:
262
268
  agents
263
269
  agents init [--force]
264
- agents discover [--json]
270
+ agents discover [--json] [--include-dot-directories]
265
271
  agents add <path...>
266
272
  agents remove <path...>
267
273
  agents validate [--json]
@@ -284,7 +290,10 @@ async function run(argv) {
284
290
  await commandInit(root, parsed.flags.get("force") === true);
285
291
  return;
286
292
  case "discover":
287
- await commandDiscover(root, parsed.flags.get("json") === true);
293
+ await commandDiscover(root, {
294
+ json: parsed.flags.get("json") === true,
295
+ includeDotDirectories: parsed.flags.get("include-dot-directories") === true
296
+ });
288
297
  return;
289
298
  case "add":
290
299
  await commandAdd(root, parsed.values);
@@ -351,9 +360,9 @@ async function commandInit(root, force) {
351
360
  clack.note(result.messages.join("\n"), "Updated");
352
361
  clack.outro("Project breadcrumb is ready.");
353
362
  }
354
- async function commandDiscover(root, json) {
355
- const documents = await discoverAgentDocuments(root);
356
- if (json) {
363
+ async function commandDiscover(root, options) {
364
+ const documents = await discoverAgentDocuments(root, { includeDotDirectories: options.includeDotDirectories });
365
+ if (options.json) {
357
366
  console.log(JSON.stringify(documents, null, 2));
358
367
  return;
359
368
  }
@@ -432,20 +441,45 @@ async function interactive(root) {
432
441
  clack.outro("No unlisted supplemental AGENTS.md files found.");
433
442
  return;
434
443
  }
435
- const selected = await clack.multiselect({
436
- message: "Choose documents to enable",
437
- options: candidates.map((doc) => ({
438
- value: doc.path,
439
- label: doc.path
440
- })),
441
- required: false
442
- });
443
- if (clack.isCancel(selected) || selected.length === 0) {
444
+ const selected = await chooseDocumentsToEnable(candidates.map((doc) => ({
445
+ value: doc.path,
446
+ label: doc.path
447
+ })));
448
+ if (clack.isCancel(selected) || selected === void 0 || selected.length === 0) {
444
449
  clack.cancel("No documents selected.");
445
450
  return;
446
451
  }
447
452
  await commandAdd(root, selected);
448
453
  }
454
+ function chooseDocumentsToEnable(options) {
455
+ return new MultiSelectPrompt({
456
+ options,
457
+ required: false,
458
+ render() {
459
+ const prefix = `${styleText("cyan", clack.S_BAR)} `;
460
+ const selected = this.value ?? [];
461
+ return `${styleText("gray", clack.S_BAR)}
462
+ ${clack.symbol(this.state)} Choose documents to enable
463
+ ${prefix}${clack.limitOptions({
464
+ options: this.options,
465
+ cursor: this.cursor,
466
+ columnPadding: prefix.length,
467
+ style: (option, active) => styleDocumentOption(option, {
468
+ active,
469
+ selected: selected.includes(option.value)
470
+ })
471
+ }).join(`
472
+ ${prefix}`)}
473
+ ${styleText("cyan", clack.S_BAR_END)}
474
+ `;
475
+ }
476
+ }).prompt();
477
+ }
478
+ function styleDocumentOption(option, state) {
479
+ if (option.disabled) return `${styleText("gray", clack.S_CHECKBOX_INACTIVE)} ${styleText(["strikethrough", "gray"], option.label)}`;
480
+ const checkbox = state.selected ? styleText("green", clack.S_CHECKBOX_SELECTED) : state.active ? styleText("cyan", clack.S_CHECKBOX_ACTIVE) : styleText("dim", clack.S_CHECKBOX_INACTIVE);
481
+ return `${state.active ? styleText("cyan", ">") : " "} ${checkbox} ${state.active ? option.label : styleText("dim", option.label)}`;
482
+ }
449
483
  //#endregion
450
484
  //#region src/index.ts
451
485
  run(process.argv.slice(2)).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents.yaml",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A CLI for discovering and curating agent-readable documentation in agents.yaml.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  ],
18
18
  "type": "module",
19
19
  "dependencies": {
20
+ "@clack/core": "1.4.1",
20
21
  "@clack/prompts": "1.5.1",
21
22
  "yaml": "2.9.0",
22
23
  "zod": "4.4.3"
@@ -30,6 +31,7 @@
30
31
  "pnpm": "11.5.2"
31
32
  },
32
33
  "scripts": {
34
+ "bench": "node src/discover.bench.ts",
33
35
  "build": "vp pack",
34
36
  "dev": "src/index.ts",
35
37
  "test": "vp test"
@@ -1,209 +1,224 @@
1
- import { access, readFile, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
- import * as YAML from 'yaml'
4
- import { z } from 'zod'
5
- import { resolveFromRoot } from './paths.ts'
1
+ import { access, readFile, writeFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import * as YAML from "yaml"
4
+ import { z } from "zod"
5
+ import { resolveFromRoot } from "./paths.ts"
6
6
 
7
7
  export type AgentsDocumentEntry = {
8
- path: string
9
- description?: string | undefined
8
+ path: string
9
+ description?: string | undefined
10
10
  }
11
11
 
12
12
  export type AgentsFile = {
13
- version: 1
14
- documents: AgentsDocumentEntry[]
13
+ version: 1
14
+ documents: AgentsDocumentEntry[]
15
15
  }
16
16
 
17
17
  export type ValidationResult = {
18
- ok: boolean
19
- errors: string[]
20
- warnings: string[]
18
+ ok: boolean
19
+ errors: string[]
20
+ warnings: string[]
21
21
  }
22
22
 
23
23
  const fileSchema = z.object({
24
- version: z.literal(1),
25
- documents: z.array(
26
- z.object({
27
- path: z.string().min(1),
28
- description: z.string().min(1).optional(),
29
- }),
30
- ),
24
+ version: z.literal(1),
25
+ documents: z.array(
26
+ z.object({
27
+ path: z.string().min(1),
28
+ description: z.string().min(1).optional(),
29
+ }),
30
+ ),
31
31
  })
32
32
 
33
33
  const breadcrumb = `Consult \`./agents.yaml\` when working with outside dependencies.`
34
34
 
35
35
  export async function loadAgentsFile(root: string): Promise<AgentsFile> {
36
- const filePath = agentsPath(root)
37
-
38
- try {
39
- const source = await readFile(filePath, 'utf8')
40
- const parsed = YAML.parse(source) as unknown
41
- return fileSchema.parse(parsed)
42
- } catch (error) {
43
- if (isNotFound(error)) {
44
- return { version: 1, documents: [] }
45
- }
46
-
47
- if (error instanceof z.ZodError) {
48
- throw new Error(
49
- `Invalid agents.yaml: ${error.issues.map((issue) => issue.message).join(', ')}`,
50
- )
51
- }
52
-
53
- throw error
54
- }
36
+ const filePath = agentsPath(root)
37
+
38
+ try {
39
+ const source = await readFile(filePath, "utf8")
40
+ const parsed = YAML.parse(source) as unknown
41
+ return fileSchema.parse(parsed)
42
+ } catch (error) {
43
+ if (isNotFound(error)) {
44
+ return { version: 1, documents: [] }
45
+ }
46
+
47
+ if (error instanceof z.ZodError) {
48
+ throw new Error(
49
+ `Invalid agents.yaml: ${error.issues.map((issue) => issue.message).join(", ")}`,
50
+ )
51
+ }
52
+
53
+ throw error
54
+ }
55
55
  }
56
56
 
57
- export async function saveAgentsFile(root: string, file: AgentsFile): Promise<void> {
58
- const normalized = {
59
- version: file.version,
60
- documents: file.documents.map((doc) => ({
61
- path: doc.path,
62
- ...(doc.description ? { description: doc.description } : {}),
63
- })),
64
- }
65
-
66
- await writeFile(agentsPath(root), YAML.stringify(normalized, { lineWidth: 0 }), 'utf8')
57
+ export async function saveAgentsFile(
58
+ root: string,
59
+ file: AgentsFile,
60
+ ): Promise<void> {
61
+ const normalized = {
62
+ version: file.version,
63
+ documents: file.documents.map((doc) => ({
64
+ path: doc.path,
65
+ ...(doc.description ? { description: doc.description } : {}),
66
+ })),
67
+ }
68
+
69
+ await writeFile(
70
+ agentsPath(root),
71
+ YAML.stringify(normalized, { lineWidth: 0 }),
72
+ "utf8",
73
+ )
67
74
  }
68
75
 
69
76
  export async function addDocuments(
70
- root: string,
71
- documents: AgentsDocumentEntry[],
77
+ root: string,
78
+ documents: AgentsDocumentEntry[],
72
79
  ): Promise<AgentsFile> {
73
- const file = await loadAgentsFile(root)
74
- const byPath = new Map(file.documents.map((doc) => [doc.path, doc]))
75
-
76
- for (const document of documents) {
77
- const existing = byPath.get(document.path)
78
- byPath.set(document.path, {
79
- path: document.path,
80
- ...(existing?.description ? { description: existing.description } : {}),
81
- ...(document.description ? { description: document.description } : {}),
82
- })
83
- }
84
-
85
- const next = {
86
- version: 1 as const,
87
- documents: [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)),
88
- }
89
-
90
- await saveAgentsFile(root, next)
91
- return next
80
+ const file = await loadAgentsFile(root)
81
+ const byPath = new Map(file.documents.map((doc) => [doc.path, doc]))
82
+
83
+ for (const document of documents) {
84
+ const existing = byPath.get(document.path)
85
+ byPath.set(document.path, {
86
+ path: document.path,
87
+ ...(existing?.description ? { description: existing.description } : {}),
88
+ ...(document.description ? { description: document.description } : {}),
89
+ })
90
+ }
91
+
92
+ const next = {
93
+ version: 1 as const,
94
+ documents: [...byPath.values()].sort((left, right) =>
95
+ left.path.localeCompare(right.path),
96
+ ),
97
+ }
98
+
99
+ await saveAgentsFile(root, next)
100
+ return next
92
101
  }
93
102
 
94
103
  export async function removeDocuments(
95
- root: string,
96
- paths: string[],
104
+ root: string,
105
+ paths: string[],
97
106
  ): Promise<{ file: AgentsFile; removed: string[] }> {
98
- const file = await loadAgentsFile(root)
99
- const pathSet = new Set(paths)
100
- const removed: string[] = []
101
- const documents = file.documents.filter((doc) => {
102
- if (pathSet.has(doc.path)) {
103
- removed.push(doc.path)
104
- return false
105
- }
106
-
107
- return true
108
- })
109
-
110
- const next = { version: 1 as const, documents }
111
- await saveAgentsFile(root, next)
112
- return { file: next, removed }
107
+ const file = await loadAgentsFile(root)
108
+ const pathSet = new Set(paths)
109
+ const removed: string[] = []
110
+ const documents = file.documents.filter((doc) => {
111
+ if (pathSet.has(doc.path)) {
112
+ removed.push(doc.path)
113
+ return false
114
+ }
115
+
116
+ return true
117
+ })
118
+
119
+ const next = { version: 1 as const, documents }
120
+ await saveAgentsFile(root, next)
121
+ return { file: next, removed }
113
122
  }
114
123
 
115
- export async function validateAgentsFile(root: string): Promise<ValidationResult> {
116
- const errors: string[] = []
117
- const warnings: string[] = []
118
- let file: AgentsFile
119
-
120
- try {
121
- file = await loadAgentsFile(root)
122
- } catch (error) {
123
- return {
124
- ok: false,
125
- errors: [error instanceof Error ? error.message : String(error)],
126
- warnings,
127
- }
128
- }
129
-
130
- const seen = new Set<string>()
131
- for (const [index, document] of file.documents.entries()) {
132
- const label = `documents[${index}] ${document.path}`
133
-
134
- if (seen.has(document.path)) {
135
- errors.push(`${label}: duplicate path`)
136
- }
137
- seen.add(document.path)
138
-
139
- if (path.basename(document.path) !== 'AGENTS.md') {
140
- warnings.push(`${label}: path does not end with AGENTS.md`)
141
- }
142
-
143
- try {
144
- await access(resolveFromRoot(root, document.path))
145
- } catch {
146
- errors.push(`${label}: file does not exist`)
147
- }
148
- }
149
-
150
- try {
151
- const source = await readFile(path.join(root, 'AGENTS.md'), 'utf8')
152
- if (!source.includes('./agents.yaml') && !source.includes('agents.yaml')) {
153
- warnings.push('AGENTS.md does not mention agents.yaml')
154
- }
155
- } catch {
156
- warnings.push('AGENTS.md is missing the agents.yaml breadcrumb')
157
- }
158
-
159
- return {
160
- ok: errors.length === 0,
161
- errors,
162
- warnings,
163
- }
124
+ export async function validateAgentsFile(
125
+ root: string,
126
+ ): Promise<ValidationResult> {
127
+ const errors: string[] = []
128
+ const warnings: string[] = []
129
+ let file: AgentsFile
130
+
131
+ try {
132
+ file = await loadAgentsFile(root)
133
+ } catch (error) {
134
+ return {
135
+ ok: false,
136
+ errors: [error instanceof Error ? error.message : String(error)],
137
+ warnings,
138
+ }
139
+ }
140
+
141
+ const seen = new Set<string>()
142
+ for (const [index, document] of file.documents.entries()) {
143
+ const label = `documents[${index}] ${document.path}`
144
+
145
+ if (seen.has(document.path)) {
146
+ errors.push(`${label}: duplicate path`)
147
+ }
148
+ seen.add(document.path)
149
+
150
+ if (path.basename(document.path) !== "AGENTS.md") {
151
+ warnings.push(`${label}: path does not end with AGENTS.md`)
152
+ }
153
+
154
+ try {
155
+ await access(resolveFromRoot(root, document.path))
156
+ } catch {
157
+ errors.push(`${label}: file does not exist`)
158
+ }
159
+ }
160
+
161
+ try {
162
+ const source = await readFile(path.join(root, "AGENTS.md"), "utf8")
163
+ if (!source.includes("./agents.yaml") && !source.includes("agents.yaml")) {
164
+ warnings.push("AGENTS.md does not mention agents.yaml")
165
+ }
166
+ } catch {
167
+ warnings.push("AGENTS.md is missing the agents.yaml breadcrumb")
168
+ }
169
+
170
+ return {
171
+ ok: errors.length === 0,
172
+ errors,
173
+ warnings,
174
+ }
164
175
  }
165
176
 
166
177
  export async function initProject(
167
- root: string,
168
- options: { force: boolean },
178
+ root: string,
179
+ options: { force: boolean },
169
180
  ): Promise<{ messages: string[] }> {
170
- const messages: string[] = []
171
-
172
- try {
173
- await access(agentsPath(root))
174
- messages.push('agents.yaml already exists')
175
- } catch {
176
- await saveAgentsFile(root, { version: 1, documents: [] })
177
- messages.push('created agents.yaml')
178
- }
179
-
180
- const projectAgentsPath = path.join(root, 'AGENTS.md')
181
- try {
182
- const source = await readFile(projectAgentsPath, 'utf8')
183
- if (source.includes('agents.yaml') && !options.force) {
184
- messages.push('AGENTS.md already mentions agents.yaml')
185
- return { messages }
186
- }
187
-
188
- const next =
189
- source.trimEnd().length === 0
190
- ? `# Project Instructions\n\n${breadcrumb}\n`
191
- : `${source.trimEnd()}\n\n${breadcrumb}\n`
192
- await writeFile(projectAgentsPath, next, 'utf8')
193
- messages.push('updated AGENTS.md')
194
- } catch (error) {
195
- if (!isNotFound(error)) throw error
196
- await writeFile(projectAgentsPath, `# Project Instructions\n\n${breadcrumb}\n`, 'utf8')
197
- messages.push('created AGENTS.md')
198
- }
199
-
200
- return { messages }
181
+ const messages: string[] = []
182
+
183
+ try {
184
+ await access(agentsPath(root))
185
+ messages.push("agents.yaml already exists")
186
+ } catch {
187
+ await saveAgentsFile(root, { version: 1, documents: [] })
188
+ messages.push("created agents.yaml")
189
+ }
190
+
191
+ const projectAgentsPath = path.join(root, "AGENTS.md")
192
+ try {
193
+ const source = await readFile(projectAgentsPath, "utf8")
194
+ if (source.includes("agents.yaml") && !options.force) {
195
+ messages.push("AGENTS.md already mentions agents.yaml")
196
+ return { messages }
197
+ }
198
+
199
+ const next =
200
+ source.trimEnd().length === 0
201
+ ? `# Project Instructions\n\n${breadcrumb}\n`
202
+ : `${source.trimEnd()}\n\n${breadcrumb}\n`
203
+ await writeFile(projectAgentsPath, next, "utf8")
204
+ messages.push("updated AGENTS.md")
205
+ } catch (error) {
206
+ if (!isNotFound(error)) throw error
207
+ await writeFile(
208
+ projectAgentsPath,
209
+ `# Project Instructions\n\n${breadcrumb}\n`,
210
+ "utf8",
211
+ )
212
+ messages.push("created AGENTS.md")
213
+ }
214
+
215
+ return { messages }
201
216
  }
202
217
 
203
218
  function agentsPath(root: string): string {
204
- return path.join(root, 'agents.yaml')
219
+ return path.join(root, "agents.yaml")
205
220
  }
206
221
 
207
222
  function isNotFound(error: unknown): boolean {
208
- return error instanceof Error && 'code' in error && error.code === 'ENOENT'
223
+ return error instanceof Error && "code" in error && error.code === "ENOENT"
209
224
  }