agentfold 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/agentfold.mjs ADDED
@@ -0,0 +1,1115 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { resolveLayerPaths } from './lib/compose-layers.mjs';
7
+ import { diffRenderMapAgainstProject } from './lib/diff.mjs';
8
+ import { loadProfile } from './lib/load-profile.mjs';
9
+ import { loadManifest, pruneManagedFiles, writeManifest } from './lib/manifest.mjs';
10
+ import { buildRenderPlan } from './lib/pipeline-steps.mjs';
11
+ import {
12
+ createLogger,
13
+ ensureDir,
14
+ listFiles,
15
+ parseCliArgs,
16
+ pathExists,
17
+ readJson,
18
+ removeDir,
19
+ toPosixPath,
20
+ writeJson,
21
+ } from './lib/util.mjs';
22
+ import { validatePlan } from './lib/validate.mjs';
23
+
24
+ const selectableTypes = ['skills', 'prompts', 'rules', 'commands', 'mcp', 'subagents'];
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+ const kitRoot = __dirname;
29
+
30
+ function resolveConfigRoot(options) {
31
+ if (options.config) return path.resolve(String(options.config));
32
+ return path.join(os.homedir(), '.agentfold');
33
+ }
34
+
35
+ function usage() {
36
+ console.log(`agentfold — layered AI agent configuration generator
37
+
38
+ usage:
39
+ agentfold <command> [options]
40
+
41
+ commands:
42
+ init Bootstrap config hub (default: ~/.agentfold)
43
+ lint Validate selected layers/profile
44
+ build Render outputs into <config>/out
45
+ diff Compare rendered outputs with project files
46
+ apply Apply rendered outputs to project files
47
+ status Show current configuration summary
48
+ doctor Validate config hub and project health
49
+ explain Show why a content file is included/excluded
50
+
51
+ create-layer Scaffold a new layer directory
52
+ create-profile Scaffold a new profile JSON
53
+ list-layers List available layers
54
+ list-profiles List available profiles
55
+ list-tags List allowed scope tags
56
+ add-tag Add a scope tag
57
+ delete-tag Delete a scope tag
58
+
59
+ add-rule Add a rule file to a layer
60
+ add-skill Add a skill directory to a layer
61
+ add-subagent Add a subagent file to a layer
62
+ add-command Add a command file to a layer
63
+ add-reference Add a reference file to a layer
64
+
65
+ options:
66
+ --config <path> Config hub root (default: ~/.agentfold)
67
+ --project <path> Target project root (default: cwd)
68
+ --profile <name> Profile name (default: "default")
69
+ --output <path> Build output folder (default: <config>/out)
70
+ --dry-run For apply: show changes only
71
+ --prune For apply: remove stale previously generated files
72
+ --name <value> For create-layer/create-profile/add-*: resource name
73
+ --layer <name> For add-*: target layer (default: "global")
74
+ --layers <list> For create-profile: comma-separated layer names
75
+ --targets <list> For init/create-profile: comma-separated targets (copilot,codex,cursor,claude)
76
+ --force Overwrite existing resources
77
+ --tag <value> For add-tag/delete-tag: tag value
78
+ --file <path> For explain: content file to trace
79
+ --quiet Reduce informational logs
80
+ --version Print version
81
+ `);
82
+ }
83
+
84
+ function normalizeTagValue(input) {
85
+ return String(input || '')
86
+ .trim()
87
+ .toLowerCase()
88
+ .replace(/\s+/g, '-')
89
+ .replace(/_/g, '-')
90
+ .replace(/[^a-z0-9-]/g, '-')
91
+ .replace(/-+/g, '-')
92
+ .replace(/^-|-$/g, '');
93
+ }
94
+
95
+ function getTagsPath({ configRoot }) {
96
+ return path.join(configRoot, 'config', 'definitions', 'scope-tags.json');
97
+ }
98
+
99
+ async function readTagList(tagsPath) {
100
+ if (!(await pathExists(tagsPath))) {
101
+ return [];
102
+ }
103
+ const tags = await readJson(tagsPath);
104
+ if (!Array.isArray(tags)) {
105
+ throw new Error(`Expected tag array at ${tagsPath}`);
106
+ }
107
+ return tags.map((tag) => normalizeTagValue(tag)).filter(Boolean);
108
+ }
109
+
110
+ async function runListTags({ options, logger }) {
111
+ const configRoot = resolveConfigRoot(options);
112
+ const tagsPath = getTagsPath({ configRoot });
113
+ const tags = await readTagList(tagsPath);
114
+ const sorted = [...new Set(tags)].sort((a, b) => a.localeCompare(b));
115
+
116
+ logger.info(`Tags source: ${tagsPath}`);
117
+ for (const tag of sorted) {
118
+ console.log(tag);
119
+ }
120
+ }
121
+
122
+ async function runAddTag({ options, logger }) {
123
+ const rawTag = typeof options.tag === 'string' ? options.tag : options.name;
124
+ const normalizedTag = normalizeTagValue(rawTag);
125
+ if (!normalizedTag) {
126
+ throw new Error('add-tag requires --tag <value> (or --name <value>).');
127
+ }
128
+
129
+ validateKebabName(normalizedTag, 'Tag');
130
+
131
+ const configRoot = resolveConfigRoot(options);
132
+ const tagsPath = getTagsPath({ configRoot });
133
+ const existing = await readTagList(tagsPath);
134
+
135
+ const next = [...new Set([...existing, normalizedTag])].sort((a, b) => a.localeCompare(b));
136
+ if (next.length === existing.length) {
137
+ logger.info(`Tag already exists: ${normalizedTag}`);
138
+ return;
139
+ }
140
+
141
+ await ensureDir(path.dirname(tagsPath));
142
+ await writeJson(tagsPath, next);
143
+ logger.info(`Tag added: ${normalizedTag}`);
144
+ logger.info(`Updated tags file: ${tagsPath}`);
145
+ }
146
+
147
+ async function runDeleteTag({ options, logger }) {
148
+ const rawTag = typeof options.tag === 'string' ? options.tag : options.name;
149
+ const normalizedTag = normalizeTagValue(rawTag);
150
+ if (!normalizedTag) {
151
+ throw new Error('delete-tag requires --tag <value> (or --name <value>).');
152
+ }
153
+
154
+ validateKebabName(normalizedTag, 'Tag');
155
+
156
+ const configRoot = resolveConfigRoot(options);
157
+ const tagsPath = getTagsPath({ configRoot });
158
+ const existing = await readTagList(tagsPath);
159
+ const next = existing.filter((tag) => tag !== normalizedTag);
160
+
161
+ if (next.length === existing.length) {
162
+ logger.info(`Tag not found: ${normalizedTag}`);
163
+ return;
164
+ }
165
+
166
+ await writeJson(tagsPath, [...new Set(next)].sort((a, b) => a.localeCompare(b)));
167
+ logger.info(`Tag deleted: ${normalizedTag}`);
168
+ logger.info(`Updated tags file: ${tagsPath}`);
169
+ }
170
+
171
+ function parseCsvOption(raw) {
172
+ if (!raw || typeof raw !== 'string') {
173
+ return [];
174
+ }
175
+
176
+ return raw
177
+ .split(',')
178
+ .map((part) => part.trim())
179
+ .filter(Boolean);
180
+ }
181
+
182
+ function validateKebabName(name, label) {
183
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
184
+ throw new Error(`${label} must be kebab-case. Received: "${name}"`);
185
+ }
186
+ }
187
+
188
+ function createDefaultLayerSelect() {
189
+ const select = {};
190
+ for (const type of selectableTypes) {
191
+ select[type] = { tags: { mode: 'any', values: [] } };
192
+ }
193
+ return select;
194
+ }
195
+
196
+ async function scaffoldLayer({ layerRoot }) {
197
+ const layerDirs = [
198
+ 'agents',
199
+ 'commands',
200
+ 'mcp',
201
+ 'references',
202
+ 'rules',
203
+ 'skills',
204
+ 'subagents',
205
+ ];
206
+
207
+ for (const folder of layerDirs) {
208
+ await ensureDir(path.join(layerRoot, folder));
209
+ }
210
+ }
211
+
212
+ async function runCreateLayer({ options, logger }) {
213
+ const layerName = typeof options.name === 'string' ? options.name.trim() : '';
214
+ if (!layerName) {
215
+ throw new Error('create-layer requires --name <layer-name>.');
216
+ }
217
+
218
+ if (layerName === 'global') {
219
+ throw new Error('Layer "global" is permanent and already reserved.');
220
+ }
221
+
222
+ validateKebabName(layerName, 'Layer name');
223
+
224
+ const configRoot = resolveConfigRoot(options);
225
+ const layerRoot = path.join(configRoot, 'layers', layerName);
226
+
227
+ const exists = await pathExists(layerRoot);
228
+ if (exists && !options.force) {
229
+ throw new Error(`Layer already exists: ${layerRoot}. Use --force to reuse it.`);
230
+ }
231
+
232
+ await scaffoldLayer({ layerRoot });
233
+ logger.info(`Layer ready: ${layerRoot}`);
234
+ logger.info('Next: add this layer to a profile with create-profile --layers global,<layer-name>.');
235
+ }
236
+
237
+ async function runCreateProfile({ options, logger }) {
238
+ const profileName = typeof options.name === 'string' ? options.name.trim() : '';
239
+ if (!profileName) {
240
+ throw new Error('create-profile requires --name <profile-name>.');
241
+ }
242
+
243
+ validateKebabName(profileName, 'Profile name');
244
+
245
+ const configRoot = resolveConfigRoot(options);
246
+ const requestedLayers = parseCsvOption(options.layers);
247
+ const requestedTargets = parseCsvOption(options.targets);
248
+ const allowedTargets = new Set(['copilot', 'codex', 'cursor', 'claude']);
249
+ const targets = (requestedTargets.length > 0 ? requestedTargets : ['codex']).map((target) => target.toLowerCase());
250
+
251
+ for (const target of targets) {
252
+ if (!allowedTargets.has(target)) {
253
+ throw new Error(`Unsupported target "${target}". Allowed: copilot,codex,cursor,claude.`);
254
+ }
255
+ }
256
+
257
+ const orderedLayers = [];
258
+ const seen = new Set();
259
+ for (const name of ['global', ...(requestedLayers.length > 0 ? requestedLayers : [])]) {
260
+ const normalized = String(name).trim();
261
+ if (!normalized || seen.has(normalized)) {
262
+ continue;
263
+ }
264
+ validateKebabName(normalized, 'Layer name');
265
+ orderedLayers.push(normalized);
266
+ seen.add(normalized);
267
+ }
268
+
269
+ for (const layerName of orderedLayers) {
270
+ const layerPath = path.join(configRoot, 'layers', layerName);
271
+ if (!(await pathExists(layerPath))) {
272
+ await scaffoldLayer({ layerRoot: layerPath });
273
+ logger.info(`Created missing layer: ${layerPath}`);
274
+ }
275
+ }
276
+
277
+ const profileBody = {
278
+ name: profileName,
279
+ targets,
280
+ layers: orderedLayers.map((layerName) => ({
281
+ name: layerName,
282
+ select: createDefaultLayerSelect(),
283
+ })),
284
+ };
285
+
286
+ const profileDir = path.join(configRoot, 'config', 'profiles');
287
+ await ensureDir(profileDir);
288
+ const profilePath = path.join(profileDir, `${profileName}.json`);
289
+
290
+ const exists = await pathExists(profilePath);
291
+ if (exists && !options.force) {
292
+ throw new Error(`Profile already exists: ${profilePath}. Use --force to overwrite.`);
293
+ }
294
+
295
+ await writeJson(profilePath, profileBody);
296
+ logger.info(`Profile created: ${profilePath}`);
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // add-rule / add-skill / add-subagent / add-command / add-reference
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function resolveLayerForAdd(options) {
304
+ const configRoot = resolveConfigRoot(options);
305
+ const layerName = typeof options.layer === 'string' ? options.layer.trim() : 'global';
306
+ validateKebabName(layerName, 'Layer name');
307
+ const layerRoot = path.join(configRoot, 'layers', layerName);
308
+ return { configRoot, layerName, layerRoot };
309
+ }
310
+
311
+ async function runAddRule({ options, logger }) {
312
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
313
+ if (!name) throw new Error('add-rule requires --name <rule-name>.');
314
+ validateKebabName(name, 'Rule name');
315
+
316
+ const { layerRoot } = resolveLayerForAdd(options);
317
+ const filePath = path.join(layerRoot, 'rules', `${name}.md`);
318
+
319
+ if ((await pathExists(filePath)) && !options.force) {
320
+ throw new Error(`Rule already exists: ${filePath}. Use --force to overwrite.`);
321
+ }
322
+
323
+ const content = `---
324
+ metadata:
325
+ scope:
326
+ tags: []
327
+ ---
328
+ # ${name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
329
+
330
+ <!-- Add your rule instructions here -->
331
+ `;
332
+ await ensureDir(path.dirname(filePath));
333
+ await fs.writeFile(filePath, content, 'utf8');
334
+ logger.info(`Rule created: ${filePath}`);
335
+ }
336
+
337
+ async function runAddSkill({ options, logger }) {
338
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
339
+ if (!name) throw new Error('add-skill requires --name <skill-name>.');
340
+ validateKebabName(name, 'Skill name');
341
+
342
+ const { layerRoot } = resolveLayerForAdd(options);
343
+ const skillDir = path.join(layerRoot, 'skills', name);
344
+ const filePath = path.join(skillDir, 'SKILL.md');
345
+
346
+ if ((await pathExists(filePath)) && !options.force) {
347
+ throw new Error(`Skill already exists: ${filePath}. Use --force to overwrite.`);
348
+ }
349
+
350
+ const title = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
351
+ const content = `---
352
+ name: ${name}
353
+ description: "TODO: describe when this skill should be invoked"
354
+ metadata:
355
+ scope:
356
+ tags: []
357
+ ---
358
+ # ${title}
359
+
360
+ ## When to Use
361
+
362
+ <!-- Describe the trigger conditions for this skill -->
363
+
364
+ ## Steps
365
+
366
+ 1. <!-- Step 1 -->
367
+ 2. <!-- Step 2 -->
368
+ 3. <!-- Step 3 -->
369
+ `;
370
+ await ensureDir(skillDir);
371
+ await fs.writeFile(filePath, content, 'utf8');
372
+ logger.info(`Skill created: ${filePath}`);
373
+ }
374
+
375
+ async function runAddSubagent({ options, logger }) {
376
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
377
+ if (!name) throw new Error('add-subagent requires --name <subagent-name>.');
378
+ validateKebabName(name, 'Subagent name');
379
+
380
+ const { layerRoot } = resolveLayerForAdd(options);
381
+ const filePath = path.join(layerRoot, 'subagents', `${name}.agent.md`);
382
+
383
+ if ((await pathExists(filePath)) && !options.force) {
384
+ throw new Error(`Subagent already exists: ${filePath}. Use --force to overwrite.`);
385
+ }
386
+
387
+ const title = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
388
+ const content = `---
389
+ name: ${name}
390
+ description: "TODO: describe what this agent specialises in"
391
+ tools: [read, search]
392
+ model: Auto (copilot)
393
+ metadata:
394
+ scope:
395
+ tags: []
396
+ ---
397
+ # ${title}
398
+
399
+ <!-- System prompt for this sub-agent -->
400
+
401
+ ## Expertise
402
+
403
+ <!-- Describe the agent's domain knowledge -->
404
+
405
+ ## Instructions
406
+
407
+ <!-- Step-by-step workflow the agent should follow -->
408
+ `;
409
+ await ensureDir(path.dirname(filePath));
410
+ await fs.writeFile(filePath, content, 'utf8');
411
+ logger.info(`Subagent created: ${filePath}`);
412
+ }
413
+
414
+ async function runAddCommand({ options, logger }) {
415
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
416
+ if (!name) throw new Error('add-command requires --name <command-name>.');
417
+ validateKebabName(name, 'Command name');
418
+
419
+ const { layerRoot } = resolveLayerForAdd(options);
420
+ const filePath = path.join(layerRoot, 'commands', `${name}.md`);
421
+
422
+ if ((await pathExists(filePath)) && !options.force) {
423
+ throw new Error(`Command already exists: ${filePath}. Use --force to overwrite.`);
424
+ }
425
+
426
+ const title = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
427
+ const content = `---
428
+ metadata:
429
+ scope:
430
+ tags: []
431
+ ---
432
+ # ${title}
433
+
434
+ ## Usage
435
+
436
+ <!-- Describe when and how to invoke this command -->
437
+
438
+ ## Steps
439
+
440
+ 1. <!-- Step 1 -->
441
+ 2. <!-- Step 2 -->
442
+ `;
443
+ await ensureDir(path.dirname(filePath));
444
+ await fs.writeFile(filePath, content, 'utf8');
445
+ logger.info(`Command created: ${filePath}`);
446
+ }
447
+
448
+ async function runAddReference({ options, logger }) {
449
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
450
+ if (!name) throw new Error('add-reference requires --name <reference-name>.');
451
+ validateKebabName(name, 'Reference name');
452
+
453
+ const { layerRoot } = resolveLayerForAdd(options);
454
+ const filePath = path.join(layerRoot, 'references', `${name}.md`);
455
+
456
+ if ((await pathExists(filePath)) && !options.force) {
457
+ throw new Error(`Reference already exists: ${filePath}. Use --force to overwrite.`);
458
+ }
459
+
460
+ const title = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
461
+ const content = `# ${title}
462
+
463
+ <!-- Reference content that other rules/skills can link to via .agents/references/${name}.md -->
464
+ `;
465
+ await ensureDir(path.dirname(filePath));
466
+ await fs.writeFile(filePath, content, 'utf8');
467
+ logger.info(`Reference created: ${filePath}`);
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // init
472
+ // ---------------------------------------------------------------------------
473
+
474
+ const STARTER_TAGS = [
475
+ 'clean-code', 'testing', 'docs', 'frontend', 'backend', 'fullstack',
476
+ ];
477
+
478
+ async function runInit({ options, logger }) {
479
+ const configRoot = resolveConfigRoot(options);
480
+
481
+ if ((await pathExists(path.join(configRoot, 'config'))) && !options.force) {
482
+ throw new Error(
483
+ `Config hub already exists at ${configRoot}. Use --force to re‑initialise.`
484
+ );
485
+ }
486
+
487
+ const requestedTargets = parseCsvOption(options.targets);
488
+ const allowedTargets = new Set(['copilot', 'codex', 'cursor', 'claude']);
489
+ const targets = (requestedTargets.length > 0 ? requestedTargets : ['codex'])
490
+ .map((t) => t.toLowerCase());
491
+ for (const t of targets) {
492
+ if (!allowedTargets.has(t)) {
493
+ throw new Error(`Unsupported target "${t}". Allowed: copilot,codex,cursor,claude.`);
494
+ }
495
+ }
496
+
497
+ // Scaffold global layer
498
+ const globalLayerRoot = path.join(configRoot, 'layers', 'global');
499
+ await scaffoldLayer({ layerRoot: globalLayerRoot });
500
+
501
+ // Default profile
502
+ const profileBody = {
503
+ name: 'default',
504
+ targets,
505
+ layers: [{ name: 'global', select: createDefaultLayerSelect() }],
506
+ };
507
+ await ensureDir(path.join(configRoot, 'config', 'profiles'));
508
+ await writeJson(path.join(configRoot, 'config', 'profiles', 'default.json'), profileBody);
509
+
510
+ // Starter tags
511
+ await ensureDir(path.join(configRoot, 'config', 'definitions'));
512
+ await writeJson(path.join(configRoot, 'config', 'definitions', 'scope-tags.json'), STARTER_TAGS);
513
+
514
+ logger.info(`Initialised config hub at ${configRoot}`);
515
+ logger.info(`Default profile: targets=[${targets.join(',')}], layers=[global]`);
516
+ logger.info('Next: agentfold create-layer --name <team> && agentfold create-profile --name <team> --layers <team>');
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // status
521
+ // ---------------------------------------------------------------------------
522
+
523
+ async function runStatus({ options, logger }) {
524
+ const configRoot = resolveConfigRoot(options);
525
+ const projectRoot = path.resolve(String(options.project || process.cwd()));
526
+
527
+ // Load profile
528
+ let preset;
529
+ try {
530
+ preset = await loadProfile({
531
+ configRoot,
532
+ profileNameOverride: options.profile ? String(options.profile) : undefined,
533
+ });
534
+ } catch (err) {
535
+ logger.error(`Could not load profile: ${err.message}`);
536
+ return;
537
+ }
538
+
539
+ console.log(`Config: ${configRoot}`);
540
+ console.log(`Profile: ${preset.profileName} (${preset.profilePath})`);
541
+
542
+ const enabledTargets = Object.entries(preset.targets)
543
+ .filter(([, enabled]) => enabled)
544
+ .map(([name]) => name);
545
+ console.log(`Targets: ${enabledTargets.join(', ') || '(none)'}`);
546
+
547
+ // Layers
548
+ let layers;
549
+ try {
550
+ layers = await resolveLayerPaths({ configRoot, preset });
551
+ } catch {
552
+ layers = [];
553
+ }
554
+
555
+ console.log(`Layers: ${layers.length}`);
556
+ for (const layer of layers) {
557
+ const files = await listFiles(layer.path);
558
+ console.log(` ${layer.name} — ${layer.path} (${files.length} file(s))`);
559
+ }
560
+
561
+ // Tags
562
+ console.log(`Tags: ${preset.scopeTags.length}`);
563
+
564
+ // Manifest
565
+ const manifest = await loadManifest(projectRoot);
566
+ if (manifest) {
567
+ console.log(`Manifest: ${manifest.files.length} managed file(s), generated ${manifest.generatedAt}`);
568
+ } else {
569
+ console.log('Manifest: (none — run agentfold apply first)');
570
+ }
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // doctor
575
+ // ---------------------------------------------------------------------------
576
+
577
+ async function runDoctor({ options, logger }) {
578
+ const configRoot = resolveConfigRoot(options);
579
+ const projectRoot = path.resolve(String(options.project || process.cwd()));
580
+ const checks = [];
581
+
582
+ function pass(label) { checks.push({ label, result: 'pass' }); }
583
+ function fail(label, detail) { checks.push({ label, result: 'FAIL', detail }); }
584
+ function warn(label, detail) { checks.push({ label, result: 'warn', detail }); }
585
+
586
+ // 1. Config root exists
587
+ if (await pathExists(configRoot)) {
588
+ pass(`Config root exists: ${configRoot}`);
589
+ } else {
590
+ fail('Config root exists', `Missing at ${configRoot} — run agentfold init`);
591
+ }
592
+
593
+ // 2. Load profile
594
+ let preset;
595
+ try {
596
+ preset = await loadProfile({
597
+ configRoot,
598
+ profileNameOverride: options.profile ? String(options.profile) : undefined,
599
+ });
600
+ pass(`Profile "${preset.profileName}" loads successfully`);
601
+ } catch (err) {
602
+ fail('Profile loads successfully', err.message);
603
+ printDoctorResults(checks);
604
+ return;
605
+ }
606
+
607
+ // 3. At least one target enabled
608
+ const enabledTargets = Object.entries(preset.targets)
609
+ .filter(([, enabled]) => enabled)
610
+ .map(([name]) => name);
611
+ if (enabledTargets.length > 0) {
612
+ pass(`Targets enabled: ${enabledTargets.join(', ')}`);
613
+ } else {
614
+ fail('At least one target enabled', 'No targets enabled');
615
+ }
616
+
617
+ // 4. Layer resolution
618
+ let layers = [];
619
+ try {
620
+ layers = await resolveLayerPaths({ configRoot, preset });
621
+ pass(`All ${layers.length} layer(s) resolve`);
622
+ } catch (err) {
623
+ fail('All layers resolve', err.message);
624
+ }
625
+
626
+ // 5. Canonical subdirectories in each layer
627
+ const canonicalDirs = ['agents', 'commands', 'mcp', 'references', 'rules', 'skills', 'subagents'];
628
+ for (const layer of layers) {
629
+ const missing = [];
630
+ for (const dir of canonicalDirs) {
631
+ if (!(await pathExists(path.join(layer.path, dir)))) {
632
+ missing.push(dir);
633
+ }
634
+ }
635
+ if (missing.length === 0) {
636
+ pass(`Layer "${layer.name}" has all canonical folders`);
637
+ } else {
638
+ warn(`Layer "${layer.name}" canonical folders`, `Missing: ${missing.join(', ')}`);
639
+ }
640
+ }
641
+
642
+ // 6. Tags validation
643
+ const scopeTagSet = new Set(preset.scopeTags);
644
+ let tagErrors = 0;
645
+ for (const layer of preset.layers) {
646
+ for (const type of selectableTypes) {
647
+ for (const tag of layer.select[type].tags.values) {
648
+ if (!scopeTagSet.has(tag)) {
649
+ fail(`Tag "${tag}" in ${layer.name}.${type}`, 'Not in scope-tags.json');
650
+ tagErrors++;
651
+ }
652
+ }
653
+ }
654
+ }
655
+ if (tagErrors === 0) {
656
+ pass('All profile tags are defined');
657
+ }
658
+
659
+ // 7. Orphaned layers — layers on disk not referenced by the active profile
660
+ const referencedNames = new Set(preset.layers.map((l) => l.name));
661
+ const layersDir = path.join(configRoot, 'layers');
662
+ if (await pathExists(layersDir)) {
663
+ const entries = await fs.readdir(layersDir, { withFileTypes: true });
664
+ for (const entry of entries) {
665
+ if (entry.isDirectory() && !referencedNames.has(entry.name)) {
666
+ warn(`Orphan layer: ${entry.name}`, 'Exists on disk but not in active profile');
667
+ }
668
+ }
669
+ }
670
+
671
+ // 8. Manifest staleness
672
+ const manifest = await loadManifest(projectRoot);
673
+ if (manifest) {
674
+ if (manifest.profile === preset.profileName) {
675
+ pass('Manifest matches active profile');
676
+ } else {
677
+ warn('Manifest profile mismatch', `Manifest: ${manifest.profile}, Active: ${preset.profileName}`);
678
+ }
679
+ } else {
680
+ warn('Manifest', 'No manifest found — run agentfold apply');
681
+ }
682
+
683
+ printDoctorResults(checks);
684
+ }
685
+
686
+ function printDoctorResults(checks) {
687
+ const icons = { pass: '✓', FAIL: '✗', warn: '!' };
688
+ let failures = 0;
689
+ for (const c of checks) {
690
+ const icon = icons[c.result] || '?';
691
+ const detail = c.detail ? ` — ${c.detail}` : '';
692
+ console.log(` [${icon}] ${c.label}${detail}`);
693
+ if (c.result === 'FAIL') failures++;
694
+ }
695
+ if (failures > 0) {
696
+ process.exitCode = 1;
697
+ console.log(`\n${failures} check(s) failed.`);
698
+ } else {
699
+ console.log('\nAll checks passed.');
700
+ }
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // list-layers
705
+ // ---------------------------------------------------------------------------
706
+
707
+ async function runListLayers({ options, logger }) {
708
+ const configRoot = resolveConfigRoot(options);
709
+ const found = [];
710
+
711
+ const layersDir = path.join(configRoot, 'layers');
712
+ if (await pathExists(layersDir)) {
713
+ const entries = await fs.readdir(layersDir, { withFileTypes: true });
714
+ for (const entry of entries) {
715
+ if (entry.isDirectory()) {
716
+ const layerPath = path.join(layersDir, entry.name);
717
+ const files = await listFiles(layerPath);
718
+ found.push({ name: entry.name, path: layerPath, files: files.length });
719
+ }
720
+ }
721
+ }
722
+
723
+ found.sort((a, b) => a.name.localeCompare(b.name));
724
+ if (found.length === 0) {
725
+ logger.info('No layers found.');
726
+ return;
727
+ }
728
+
729
+ for (const layer of found) {
730
+ console.log(`${layer.name} ${layer.files} file(s) ${layer.path}`);
731
+ }
732
+ }
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // list-profiles
736
+ // ---------------------------------------------------------------------------
737
+
738
+ async function runListProfiles({ options, logger }) {
739
+ const configRoot = resolveConfigRoot(options);
740
+ const found = [];
741
+
742
+ const profilesDir = path.join(configRoot, 'config', 'profiles');
743
+ if (await pathExists(profilesDir)) {
744
+ const entries = await fs.readdir(profilesDir, { withFileTypes: true });
745
+ for (const entry of entries) {
746
+ if (entry.isFile() && entry.name.endsWith('.json')) {
747
+ const profilePath = path.join(profilesDir, entry.name);
748
+ try {
749
+ const raw = await readJson(profilePath);
750
+ found.push({
751
+ name: entry.name.replace(/\.json$/, ''),
752
+ path: profilePath,
753
+ layers: Array.isArray(raw.layers) ? raw.layers.length : 0,
754
+ targets: Array.isArray(raw.targets) ? raw.targets.join(',') : '',
755
+ });
756
+ } catch {
757
+ found.push({ name: entry.name.replace(/\.json$/, ''), path: profilePath, layers: '?', targets: '?' });
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ found.sort((a, b) => a.name.localeCompare(b.name));
764
+ if (found.length === 0) {
765
+ logger.info('No profiles found.');
766
+ return;
767
+ }
768
+
769
+ for (const p of found) {
770
+ console.log(`${p.name} ${p.layers} layer(s) targets=${p.targets} ${p.path}`);
771
+ }
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // explain
776
+ // ---------------------------------------------------------------------------
777
+
778
+ async function runExplain({ options, logger }) {
779
+ const filePath = typeof options.file === 'string' ? options.file.trim() : '';
780
+ if (!filePath) {
781
+ throw new Error('explain requires --file <path> pointing to a source content file.');
782
+ }
783
+
784
+ const configRoot = resolveConfigRoot(options);
785
+ const resolvedFile = path.resolve(filePath);
786
+
787
+ if (!(await pathExists(resolvedFile))) {
788
+ throw new Error(`File not found: ${resolvedFile}`);
789
+ }
790
+
791
+ // Load profile
792
+ const preset = await loadProfile({
793
+ configRoot,
794
+ profileNameOverride: options.profile ? String(options.profile) : undefined,
795
+ });
796
+ const layers = await resolveLayerPaths({ configRoot, preset });
797
+
798
+ // Determine which layer this file belongs to
799
+ let matchedLayer = null;
800
+ let relativeInLayer = null;
801
+ for (const layer of layers) {
802
+ const layerAbs = path.resolve(layer.path);
803
+ if (resolvedFile.startsWith(layerAbs + path.sep)) {
804
+ matchedLayer = layer;
805
+ relativeInLayer = path.relative(layerAbs, resolvedFile);
806
+ break;
807
+ }
808
+ }
809
+
810
+ if (!matchedLayer) {
811
+ console.log(`File is NOT inside any resolved layer for profile "${preset.profileName}".`);
812
+ console.log(`Resolved layers:`);
813
+ for (const l of layers) console.log(` ${l.name} → ${l.path}`);
814
+ return;
815
+ }
816
+
817
+ console.log(`File: ${resolvedFile}`);
818
+ console.log(`Layer: ${matchedLayer.name} (${matchedLayer.path})`);
819
+ console.log(`Rel: ${relativeInLayer}`);
820
+
821
+ // Determine content type from first path segment
822
+ const firstSegment = relativeInLayer.split(path.sep)[0];
823
+ const typeMap = {
824
+ skills: 'skills', rules: 'rules', commands: 'commands',
825
+ mcp: 'mcp', subagents: 'subagents', agents: 'agents',
826
+ references: 'references',
827
+ };
828
+ const contentType = typeMap[firstSegment] || null;
829
+ console.log(`Type: ${contentType || '(unknown)'}`);
830
+
831
+ if (!contentType || !selectableTypes.includes(contentType)) {
832
+ console.log('Result: INCLUDED (no scope filtering applies to this type)');
833
+ return;
834
+ }
835
+
836
+ // Check scope selector
837
+ const selector = matchedLayer.select[contentType];
838
+ const hasActiveSelector = selector.namespaces.length > 0 || selector.tags.values.length > 0;
839
+ if (!hasActiveSelector) {
840
+ console.log('Select: (empty — all content passes)');
841
+ console.log('Result: INCLUDED');
842
+ return;
843
+ }
844
+
845
+ console.log(`Select: namespaces=${JSON.stringify(selector.namespaces)} tags=${JSON.stringify(selector.tags)}`);
846
+
847
+ // Parse frontmatter
848
+ const { normalizeScopeMetadata, matchesScope } = await import('./lib/scope.mjs');
849
+ const { parseFrontMatter } = await import('./lib/util.mjs');
850
+ const text = await fs.readFile(resolvedFile, 'utf8');
851
+ const fm = parseFrontMatter(text);
852
+ if (!fm) {
853
+ console.log('Front: (none)');
854
+ console.log('Result: EXCLUDED (no front matter to match against non-empty selector)');
855
+ return;
856
+ }
857
+
858
+ const metadata = normalizeScopeMetadata(fm);
859
+ console.log(`Front: namespace="${metadata.namespace}" tags=${JSON.stringify(metadata.tags)}`);
860
+
861
+ const matched = matchesScope({ selector, metadata });
862
+ console.log(`Result: ${matched ? 'INCLUDED' : 'EXCLUDED'}`);
863
+ }
864
+
865
+ // ---------------------------------------------------------------------------
866
+ // render pipeline helpers
867
+ // ---------------------------------------------------------------------------
868
+
869
+ async function writeRenderMapToDirectory(renderMap, rootDir) {
870
+ await removeDir(rootDir);
871
+ await ensureDir(rootDir);
872
+
873
+ const entries = [...renderMap.entries()].sort(([a], [b]) => a.localeCompare(b));
874
+ for (const [relativePath, record] of entries) {
875
+ const destination = path.join(rootDir, relativePath);
876
+ await ensureDir(path.dirname(destination));
877
+ await fs.writeFile(destination, record.content, 'utf8');
878
+ }
879
+ }
880
+
881
+ async function gatherContext({ options, logger }) {
882
+ const configRoot = resolveConfigRoot(options);
883
+ const projectRoot = path.resolve(String(options.project || process.cwd()));
884
+ const outputRoot = path.resolve(String(options.output || path.join(configRoot, 'out')));
885
+
886
+ const preset = await loadProfile({
887
+ configRoot,
888
+ profileNameOverride: options.profile ? String(options.profile) : undefined,
889
+ });
890
+
891
+ const layers = await resolveLayerPaths({ configRoot, preset });
892
+ const build = await buildRenderPlan({
893
+ preset,
894
+ layers,
895
+ logger,
896
+ projectRoot,
897
+ configRoot,
898
+ });
899
+ const validation = await validatePlan({
900
+ preset,
901
+ layers,
902
+ skillDefinitions: build.skillDefinitions,
903
+ promptDefinitions: build.promptDefinitions,
904
+ subagentDefinitions: build.subagentDefinitions,
905
+ });
906
+
907
+ return { configRoot, projectRoot, outputRoot, preset, layers, build, validation };
908
+ }
909
+
910
+ function printValidation(validation, logger) {
911
+ for (const warning of validation.warnings) {
912
+ logger.warn(`Warning: ${warning}`);
913
+ }
914
+
915
+ if (validation.errors.length > 0) {
916
+ for (const error of validation.errors) {
917
+ logger.error(`Error: ${error}`);
918
+ }
919
+ }
920
+ }
921
+
922
+ async function runLint({ options, logger }) {
923
+ const context = await gatherContext({ options, logger });
924
+ printValidation(context.validation, logger);
925
+
926
+ if (context.validation.errors.length > 0) {
927
+ throw new Error(`Lint failed with ${context.validation.errors.length} error(s).`);
928
+ }
929
+
930
+ logger.info('Lint passed.');
931
+ }
932
+
933
+ async function runBuild({ options, logger }) {
934
+ const context = await gatherContext({ options, logger });
935
+ printValidation(context.validation, logger);
936
+
937
+ if (context.validation.errors.length > 0) {
938
+ throw new Error(`Build halted due to ${context.validation.errors.length} lint error(s).`);
939
+ }
940
+
941
+ await writeRenderMapToDirectory(context.build.renderMap, context.outputRoot);
942
+ logger.info(`Build complete: ${context.outputRoot}`);
943
+ }
944
+
945
+ async function ensureBuilt(context, logger) {
946
+ if (!(await pathExists(context.outputRoot))) {
947
+ logger.info('Build output missing; generating it now...');
948
+ await writeRenderMapToDirectory(context.build.renderMap, context.outputRoot);
949
+ }
950
+ }
951
+
952
+ function printDiff(diff) {
953
+ console.log(`Added: ${diff.added.length}`);
954
+ for (const relativePath of diff.added) {
955
+ console.log(` + ${relativePath}`);
956
+ }
957
+
958
+ console.log(`Changed: ${diff.changed.length}`);
959
+ for (const relativePath of diff.changed) {
960
+ console.log(` ~ ${relativePath}`);
961
+ }
962
+
963
+ console.log(`Removed: ${diff.removed.length}`);
964
+ for (const relativePath of diff.removed) {
965
+ console.log(` - ${relativePath}`);
966
+ }
967
+ }
968
+
969
+ async function runDiff({ options, logger }) {
970
+ const context = await gatherContext({ options, logger });
971
+ printValidation(context.validation, logger);
972
+
973
+ if (context.validation.errors.length > 0) {
974
+ throw new Error(`Diff halted due to ${context.validation.errors.length} lint error(s).`);
975
+ }
976
+
977
+ await ensureBuilt(context, logger);
978
+ const previousManifest = await loadManifest(context.projectRoot);
979
+ const diff = await diffRenderMapAgainstProject({
980
+ renderMap: context.build.renderMap,
981
+ projectRoot: context.projectRoot,
982
+ previousManifest,
983
+ });
984
+
985
+ printDiff(diff);
986
+ if (diff.added.length + diff.changed.length + diff.removed.length > 0) {
987
+ process.exitCode = 1;
988
+ }
989
+ }
990
+
991
+ async function runApply({ options, logger }) {
992
+ const context = await gatherContext({ options, logger });
993
+ printValidation(context.validation, logger);
994
+
995
+ if (context.validation.errors.length > 0) {
996
+ throw new Error(`Apply halted due to ${context.validation.errors.length} lint error(s).`);
997
+ }
998
+
999
+ await ensureBuilt(context, logger);
1000
+ const previousManifest = await loadManifest(context.projectRoot);
1001
+ const diff = await diffRenderMapAgainstProject({
1002
+ renderMap: context.build.renderMap,
1003
+ projectRoot: context.projectRoot,
1004
+ previousManifest,
1005
+ });
1006
+
1007
+ if (options['dry-run']) {
1008
+ logger.info('Apply dry-run mode. No files written.');
1009
+ printDiff(diff);
1010
+ return;
1011
+ }
1012
+
1013
+ const sortedEntries = [...context.build.renderMap.entries()].sort(([a], [b]) => a.localeCompare(b));
1014
+ for (const [relativePath, record] of sortedEntries) {
1015
+ const destination = path.join(context.projectRoot, relativePath);
1016
+ await ensureDir(path.dirname(destination));
1017
+ await fs.writeFile(destination, record.content, 'utf8');
1018
+ }
1019
+
1020
+ let deleted = [];
1021
+ const isConfigRootProject =
1022
+ path.resolve(context.projectRoot) === path.resolve(context.configRoot);
1023
+ if (options.prune) {
1024
+ deleted = await pruneManagedFiles({
1025
+ previousManifest,
1026
+ renderMap: context.build.renderMap,
1027
+ projectRoot: context.projectRoot,
1028
+ preservePaths: isConfigRootProject ? ['AGENTS.md'] : [],
1029
+ });
1030
+ }
1031
+
1032
+ const manifest = await writeManifest({
1033
+ projectRoot: context.projectRoot,
1034
+ preset: context.preset,
1035
+ renderMap: context.build.renderMap,
1036
+ });
1037
+
1038
+ logger.info(`Apply complete. Wrote ${manifest.files.length} managed file(s).`);
1039
+ if (deleted.length > 0) {
1040
+ logger.info(`Pruned ${deleted.length} stale generated file(s).`);
1041
+ for (const relativePath of deleted) {
1042
+ logger.info(` - ${toPosixPath(relativePath)}`);
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ async function main() {
1048
+ const { positional, options } = parseCliArgs(process.argv.slice(2));
1049
+ const command = positional[0];
1050
+ const logger = createLogger({ quiet: Boolean(options.quiet) });
1051
+
1052
+ if (options.version || command === '--version') {
1053
+ const pkg = await readJson(path.join(kitRoot, 'package.json'));
1054
+ console.log(`agentfold v${pkg.version}`);
1055
+ return;
1056
+ }
1057
+
1058
+ if (!command || command === 'help' || command === '--help') {
1059
+ usage();
1060
+ return;
1061
+ }
1062
+
1063
+ const commands = [
1064
+ 'lint', 'build', 'diff', 'apply',
1065
+ 'init', 'status', 'doctor',
1066
+ 'create-layer', 'create-profile',
1067
+ 'list-layers', 'list-profiles',
1068
+ 'list-tags', 'add-tag', 'delete-tag',
1069
+ 'add-rule', 'add-skill', 'add-subagent', 'add-command', 'add-reference',
1070
+ 'explain',
1071
+ ];
1072
+
1073
+ if (!commands.includes(command)) {
1074
+ usage();
1075
+ throw new Error(`Unknown command: ${command}`);
1076
+ }
1077
+
1078
+ if (options.preset) {
1079
+ throw new Error('The --preset option is no longer supported. Use --profile.');
1080
+ }
1081
+
1082
+ if (options.bundled) {
1083
+ throw new Error('The --bundled option is no longer supported. All content lives in the config hub (--config or ~/.agentfold).');
1084
+ }
1085
+
1086
+ const dispatch = {
1087
+ lint: runLint,
1088
+ build: runBuild,
1089
+ diff: runDiff,
1090
+ apply: runApply,
1091
+ init: runInit,
1092
+ status: runStatus,
1093
+ doctor: runDoctor,
1094
+ 'create-layer': runCreateLayer,
1095
+ 'create-profile': runCreateProfile,
1096
+ 'list-layers': runListLayers,
1097
+ 'list-profiles': runListProfiles,
1098
+ 'list-tags': runListTags,
1099
+ 'add-tag': runAddTag,
1100
+ 'delete-tag': runDeleteTag,
1101
+ 'add-rule': runAddRule,
1102
+ 'add-skill': runAddSkill,
1103
+ 'add-subagent': runAddSubagent,
1104
+ 'add-command': runAddCommand,
1105
+ 'add-reference': runAddReference,
1106
+ explain: runExplain,
1107
+ };
1108
+
1109
+ await dispatch[command]({ options, logger });
1110
+ }
1111
+
1112
+ main().catch((error) => {
1113
+ console.error(error.message);
1114
+ process.exit(1);
1115
+ });