agents.yaml 0.1.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 ADDED
@@ -0,0 +1,20 @@
1
+ # agents.yaml CLI Guidance
2
+
3
+ This package provides the `agents` CLI for maintaining an `agents.yaml` file.
4
+
5
+ `agents.yaml` is a curated table of contents for active agent-readable documentation. It does not define a new instruction language, replace `AGENTS.md`, or automatically load every dependency document.
6
+
7
+ The file format is intentionally small:
8
+
9
+ ```yaml
10
+ version: 1
11
+
12
+ documents:
13
+ - path: ./node_modules/example-package/AGENTS.md
14
+ ```
15
+
16
+ Agents should treat only the paths listed in `documents` as active supplemental guidance for the project.
17
+
18
+ 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.
19
+
20
+ Discovery only considers direct dependencies under a project's `node_modules`; nested dependency `AGENTS.md` files are not automatically activated.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # agents.yaml
2
+
3
+ A lightweight CLI for discovering and curating agent-readable documentation.
4
+
5
+ `agents.yaml` is a curated table of contents. It points agents at dependency-specific and supplemental `AGENTS.md` documents without copying, flattening, or auto-loading every file in a dependency tree.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ mise install
11
+ pnpm install
12
+ pnpm run build
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ agents init
19
+ agents discover
20
+ agents add ./node_modules/react/AGENTS.md
21
+ agents validate
22
+ ```
23
+
24
+ Run `agents` with no command for the interactive flow.
25
+
26
+ ## File Format
27
+
28
+ ```yaml
29
+ version: 1
30
+
31
+ documents:
32
+ - path: ./node_modules/react/AGENTS.md
33
+ ```
34
+
35
+ Add this breadcrumb to your root `AGENTS.md`:
36
+
37
+ ```md
38
+ For dependency-specific and supplemental guidance, consult `./agents.yaml`.
39
+
40
+ Only the documents listed there should be considered active external guidance for this project.
41
+ ```
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.mjs ADDED
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ import * as clack from "@clack/prompts";
3
+ import { access, opendir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import * as YAML from "yaml";
6
+ import { z } from "zod";
7
+ //#region src/paths.ts
8
+ function cwd() {
9
+ return process.cwd();
10
+ }
11
+ function resolveFromRoot(root, input) {
12
+ return path.isAbsolute(input) ? path.normalize(input) : path.resolve(root, input);
13
+ }
14
+ function formatProjectPath(root, target) {
15
+ 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
+ }
19
+ //#endregion
20
+ //#region src/agents-file.ts
21
+ const fileSchema = z.object({
22
+ version: z.literal(1),
23
+ documents: z.array(z.object({ path: z.string().min(1) }))
24
+ });
25
+ const breadcrumb = `For dependency-specific and supplemental guidance, consult \`./agents.yaml\`.
26
+
27
+ Only the documents listed there should be considered active external guidance for this project.`;
28
+ async function loadAgentsFile(root) {
29
+ const filePath = agentsPath(root);
30
+ try {
31
+ const source = await readFile(filePath, "utf8");
32
+ const parsed = YAML.parse(source);
33
+ return fileSchema.parse(parsed);
34
+ } catch (error) {
35
+ if (isNotFound(error)) return {
36
+ version: 1,
37
+ documents: []
38
+ };
39
+ if (error instanceof z.ZodError) throw new Error(`Invalid agents.yaml: ${error.issues.map((issue) => issue.message).join(", ")}`);
40
+ throw error;
41
+ }
42
+ }
43
+ async function saveAgentsFile(root, file) {
44
+ const normalized = {
45
+ version: file.version,
46
+ documents: file.documents.map((doc) => ({ path: doc.path }))
47
+ };
48
+ await writeFile(agentsPath(root), YAML.stringify(normalized, { lineWidth: 0 }), "utf8");
49
+ }
50
+ async function addDocuments(root, documents) {
51
+ const file = await loadAgentsFile(root);
52
+ const byPath = new Map(file.documents.map((doc) => [doc.path, doc]));
53
+ for (const document of documents) byPath.set(document.path, document);
54
+ const next = {
55
+ version: 1,
56
+ documents: [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path))
57
+ };
58
+ await saveAgentsFile(root, next);
59
+ return next;
60
+ }
61
+ async function removeDocuments(root, paths) {
62
+ const file = await loadAgentsFile(root);
63
+ const pathSet = new Set(paths);
64
+ const removed = [];
65
+ const next = {
66
+ version: 1,
67
+ documents: file.documents.filter((doc) => {
68
+ if (pathSet.has(doc.path)) {
69
+ removed.push(doc.path);
70
+ return false;
71
+ }
72
+ return true;
73
+ })
74
+ };
75
+ await saveAgentsFile(root, next);
76
+ return {
77
+ file: next,
78
+ removed
79
+ };
80
+ }
81
+ async function validateAgentsFile(root) {
82
+ const errors = [];
83
+ const warnings = [];
84
+ let file;
85
+ try {
86
+ file = await loadAgentsFile(root);
87
+ } catch (error) {
88
+ return {
89
+ ok: false,
90
+ errors: [error instanceof Error ? error.message : String(error)],
91
+ warnings
92
+ };
93
+ }
94
+ const seen = /* @__PURE__ */ new Set();
95
+ for (const [index, document] of file.documents.entries()) {
96
+ const label = `documents[${index}] ${document.path}`;
97
+ if (seen.has(document.path)) errors.push(`${label}: duplicate path`);
98
+ seen.add(document.path);
99
+ if (path.basename(document.path) !== "AGENTS.md") warnings.push(`${label}: path does not end with AGENTS.md`);
100
+ try {
101
+ await access(resolveFromRoot(root, document.path));
102
+ } catch {
103
+ errors.push(`${label}: file does not exist`);
104
+ }
105
+ }
106
+ try {
107
+ const source = await readFile(path.join(root, "AGENTS.md"), "utf8");
108
+ if (!source.includes("./agents.yaml") && !source.includes("agents.yaml")) warnings.push("AGENTS.md does not mention agents.yaml");
109
+ } catch {
110
+ warnings.push("AGENTS.md is missing the agents.yaml breadcrumb");
111
+ }
112
+ return {
113
+ ok: errors.length === 0,
114
+ errors,
115
+ warnings
116
+ };
117
+ }
118
+ async function initProject(root, options) {
119
+ const messages = [];
120
+ try {
121
+ await access(agentsPath(root));
122
+ messages.push("agents.yaml already exists");
123
+ } catch {
124
+ await saveAgentsFile(root, {
125
+ version: 1,
126
+ documents: []
127
+ });
128
+ messages.push("created agents.yaml");
129
+ }
130
+ const projectAgentsPath = path.join(root, "AGENTS.md");
131
+ try {
132
+ const source = await readFile(projectAgentsPath, "utf8");
133
+ if (source.includes("agents.yaml") && !options.force) {
134
+ messages.push("AGENTS.md already mentions agents.yaml");
135
+ return { messages };
136
+ }
137
+ await writeFile(projectAgentsPath, source.trimEnd().length === 0 ? `# Project Instructions\n\n${breadcrumb}\n` : `${source.trimEnd()}\n\n${breadcrumb}\n`, "utf8");
138
+ messages.push("updated AGENTS.md");
139
+ } catch (error) {
140
+ if (!isNotFound(error)) throw error;
141
+ await writeFile(projectAgentsPath, `# Project Instructions\n\n${breadcrumb}\n`, "utf8");
142
+ messages.push("created AGENTS.md");
143
+ }
144
+ return { messages };
145
+ }
146
+ function agentsPath(root) {
147
+ return path.join(root, "agents.yaml");
148
+ }
149
+ function isNotFound(error) {
150
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
151
+ }
152
+ //#endregion
153
+ //#region src/discover.ts
154
+ const skippedDirectories = new Set([
155
+ ".git",
156
+ ".hg",
157
+ ".svn",
158
+ ".turbo",
159
+ ".next",
160
+ "coverage",
161
+ "dist",
162
+ "build"
163
+ ]);
164
+ async function discoverAgentDocuments(root) {
165
+ const found = [];
166
+ await walk(root, root, found);
167
+ return found.filter((document) => document.path !== "./AGENTS.md").sort((left, right) => left.path.localeCompare(right.path));
168
+ }
169
+ async function walk(root, directory, found) {
170
+ let handle;
171
+ try {
172
+ handle = await opendir(directory);
173
+ } catch {
174
+ return;
175
+ }
176
+ for await (const entry of handle) {
177
+ const absolutePath = path.join(directory, entry.name);
178
+ if (entry.isDirectory()) {
179
+ if (entry.name === "node_modules") {
180
+ await scanDirectNodeModules(root, absolutePath, found);
181
+ continue;
182
+ }
183
+ if (!skippedDirectories.has(entry.name)) await walk(root, absolutePath, found);
184
+ continue;
185
+ }
186
+ if (entry.isFile() && entry.name === "AGENTS.md") found.push({ path: formatProjectPath(root, absolutePath) });
187
+ }
188
+ }
189
+ async function scanDirectNodeModules(root, nodeModulesPath, found) {
190
+ let handle;
191
+ try {
192
+ handle = await opendir(nodeModulesPath);
193
+ } catch {
194
+ return;
195
+ }
196
+ for await (const entry of handle) {
197
+ if (!entry.isDirectory() && !entry.isSymbolicLink() || entry.name.startsWith(".")) continue;
198
+ const packagePath = path.join(nodeModulesPath, entry.name);
199
+ if (entry.name.startsWith("@")) {
200
+ await scanScopedPackages(root, packagePath, found);
201
+ continue;
202
+ }
203
+ await addPackageAgentsDocument(root, packagePath, found);
204
+ }
205
+ }
206
+ async function scanScopedPackages(root, scopePath, found) {
207
+ let handle;
208
+ try {
209
+ handle = await opendir(scopePath);
210
+ } catch {
211
+ return;
212
+ }
213
+ for await (const entry of handle) if (entry.isDirectory() || entry.isSymbolicLink()) await addPackageAgentsDocument(root, path.join(scopePath, entry.name), found);
214
+ }
215
+ async function addPackageAgentsDocument(root, packagePath, found) {
216
+ const agentsPath = path.join(packagePath, "AGENTS.md");
217
+ try {
218
+ await access(agentsPath);
219
+ found.push({ path: formatProjectPath(root, agentsPath) });
220
+ } catch {}
221
+ }
222
+ //#endregion
223
+ //#region src/run.ts
224
+ const helpText = `agents
225
+
226
+ Usage:
227
+ agents
228
+ agents init [--force]
229
+ agents discover [--json]
230
+ agents add <path...>
231
+ agents remove <path...>
232
+ agents validate [--json]
233
+
234
+ agents.yaml is a curated table of contents for active external AGENTS.md guidance.`;
235
+ async function run(argv) {
236
+ const parsed = parseArgs(argv);
237
+ const root = cwd();
238
+ switch (parsed.command) {
239
+ case void 0:
240
+ await interactive(root);
241
+ return;
242
+ case "help":
243
+ console.log(helpText);
244
+ return;
245
+ case "version":
246
+ console.log("0.1.0");
247
+ return;
248
+ case "init":
249
+ await commandInit(root, parsed.flags.get("force") === true);
250
+ return;
251
+ case "discover":
252
+ await commandDiscover(root, parsed.flags.get("json") === true);
253
+ return;
254
+ case "add":
255
+ await commandAdd(root, parsed.values);
256
+ return;
257
+ case "remove":
258
+ await commandRemove(root, parsed.values);
259
+ return;
260
+ case "validate":
261
+ await commandValidate(root, parsed.flags.get("json") === true);
262
+ return;
263
+ }
264
+ }
265
+ function parseArgs(argv) {
266
+ const flags = /* @__PURE__ */ new Map();
267
+ const values = [];
268
+ let command;
269
+ for (let index = 0; index < argv.length; index += 1) {
270
+ const arg = argv[index];
271
+ if (!arg) continue;
272
+ if (arg === "--help" || arg === "-h") {
273
+ command = "help";
274
+ continue;
275
+ }
276
+ if (arg === "--version" || arg === "-v") {
277
+ command = "version";
278
+ continue;
279
+ }
280
+ if (arg.startsWith("--")) {
281
+ const [rawName, inlineValue] = arg.slice(2).split("=", 2);
282
+ if (!rawName) continue;
283
+ if (inlineValue !== void 0) {
284
+ flags.set(rawName, inlineValue);
285
+ continue;
286
+ }
287
+ flags.set(rawName, true);
288
+ continue;
289
+ }
290
+ if (!command && isCommand(arg)) {
291
+ command = arg;
292
+ continue;
293
+ }
294
+ values.push(arg);
295
+ }
296
+ return {
297
+ command,
298
+ values,
299
+ flags
300
+ };
301
+ }
302
+ function isCommand(value) {
303
+ return [
304
+ "add",
305
+ "discover",
306
+ "help",
307
+ "init",
308
+ "remove",
309
+ "validate",
310
+ "version"
311
+ ].includes(value);
312
+ }
313
+ async function commandInit(root, force) {
314
+ clack.intro("agents init");
315
+ const result = await initProject(root, { force });
316
+ clack.note(result.messages.join("\n"), "Updated");
317
+ clack.outro("Project breadcrumb is ready.");
318
+ }
319
+ async function commandDiscover(root, json) {
320
+ const documents = await discoverAgentDocuments(root);
321
+ if (json) {
322
+ console.log(JSON.stringify(documents, null, 2));
323
+ return;
324
+ }
325
+ clack.intro("agents discover");
326
+ if (documents.length === 0) {
327
+ clack.outro("No supplemental AGENTS.md files found.");
328
+ return;
329
+ }
330
+ clack.note(documents.map((doc) => doc.path).join("\n"), `Found ${documents.length}`);
331
+ clack.outro("Use agents add <path> to enable one.");
332
+ }
333
+ async function commandAdd(root, paths) {
334
+ if (paths.length === 0) throw new Error("add requires at least one AGENTS.md path");
335
+ const documents = paths.map((path) => ({ path: formatProjectPath(root, resolveFromRoot(root, path)) }));
336
+ const file = await addDocuments(root, documents);
337
+ clack.intro("agents add");
338
+ clack.note(file.documents.map((doc) => doc.path).join("\n"), "Active documents");
339
+ clack.outro(`Added ${documents.length} document${documents.length === 1 ? "" : "s"}.`);
340
+ }
341
+ async function commandRemove(root, paths) {
342
+ if (paths.length === 0) throw new Error("remove requires at least one path");
343
+ const result = await removeDocuments(root, paths.map((path) => formatProjectPath(root, resolveFromRoot(root, path))));
344
+ clack.intro("agents remove");
345
+ clack.note(result.removed.join("\n") || "No matching documents were active.", "Removed");
346
+ clack.outro(`agents.yaml now has ${result.file.documents.length} active document${result.file.documents.length === 1 ? "" : "s"}.`);
347
+ }
348
+ async function commandValidate(root, json) {
349
+ const result = await validateAgentsFile(root);
350
+ if (json) {
351
+ console.log(JSON.stringify(result, null, 2));
352
+ return;
353
+ }
354
+ clack.intro("agents validate");
355
+ if (result.errors.length > 0) clack.note(result.errors.join("\n"), "Errors");
356
+ if (result.warnings.length > 0) clack.note(result.warnings.join("\n"), "Warnings");
357
+ clack.outro(result.ok ? "agents.yaml is valid." : "agents.yaml needs attention.");
358
+ if (!result.ok) process.exitCode = 1;
359
+ }
360
+ async function interactive(root) {
361
+ clack.intro("agents");
362
+ const action = await clack.select({
363
+ message: "What would you like to do?",
364
+ options: [
365
+ {
366
+ value: "discover",
367
+ label: "Discover and enable AGENTS.md files"
368
+ },
369
+ {
370
+ value: "validate",
371
+ label: "Validate agents.yaml"
372
+ },
373
+ {
374
+ value: "init",
375
+ label: "Initialize breadcrumb files"
376
+ }
377
+ ]
378
+ });
379
+ if (clack.isCancel(action)) {
380
+ clack.cancel("Cancelled.");
381
+ return;
382
+ }
383
+ if (action === "init") {
384
+ await commandInit(root, false);
385
+ return;
386
+ }
387
+ if (action === "validate") {
388
+ await commandValidate(root, false);
389
+ return;
390
+ }
391
+ const existing = await loadAgentsFile(root);
392
+ const candidates = (await discoverAgentDocuments(root)).filter((doc) => !existing.documents.some((active) => active.path === doc.path));
393
+ if (candidates.length === 0) {
394
+ clack.outro("No inactive supplemental AGENTS.md files found.");
395
+ return;
396
+ }
397
+ const selected = await clack.multiselect({
398
+ message: "Choose documents to enable",
399
+ options: candidates.map((doc) => ({
400
+ value: doc.path,
401
+ label: doc.path
402
+ })),
403
+ required: false
404
+ });
405
+ if (clack.isCancel(selected) || selected.length === 0) {
406
+ clack.cancel("No documents selected.");
407
+ return;
408
+ }
409
+ await commandAdd(root, selected);
410
+ }
411
+ //#endregion
412
+ //#region src/index.ts
413
+ run(process.argv.slice(2)).catch((error) => {
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ console.error(`agents: ${message}`);
416
+ process.exitCode = 1;
417
+ });
418
+ //#endregion
419
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "agents.yaml",
3
+ "version": "0.1.0",
4
+ "description": "A CLI for discovering and curating agent-readable documentation in agents.yaml.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jeremybanka/agents.yaml.git"
8
+ },
9
+ "bin": {
10
+ "agents": "./dist/index.mjs"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "README.md",
16
+ "AGENTS.md"
17
+ ],
18
+ "type": "module",
19
+ "scripts": {
20
+ "build": "tsdown",
21
+ "dev": "src/index.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "check": "tsc --noEmit && tsdown"
24
+ },
25
+ "dependencies": {
26
+ "@clack/prompts": "1.5.1",
27
+ "yaml": "2.9.0",
28
+ "zod": "4.4.3"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "25.9.2",
32
+ "tsdown": "0.22.2",
33
+ "typescript": "6.0.3"
34
+ },
35
+ "engines": {
36
+ "node": "26.3.0",
37
+ "pnpm": "11.5.2"
38
+ },
39
+ "packageManager": "pnpm@11.5.2"
40
+ }
@@ -0,0 +1,201 @@
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
+
7
+ export type AgentsDocumentEntry = {
8
+ path: string
9
+ }
10
+
11
+ export type AgentsFile = {
12
+ version: 1
13
+ documents: AgentsDocumentEntry[]
14
+ }
15
+
16
+ export type ValidationResult = {
17
+ ok: boolean
18
+ errors: string[]
19
+ warnings: string[]
20
+ }
21
+
22
+ const fileSchema = z.object({
23
+ version: z.literal(1),
24
+ documents: z.array(
25
+ z.object({
26
+ path: z.string().min(1),
27
+ }),
28
+ ),
29
+ })
30
+
31
+ const breadcrumb = `For dependency-specific and supplemental guidance, consult \`./agents.yaml\`.
32
+
33
+ Only the documents listed there should be considered active external guidance for this project.`
34
+
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
+ }
55
+ }
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) => ({ path: doc.path })),
61
+ }
62
+
63
+ await writeFile(agentsPath(root), YAML.stringify(normalized, { lineWidth: 0 }), 'utf8')
64
+ }
65
+
66
+ export async function addDocuments(
67
+ root: string,
68
+ documents: AgentsDocumentEntry[],
69
+ ): Promise<AgentsFile> {
70
+ const file = await loadAgentsFile(root)
71
+ const byPath = new Map(file.documents.map((doc) => [doc.path, doc]))
72
+
73
+ for (const document of documents) {
74
+ byPath.set(document.path, document)
75
+ }
76
+
77
+ const next = {
78
+ version: 1 as const,
79
+ documents: [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)),
80
+ }
81
+
82
+ await saveAgentsFile(root, next)
83
+ return next
84
+ }
85
+
86
+ export async function removeDocuments(
87
+ root: string,
88
+ paths: string[],
89
+ ): Promise<{ file: AgentsFile; removed: string[] }> {
90
+ const file = await loadAgentsFile(root)
91
+ const pathSet = new Set(paths)
92
+ const removed: string[] = []
93
+ const documents = file.documents.filter((doc) => {
94
+ if (pathSet.has(doc.path)) {
95
+ removed.push(doc.path)
96
+ return false
97
+ }
98
+
99
+ return true
100
+ })
101
+
102
+ const next = { version: 1 as const, documents }
103
+ await saveAgentsFile(root, next)
104
+ return { file: next, removed }
105
+ }
106
+
107
+ export async function validateAgentsFile(root: string): Promise<ValidationResult> {
108
+ const errors: string[] = []
109
+ const warnings: string[] = []
110
+ let file: AgentsFile
111
+
112
+ try {
113
+ file = await loadAgentsFile(root)
114
+ } catch (error) {
115
+ return {
116
+ ok: false,
117
+ errors: [error instanceof Error ? error.message : String(error)],
118
+ warnings,
119
+ }
120
+ }
121
+
122
+ const seen = new Set<string>()
123
+ for (const [index, document] of file.documents.entries()) {
124
+ const label = `documents[${index}] ${document.path}`
125
+
126
+ if (seen.has(document.path)) {
127
+ errors.push(`${label}: duplicate path`)
128
+ }
129
+ seen.add(document.path)
130
+
131
+ if (path.basename(document.path) !== 'AGENTS.md') {
132
+ warnings.push(`${label}: path does not end with AGENTS.md`)
133
+ }
134
+
135
+ try {
136
+ await access(resolveFromRoot(root, document.path))
137
+ } catch {
138
+ errors.push(`${label}: file does not exist`)
139
+ }
140
+ }
141
+
142
+ try {
143
+ const source = await readFile(path.join(root, 'AGENTS.md'), 'utf8')
144
+ if (!source.includes('./agents.yaml') && !source.includes('agents.yaml')) {
145
+ warnings.push('AGENTS.md does not mention agents.yaml')
146
+ }
147
+ } catch {
148
+ warnings.push('AGENTS.md is missing the agents.yaml breadcrumb')
149
+ }
150
+
151
+ return {
152
+ ok: errors.length === 0,
153
+ errors,
154
+ warnings,
155
+ }
156
+ }
157
+
158
+ export async function initProject(
159
+ root: string,
160
+ options: { force: boolean },
161
+ ): Promise<{ messages: string[] }> {
162
+ const messages: string[] = []
163
+
164
+ try {
165
+ await access(agentsPath(root))
166
+ messages.push('agents.yaml already exists')
167
+ } catch {
168
+ await saveAgentsFile(root, { version: 1, documents: [] })
169
+ messages.push('created agents.yaml')
170
+ }
171
+
172
+ const projectAgentsPath = path.join(root, 'AGENTS.md')
173
+ try {
174
+ const source = await readFile(projectAgentsPath, 'utf8')
175
+ if (source.includes('agents.yaml') && !options.force) {
176
+ messages.push('AGENTS.md already mentions agents.yaml')
177
+ return { messages }
178
+ }
179
+
180
+ const next =
181
+ source.trimEnd().length === 0
182
+ ? `# Project Instructions\n\n${breadcrumb}\n`
183
+ : `${source.trimEnd()}\n\n${breadcrumb}\n`
184
+ await writeFile(projectAgentsPath, next, 'utf8')
185
+ messages.push('updated AGENTS.md')
186
+ } catch (error) {
187
+ if (!isNotFound(error)) throw error
188
+ await writeFile(projectAgentsPath, `# Project Instructions\n\n${breadcrumb}\n`, 'utf8')
189
+ messages.push('created AGENTS.md')
190
+ }
191
+
192
+ return { messages }
193
+ }
194
+
195
+ function agentsPath(root: string): string {
196
+ return path.join(root, 'agents.yaml')
197
+ }
198
+
199
+ function isNotFound(error: unknown): boolean {
200
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
201
+ }
@@ -0,0 +1,71 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { addDocuments, loadAgentsFile } from './agents-file.ts'
6
+ import { discoverAgentDocuments } from './discover.ts'
7
+
8
+ const tempRoots: string[] = []
9
+
10
+ describe('agents.yaml dependency discovery', () => {
11
+ afterEach(async () => {
12
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
13
+ })
14
+
15
+ it('discovers a direct dependency with an AGENTS.md and adds it to agents.yaml', async () => {
16
+ const root = await createTempProject()
17
+ const dependencyAgentsPath = path.join(root, 'node_modules', 'direct-lib', 'AGENTS.md')
18
+ await mkdir(path.dirname(dependencyAgentsPath), { recursive: true })
19
+ await writeFile(dependencyAgentsPath, '# Direct dependency guidance\n', 'utf8')
20
+
21
+ const discovered = await discoverAgentDocuments(root)
22
+ expect(discovered).toEqual([{ path: './node_modules/direct-lib/AGENTS.md' }])
23
+
24
+ await addDocuments(root, [
25
+ {
26
+ path: discovered[0]!.path,
27
+ },
28
+ ])
29
+
30
+ await expect(loadAgentsFile(root)).resolves.toEqual({
31
+ version: 1,
32
+ documents: [
33
+ {
34
+ path: './node_modules/direct-lib/AGENTS.md',
35
+ },
36
+ ],
37
+ })
38
+ })
39
+
40
+ it('does not discover an indirect dependency that has an AGENTS.md', async () => {
41
+ const root = await createTempProject()
42
+ const directAgentsPath = path.join(root, 'node_modules', 'direct-lib', 'AGENTS.md')
43
+ const indirectAgentsPath = path.join(
44
+ root,
45
+ 'node_modules',
46
+ 'direct-lib',
47
+ 'node_modules',
48
+ 'indirect-lib',
49
+ 'AGENTS.md',
50
+ )
51
+
52
+ await mkdir(path.dirname(directAgentsPath), { recursive: true })
53
+ await mkdir(path.dirname(indirectAgentsPath), { recursive: true })
54
+ await writeFile(directAgentsPath, '# Direct dependency guidance\n', 'utf8')
55
+ await writeFile(indirectAgentsPath, '# Indirect dependency guidance\n', 'utf8')
56
+
57
+ await expect(discoverAgentDocuments(root)).resolves.toEqual([
58
+ { path: './node_modules/direct-lib/AGENTS.md' },
59
+ ])
60
+ })
61
+ })
62
+
63
+ async function createTempProject(): Promise<string> {
64
+ const root = await mkdtemp(path.join(tmpdir(), 'agents-yaml-'))
65
+ tempRoots.push(root)
66
+
67
+ await writeFile(path.join(root, 'agents.yaml'), 'version: 1\n\ndocuments: []\n', 'utf8')
68
+ await writeFile(path.join(root, 'AGENTS.md'), 'Consult ./agents.yaml.\n', 'utf8')
69
+
70
+ return root
71
+ }
@@ -0,0 +1,115 @@
1
+ import { access, opendir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { formatProjectPath } from './paths.ts'
4
+
5
+ export type DiscoveredDocument = {
6
+ path: string
7
+ }
8
+
9
+ const skippedDirectories = new Set([
10
+ '.git',
11
+ '.hg',
12
+ '.svn',
13
+ '.turbo',
14
+ '.next',
15
+ 'coverage',
16
+ 'dist',
17
+ 'build',
18
+ ])
19
+
20
+ export async function discoverAgentDocuments(root: string): Promise<DiscoveredDocument[]> {
21
+ const found: DiscoveredDocument[] = []
22
+ await walk(root, root, found)
23
+ return found
24
+ .filter((document) => document.path !== './AGENTS.md')
25
+ .sort((left, right) => left.path.localeCompare(right.path))
26
+ }
27
+
28
+ async function walk(root: string, directory: string, found: DiscoveredDocument[]): Promise<void> {
29
+ let handle
30
+ try {
31
+ handle = await opendir(directory)
32
+ } catch {
33
+ return
34
+ }
35
+
36
+ for await (const entry of handle) {
37
+ const absolutePath = path.join(directory, entry.name)
38
+
39
+ if (entry.isDirectory()) {
40
+ if (entry.name === 'node_modules') {
41
+ await scanDirectNodeModules(root, absolutePath, found)
42
+ continue
43
+ }
44
+
45
+ if (!skippedDirectories.has(entry.name)) {
46
+ await walk(root, absolutePath, found)
47
+ }
48
+ continue
49
+ }
50
+
51
+ if (entry.isFile() && entry.name === 'AGENTS.md') {
52
+ found.push({ path: formatProjectPath(root, absolutePath) })
53
+ }
54
+ }
55
+ }
56
+
57
+ async function scanDirectNodeModules(
58
+ root: string,
59
+ nodeModulesPath: string,
60
+ found: DiscoveredDocument[],
61
+ ): Promise<void> {
62
+ let handle
63
+ try {
64
+ handle = await opendir(nodeModulesPath)
65
+ } catch {
66
+ return
67
+ }
68
+
69
+ for await (const entry of handle) {
70
+ if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith('.')) {
71
+ continue
72
+ }
73
+
74
+ const packagePath = path.join(nodeModulesPath, entry.name)
75
+ if (entry.name.startsWith('@')) {
76
+ await scanScopedPackages(root, packagePath, found)
77
+ continue
78
+ }
79
+
80
+ await addPackageAgentsDocument(root, packagePath, found)
81
+ }
82
+ }
83
+
84
+ async function scanScopedPackages(
85
+ root: string,
86
+ scopePath: string,
87
+ found: DiscoveredDocument[],
88
+ ): Promise<void> {
89
+ let handle
90
+ try {
91
+ handle = await opendir(scopePath)
92
+ } catch {
93
+ return
94
+ }
95
+
96
+ for await (const entry of handle) {
97
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
98
+ await addPackageAgentsDocument(root, path.join(scopePath, entry.name), found)
99
+ }
100
+ }
101
+ }
102
+
103
+ async function addPackageAgentsDocument(
104
+ root: string,
105
+ packagePath: string,
106
+ found: DiscoveredDocument[],
107
+ ): Promise<void> {
108
+ const agentsPath = path.join(packagePath, 'AGENTS.md')
109
+ try {
110
+ await access(agentsPath)
111
+ found.push({ path: formatProjectPath(root, agentsPath) })
112
+ } catch {
113
+ // Packages without AGENTS.md are simply not candidates.
114
+ }
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from './run.ts'
4
+
5
+ run(process.argv.slice(2)).catch((error: unknown) => {
6
+ const message = error instanceof Error ? error.message : String(error)
7
+ console.error(`agents: ${message}`)
8
+ process.exitCode = 1
9
+ })
package/src/paths.ts ADDED
@@ -0,0 +1,18 @@
1
+ import path from 'node:path'
2
+
3
+ export function cwd(): string {
4
+ return process.cwd()
5
+ }
6
+
7
+ export function resolveFromRoot(root: string, input: string): string {
8
+ return path.isAbsolute(input) ? path.normalize(input) : path.resolve(root, input)
9
+ }
10
+
11
+ export function formatProjectPath(root: string, target: string): string {
12
+ const relative = path.relative(root, target).split(path.sep).join(path.posix.sep)
13
+ if (relative.startsWith('..')) {
14
+ return target
15
+ }
16
+
17
+ return relative.startsWith('.') ? relative : `./${relative}`
18
+ }
package/src/run.ts ADDED
@@ -0,0 +1,233 @@
1
+ import * as clack from '@clack/prompts'
2
+ import {
3
+ addDocuments,
4
+ initProject,
5
+ loadAgentsFile,
6
+ removeDocuments,
7
+ validateAgentsFile,
8
+ } from './agents-file.ts'
9
+ import { discoverAgentDocuments } from './discover.ts'
10
+ import { cwd, formatProjectPath, resolveFromRoot } from './paths.ts'
11
+
12
+ type Command = 'add' | 'discover' | 'help' | 'init' | 'remove' | 'validate' | 'version'
13
+
14
+ type ParsedArgs = {
15
+ command: Command | undefined
16
+ values: string[]
17
+ flags: Map<string, string | boolean>
18
+ }
19
+
20
+ const helpText = `agents
21
+
22
+ Usage:
23
+ agents
24
+ agents init [--force]
25
+ agents discover [--json]
26
+ agents add <path...>
27
+ agents remove <path...>
28
+ agents validate [--json]
29
+
30
+ agents.yaml is a curated table of contents for active external AGENTS.md guidance.`
31
+
32
+ export async function run(argv: string[]): Promise<void> {
33
+ const parsed = parseArgs(argv)
34
+ const root = cwd()
35
+
36
+ switch (parsed.command) {
37
+ case undefined:
38
+ await interactive(root)
39
+ return
40
+ case 'help':
41
+ console.log(helpText)
42
+ return
43
+ case 'version':
44
+ console.log('0.1.0')
45
+ return
46
+ case 'init':
47
+ await commandInit(root, parsed.flags.get('force') === true)
48
+ return
49
+ case 'discover':
50
+ await commandDiscover(root, parsed.flags.get('json') === true)
51
+ return
52
+ case 'add':
53
+ await commandAdd(root, parsed.values)
54
+ return
55
+ case 'remove':
56
+ await commandRemove(root, parsed.values)
57
+ return
58
+ case 'validate':
59
+ await commandValidate(root, parsed.flags.get('json') === true)
60
+ return
61
+ }
62
+ }
63
+
64
+ function parseArgs(argv: string[]): ParsedArgs {
65
+ const flags = new Map<string, string | boolean>()
66
+ const values: string[] = []
67
+ let command: Command | undefined
68
+
69
+ for (let index = 0; index < argv.length; index += 1) {
70
+ const arg = argv[index]
71
+ if (!arg) continue
72
+
73
+ if (arg === '--help' || arg === '-h') {
74
+ command = 'help'
75
+ continue
76
+ }
77
+
78
+ if (arg === '--version' || arg === '-v') {
79
+ command = 'version'
80
+ continue
81
+ }
82
+
83
+ if (arg.startsWith('--')) {
84
+ const [rawName, inlineValue] = arg.slice(2).split('=', 2)
85
+ if (!rawName) continue
86
+ if (inlineValue !== undefined) {
87
+ flags.set(rawName, inlineValue)
88
+ continue
89
+ }
90
+
91
+ flags.set(rawName, true)
92
+ continue
93
+ }
94
+
95
+ if (!command && isCommand(arg)) {
96
+ command = arg
97
+ continue
98
+ }
99
+
100
+ values.push(arg)
101
+ }
102
+
103
+ return { command, values, flags }
104
+ }
105
+
106
+ function isCommand(value: string): value is Command {
107
+ return ['add', 'discover', 'help', 'init', 'remove', 'validate', 'version'].includes(value)
108
+ }
109
+
110
+ async function commandInit(root: string, force: boolean): Promise<void> {
111
+ clack.intro('agents init')
112
+ const result = await initProject(root, { force })
113
+ clack.note(result.messages.join('\n'), 'Updated')
114
+ clack.outro('Project breadcrumb is ready.')
115
+ }
116
+
117
+ async function commandDiscover(root: string, json: boolean): Promise<void> {
118
+ const documents = await discoverAgentDocuments(root)
119
+ if (json) {
120
+ console.log(JSON.stringify(documents, null, 2))
121
+ return
122
+ }
123
+
124
+ clack.intro('agents discover')
125
+ if (documents.length === 0) {
126
+ clack.outro('No supplemental AGENTS.md files found.')
127
+ return
128
+ }
129
+
130
+ clack.note(documents.map((doc) => doc.path).join('\n'), `Found ${documents.length}`)
131
+ clack.outro('Use agents add <path> to enable one.')
132
+ }
133
+
134
+ async function commandAdd(root: string, paths: string[]): Promise<void> {
135
+ if (paths.length === 0) {
136
+ throw new Error('add requires at least one AGENTS.md path')
137
+ }
138
+
139
+ const documents = paths.map((path) => ({
140
+ path: formatProjectPath(root, resolveFromRoot(root, path)),
141
+ }))
142
+
143
+ const file = await addDocuments(root, documents)
144
+ clack.intro('agents add')
145
+ clack.note(file.documents.map((doc) => doc.path).join('\n'), 'Active documents')
146
+ clack.outro(`Added ${documents.length} document${documents.length === 1 ? '' : 's'}.`)
147
+ }
148
+
149
+ async function commandRemove(root: string, paths: string[]): Promise<void> {
150
+ if (paths.length === 0) {
151
+ throw new Error('remove requires at least one path')
152
+ }
153
+
154
+ const normalizedPaths = paths.map((path) => formatProjectPath(root, resolveFromRoot(root, path)))
155
+ const result = await removeDocuments(root, normalizedPaths)
156
+ clack.intro('agents remove')
157
+ clack.note(result.removed.join('\n') || 'No matching documents were active.', 'Removed')
158
+ clack.outro(
159
+ `agents.yaml now has ${result.file.documents.length} active document${result.file.documents.length === 1 ? '' : 's'}.`,
160
+ )
161
+ }
162
+
163
+ async function commandValidate(root: string, json: boolean): Promise<void> {
164
+ const result = await validateAgentsFile(root)
165
+ if (json) {
166
+ console.log(JSON.stringify(result, null, 2))
167
+ return
168
+ }
169
+
170
+ clack.intro('agents validate')
171
+ if (result.errors.length > 0) {
172
+ clack.note(result.errors.join('\n'), 'Errors')
173
+ }
174
+ if (result.warnings.length > 0) {
175
+ clack.note(result.warnings.join('\n'), 'Warnings')
176
+ }
177
+
178
+ clack.outro(result.ok ? 'agents.yaml is valid.' : 'agents.yaml needs attention.')
179
+ if (!result.ok) {
180
+ process.exitCode = 1
181
+ }
182
+ }
183
+
184
+ async function interactive(root: string): Promise<void> {
185
+ clack.intro('agents')
186
+ const action = await clack.select({
187
+ message: 'What would you like to do?',
188
+ options: [
189
+ { value: 'discover', label: 'Discover and enable AGENTS.md files' },
190
+ { value: 'validate', label: 'Validate agents.yaml' },
191
+ { value: 'init', label: 'Initialize breadcrumb files' },
192
+ ],
193
+ })
194
+
195
+ if (clack.isCancel(action)) {
196
+ clack.cancel('Cancelled.')
197
+ return
198
+ }
199
+
200
+ if (action === 'init') {
201
+ await commandInit(root, false)
202
+ return
203
+ }
204
+
205
+ if (action === 'validate') {
206
+ await commandValidate(root, false)
207
+ return
208
+ }
209
+
210
+ const existing = await loadAgentsFile(root)
211
+ const discovered = await discoverAgentDocuments(root)
212
+ const candidates = discovered.filter(
213
+ (doc) => !existing.documents.some((active) => active.path === doc.path),
214
+ )
215
+
216
+ if (candidates.length === 0) {
217
+ clack.outro('No inactive supplemental AGENTS.md files found.')
218
+ return
219
+ }
220
+
221
+ const selected = await clack.multiselect({
222
+ message: 'Choose documents to enable',
223
+ options: candidates.map((doc) => ({ value: doc.path, label: doc.path })),
224
+ required: false,
225
+ })
226
+
227
+ if (clack.isCancel(selected) || selected.length === 0) {
228
+ clack.cancel('No documents selected.')
229
+ return
230
+ }
231
+
232
+ await commandAdd(root, selected)
233
+ }