agentboot 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.
Files changed (78) hide show
  1. package/.github/ISSUE_TEMPLATE/persona-request.md +62 -0
  2. package/.github/ISSUE_TEMPLATE/quality-feedback.md +67 -0
  3. package/.github/workflows/cla.yml +25 -0
  4. package/.github/workflows/validate.yml +49 -0
  5. package/.idea/agentboot.iml +9 -0
  6. package/.idea/misc.xml +6 -0
  7. package/.idea/modules.xml +8 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/CLA.md +98 -0
  10. package/CLAUDE.md +230 -0
  11. package/CONTRIBUTING.md +168 -0
  12. package/LICENSE +191 -0
  13. package/NOTICE +4 -0
  14. package/PERSONAS.md +156 -0
  15. package/README.md +172 -0
  16. package/agentboot.config.json +207 -0
  17. package/bin/agentboot.js +17 -0
  18. package/core/gotchas/README.md +35 -0
  19. package/core/instructions/baseline.instructions.md +133 -0
  20. package/core/instructions/security.instructions.md +186 -0
  21. package/core/personas/code-reviewer/SKILL.md +175 -0
  22. package/core/personas/code-reviewer/persona.config.json +11 -0
  23. package/core/personas/security-reviewer/SKILL.md +233 -0
  24. package/core/personas/security-reviewer/persona.config.json +11 -0
  25. package/core/personas/test-data-expert/SKILL.md +234 -0
  26. package/core/personas/test-data-expert/persona.config.json +10 -0
  27. package/core/personas/test-generator/SKILL.md +262 -0
  28. package/core/personas/test-generator/persona.config.json +10 -0
  29. package/core/traits/audit-trail.md +182 -0
  30. package/core/traits/confidence-signaling.md +172 -0
  31. package/core/traits/critical-thinking.md +129 -0
  32. package/core/traits/schema-awareness.md +132 -0
  33. package/core/traits/source-citation.md +174 -0
  34. package/core/traits/structured-output.md +199 -0
  35. package/docs/ci-cd-automation.md +548 -0
  36. package/docs/claude-code-reference/README.md +21 -0
  37. package/docs/claude-code-reference/agentboot-coverage.md +484 -0
  38. package/docs/claude-code-reference/feature-inventory.md +906 -0
  39. package/docs/cli-commands-audit.md +112 -0
  40. package/docs/cli-design.md +924 -0
  41. package/docs/concepts.md +1117 -0
  42. package/docs/config-schema-audit.md +121 -0
  43. package/docs/configuration.md +645 -0
  44. package/docs/delivery-methods.md +758 -0
  45. package/docs/developer-onboarding.md +342 -0
  46. package/docs/extending.md +448 -0
  47. package/docs/getting-started.md +298 -0
  48. package/docs/knowledge-layer.md +464 -0
  49. package/docs/marketplace.md +822 -0
  50. package/docs/org-connection.md +570 -0
  51. package/docs/plans/architecture.md +2429 -0
  52. package/docs/plans/design.md +2018 -0
  53. package/docs/plans/prd.md +1862 -0
  54. package/docs/plans/stack-rank.md +261 -0
  55. package/docs/plans/technical-spec.md +2755 -0
  56. package/docs/privacy-and-safety.md +807 -0
  57. package/docs/prompt-optimization.md +1071 -0
  58. package/docs/test-plan.md +972 -0
  59. package/docs/third-party-ecosystem.md +496 -0
  60. package/domains/compliance-template/README.md +173 -0
  61. package/domains/compliance-template/traits/compliance-aware.md +228 -0
  62. package/examples/enterprise/agentboot.config.json +184 -0
  63. package/examples/minimal/agentboot.config.json +46 -0
  64. package/package.json +63 -0
  65. package/repos.json +1 -0
  66. package/scripts/cli.ts +1069 -0
  67. package/scripts/compile.ts +1000 -0
  68. package/scripts/dev-sync.ts +149 -0
  69. package/scripts/lib/config.ts +137 -0
  70. package/scripts/lib/frontmatter.ts +61 -0
  71. package/scripts/sync.ts +687 -0
  72. package/scripts/validate.ts +421 -0
  73. package/tests/REGRESSION-PLAN.md +705 -0
  74. package/tests/TEST-PLAN.md +111 -0
  75. package/tests/cli.test.ts +705 -0
  76. package/tests/pipeline.test.ts +608 -0
  77. package/tests/validate.test.ts +278 -0
  78. package/tsconfig.json +62 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * AgentBoot sync script.
3
+ *
4
+ * Reads repos.json and distributes compiled output from dist/{platform}/ to each
5
+ * registered repository. For each repo, it merges the applicable scopes in order:
6
+ *
7
+ * 1. dist/{platform}/core/ — org baseline (all repos)
8
+ * 2. dist/{platform}/groups/{group}/ — group-level additions
9
+ * 3. dist/{platform}/teams/{group}/{team}/ — team-level additions
10
+ *
11
+ * Higher specificity scope wins on filename conflict:
12
+ * team > group > core
13
+ *
14
+ * Output is written to {repo}/.claude/ (or the configured targetDir).
15
+ * copilot-instructions.md fragments are also written to {repo}/.github/.
16
+ *
17
+ * Usage:
18
+ * npm run sync
19
+ * tsx scripts/sync.ts
20
+ * tsx scripts/sync.ts --dry-run
21
+ * tsx scripts/sync.ts --config path/to/agentboot.config.json
22
+ * tsx scripts/sync.ts --repos path/to/repos.json
23
+ */
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { createHash } from "node:crypto";
28
+ import { spawnSync } from "node:child_process";
29
+ import { fileURLToPath } from "node:url";
30
+ import chalk from "chalk";
31
+ import {
32
+ type AgentBootConfig,
33
+ resolveConfigPath,
34
+ loadConfig,
35
+ } from "./lib/config.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Paths
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
+ const ROOT = path.resolve(__dirname, "..");
43
+
44
+ interface RepoEntry {
45
+ // Absolute or relative path to the repo root.
46
+ path: string;
47
+ // Platform distribution to sync: "claude", "copilot", "cursor", "skill", "gemini".
48
+ // Defaults to "claude".
49
+ platform?: string;
50
+ // Group this repo belongs to (must match a key in config.groups).
51
+ group?: string;
52
+ // Team this repo belongs to (must be a member of the group's teams).
53
+ team?: string;
54
+ // Human-readable label. Used in sync output only.
55
+ label?: string;
56
+ }
57
+
58
+ interface SyncResult {
59
+ repo: string;
60
+ label?: string;
61
+ platform?: string;
62
+ group?: string;
63
+ team?: string;
64
+ filesWritten: string[];
65
+ filesSkipped: string[]; // unchanged files (same content)
66
+ errors: string[];
67
+ dryRun: boolean;
68
+ prUrl?: string;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function loadRepos(reposPath: string, configDir: string): RepoEntry[] {
76
+ const resolved = path.resolve(configDir, reposPath);
77
+ if (!fs.existsSync(resolved)) {
78
+ console.error(chalk.red(`✗ repos.json not found: ${resolved}`));
79
+ process.exit(1);
80
+ }
81
+ return JSON.parse(fs.readFileSync(resolved, "utf-8")) as RepoEntry[];
82
+ }
83
+
84
+ function ensureDir(dirPath: string, dryRun: boolean): void {
85
+ if (!dryRun) {
86
+ fs.mkdirSync(dirPath, { recursive: true });
87
+ }
88
+ }
89
+
90
+ function writeFile(filePath: string, content: string, dryRun: boolean): "written" | "skipped" {
91
+ if (dryRun) {
92
+ // In dry-run mode, always report as "would write".
93
+ return "written";
94
+ }
95
+
96
+ // Check if the file already has the same content to avoid unnecessary writes.
97
+ if (fs.existsSync(filePath)) {
98
+ const existing = fs.readFileSync(filePath, "utf-8");
99
+ if (existing === content) {
100
+ return "skipped";
101
+ }
102
+ }
103
+
104
+ ensureDir(path.dirname(filePath), false);
105
+ fs.writeFileSync(filePath, content, "utf-8");
106
+ return "written";
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Scope file collection
111
+ // ---------------------------------------------------------------------------
112
+
113
+ interface ScopedFile {
114
+ relativePath: string; // relative to the scope root (e.g. "code-reviewer/SKILL.md")
115
+ absolutePath: string;
116
+ scope: "core" | "group" | "team";
117
+ }
118
+
119
+ /**
120
+ * Recursively collect all files from a directory, returning them as
121
+ * ScopedFile entries. Filters out non-content files.
122
+ */
123
+ function collectScopeFiles(
124
+ scopeDir: string,
125
+ scope: "core" | "group" | "team"
126
+ ): ScopedFile[] {
127
+ if (!fs.existsSync(scopeDir)) {
128
+ return [];
129
+ }
130
+
131
+ const results: ScopedFile[] = [];
132
+
133
+ function walk(dir: string, relBase: string): void {
134
+ for (const entry of fs.readdirSync(dir)) {
135
+ const absPath = path.join(dir, entry);
136
+ const relPath = relBase ? `${relBase}/${entry}` : entry;
137
+ const stat = fs.statSync(absPath);
138
+
139
+ if (stat.isDirectory()) {
140
+ walk(absPath, relPath);
141
+ } else {
142
+ results.push({
143
+ relativePath: relPath,
144
+ absolutePath: absPath,
145
+ scope,
146
+ });
147
+ }
148
+ }
149
+ }
150
+
151
+ walk(scopeDir, "");
152
+ return results;
153
+ }
154
+
155
+ /**
156
+ * Merge files from multiple scopes. Higher specificity scope wins on
157
+ * filename conflict: team > group > core.
158
+ */
159
+ function mergeScopes(
160
+ coreFiles: ScopedFile[],
161
+ groupFiles: ScopedFile[],
162
+ teamFiles: ScopedFile[]
163
+ ): Map<string, ScopedFile> {
164
+ const merged = new Map<string, ScopedFile>();
165
+
166
+ // Apply in order of increasing specificity so higher specificity overwrites lower.
167
+ for (const file of [...coreFiles, ...groupFiles, ...teamFiles]) {
168
+ merged.set(file.relativePath, file);
169
+ }
170
+
171
+ return merged;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Copilot instructions merger
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Build a merged copilot-instructions.md from all persona copilot fragments.
180
+ * GitHub Copilot reads a single file, so we concatenate all fragments.
181
+ */
182
+ function buildCopilotInstructions(
183
+ mergedFiles: Map<string, ScopedFile>,
184
+ org: string
185
+ ): string | null {
186
+ const fragments: string[] = [];
187
+
188
+ for (const [relPath, file] of mergedFiles) {
189
+ if (relPath.endsWith("copilot-instructions.md")) {
190
+ fragments.push(fs.readFileSync(file.absolutePath, "utf-8").trim());
191
+ }
192
+ }
193
+
194
+ if (fragments.length === 0) return null;
195
+
196
+ const header = [
197
+ `<!-- AgentBoot merged copilot instructions — do not edit manually. -->`,
198
+ `<!-- Org: ${org} | Generated: ${new Date().toISOString()} -->`,
199
+ "",
200
+ ].join("\n");
201
+
202
+ return `${header}${fragments.join("\n\n---\n\n")}\n`;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Per-repo sync
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function syncRepo(
210
+ entry: RepoEntry,
211
+ distPath: string,
212
+ config: AgentBootConfig,
213
+ dryRun: boolean
214
+ ): SyncResult {
215
+ const repoPath = path.resolve(entry.path);
216
+ const targetDir = config.sync?.targetDir ?? ".claude";
217
+ const writePersonasIndex = config.sync?.writePersonasIndex !== false;
218
+ const org = config.orgDisplayName ?? config.org;
219
+
220
+ const result: SyncResult = {
221
+ repo: repoPath,
222
+ label: entry.label,
223
+ platform: entry.platform ?? "claude",
224
+ group: entry.group,
225
+ team: entry.team,
226
+ filesWritten: [],
227
+ filesSkipped: [],
228
+ errors: [],
229
+ dryRun,
230
+ };
231
+
232
+ if (!fs.existsSync(repoPath)) {
233
+ result.errors.push(`Repo path does not exist: ${repoPath}`);
234
+ return result;
235
+ }
236
+
237
+ // Collect files from applicable scopes within the platform distribution.
238
+ const platform = entry.platform ?? "claude";
239
+ const platformDir = path.join(distPath, platform);
240
+ const coreDir = path.join(platformDir, "core");
241
+ const groupDir = entry.group
242
+ ? path.join(platformDir, "groups", entry.group)
243
+ : null;
244
+ const teamDir = entry.group && entry.team
245
+ ? path.join(platformDir, "teams", entry.group, entry.team)
246
+ : null;
247
+
248
+ const coreFiles = collectScopeFiles(coreDir, "core");
249
+ const groupFiles = groupDir ? collectScopeFiles(groupDir, "group") : [];
250
+ const teamFiles = teamDir ? collectScopeFiles(teamDir, "team") : [];
251
+
252
+ if (coreFiles.length === 0) {
253
+ result.errors.push(
254
+ `dist/${platform}/core/ is empty. Run \`npm run build\` before syncing.`
255
+ );
256
+ return result;
257
+ }
258
+
259
+ const merged = mergeScopes(coreFiles, groupFiles, teamFiles);
260
+
261
+ // Write all merged files to the target directory.
262
+ // For copilot platform, only write the merged copilot-instructions.md to .github/
263
+ // (individual fragments and non-copilot files are not useful in a copilot-only repo).
264
+ const targetBase = path.join(repoPath, targetDir);
265
+
266
+ if (platform !== "copilot") {
267
+ ensureDir(targetBase, dryRun);
268
+
269
+ for (const [relPath, file] of merged) {
270
+ // copilot-instructions.md fragments are handled separately below.
271
+ if (relPath.endsWith("copilot-instructions.md")) continue;
272
+
273
+ // PERSONAS.md is handled separately (controlled by writePersonasIndex config).
274
+ if (relPath === "PERSONAS.md") continue;
275
+
276
+ // These files need special placement at repo root, handled below.
277
+ if (relPath === ".mcp.json" || relPath === "CLAUDE.md") continue;
278
+
279
+ const destPath = path.join(targetBase, relPath);
280
+ const content = fs.readFileSync(file.absolutePath, "utf-8");
281
+ const status = writeFile(destPath, content, dryRun);
282
+
283
+ const relDest = path.relative(repoPath, destPath);
284
+ if (status === "written") {
285
+ result.filesWritten.push(relDest);
286
+ } else {
287
+ result.filesSkipped.push(relDest);
288
+ }
289
+ }
290
+ }
291
+
292
+ // Write merged copilot-instructions.md to .github/.
293
+ const copilotContent = buildCopilotInstructions(merged, org);
294
+ if (copilotContent) {
295
+ const copilotDest = path.join(repoPath, ".github", "copilot-instructions.md");
296
+ ensureDir(path.dirname(copilotDest), dryRun);
297
+ const status = writeFile(copilotDest, copilotContent, dryRun);
298
+ const relDest = path.relative(repoPath, copilotDest);
299
+ if (status === "written") {
300
+ result.filesWritten.push(relDest);
301
+ } else {
302
+ result.filesSkipped.push(relDest);
303
+ }
304
+ }
305
+
306
+ // Write root-level files (CC reads .mcp.json and CLAUDE.md from project root, not .claude/).
307
+ if (platform !== "copilot") {
308
+ const rootFiles = [".mcp.json", "CLAUDE.md"];
309
+ for (const rootFile of rootFiles) {
310
+ const file = merged.get(rootFile);
311
+ if (file) {
312
+ const destPath = path.join(repoPath, rootFile);
313
+ const content = fs.readFileSync(file.absolutePath, "utf-8");
314
+ const status = writeFile(destPath, content, dryRun);
315
+ const relDest = path.relative(repoPath, destPath);
316
+ if (status === "written") {
317
+ result.filesWritten.push(relDest);
318
+ } else {
319
+ result.filesSkipped.push(relDest);
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // Optionally write PERSONAS.md to the target directory.
326
+ if (writePersonasIndex) {
327
+ const personasIndexSrc = path.join(coreDir, "PERSONAS.md");
328
+ if (fs.existsSync(personasIndexSrc)) {
329
+ const destPath = path.join(repoPath, targetDir, "PERSONAS.md");
330
+ const content = fs.readFileSync(personasIndexSrc, "utf-8");
331
+ const status = writeFile(destPath, content, dryRun);
332
+ const relDest = path.relative(repoPath, destPath);
333
+ if (status === "written") {
334
+ result.filesWritten.push(relDest);
335
+ } else {
336
+ result.filesSkipped.push(relDest);
337
+ }
338
+ }
339
+ }
340
+
341
+ // AB-24: Generate manifest after all files are written.
342
+ const manifestRelPath = generateManifest(
343
+ repoPath,
344
+ targetDir,
345
+ result.filesWritten,
346
+ entry.group,
347
+ entry.team,
348
+ dryRun
349
+ );
350
+ if (!dryRun) {
351
+ result.filesWritten.push(manifestRelPath);
352
+ }
353
+
354
+ return result;
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Validation: group/team references
359
+ // ---------------------------------------------------------------------------
360
+
361
+ function validateRepoEntry(entry: RepoEntry, config: AgentBootConfig): string[] {
362
+ const errors: string[] = [];
363
+ const label = entry.label ?? entry.path;
364
+
365
+ if (entry.group && !config.groups?.[entry.group]) {
366
+ errors.push(
367
+ `[${label}] Group "${entry.group}" is not defined in agentboot.config.json`
368
+ );
369
+ }
370
+
371
+ if (entry.team && !entry.group) {
372
+ errors.push(
373
+ `[${label}] Has team "${entry.team}" but no group. Team requires a group.`
374
+ );
375
+ }
376
+
377
+ if (entry.group && entry.team) {
378
+ const groupTeams = config.groups?.[entry.group]?.teams ?? [];
379
+ if (!groupTeams.includes(entry.team)) {
380
+ errors.push(
381
+ `[${label}] Team "${entry.team}" is not a member of group "${entry.group}" ` +
382
+ `(defined teams: ${groupTeams.join(", ") || "(none)"})`
383
+ );
384
+ }
385
+ }
386
+
387
+ // Validate platform
388
+ const validPlatforms = ["skill", "claude", "copilot"];
389
+ const platform = entry.platform ?? "claude";
390
+ if (!validPlatforms.includes(platform)) {
391
+ errors.push(
392
+ `[${label}] Platform "${platform}" is not supported. Valid: ${validPlatforms.join(", ")}`
393
+ );
394
+ }
395
+
396
+ return errors;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // AB-24: Manifest generation
401
+ // ---------------------------------------------------------------------------
402
+
403
+ function generateManifest(
404
+ repoPath: string,
405
+ targetDir: string,
406
+ filesWritten: string[],
407
+ group?: string,
408
+ team?: string,
409
+ dryRun?: boolean
410
+ ): string {
411
+ // Read version from package.json
412
+ const pkgJsonPath = path.join(ROOT, "package.json");
413
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) as { version: string };
414
+
415
+ // Compute SHA-256 hashes of written files
416
+ const fileEntries: { path: string; hash: string }[] = [];
417
+ for (const relPath of filesWritten) {
418
+ const absPath = path.join(repoPath, relPath);
419
+ if (fs.existsSync(absPath)) {
420
+ const content = fs.readFileSync(absPath);
421
+ const hash = createHash("sha256").update(content).digest("hex");
422
+ fileEntries.push({ path: relPath, hash });
423
+ }
424
+ }
425
+
426
+ const manifest = {
427
+ managed_by: "agentboot",
428
+ version: pkg.version,
429
+ synced_at: new Date().toISOString(),
430
+ scope: { group: group ?? null, team: team ?? null },
431
+ files: fileEntries,
432
+ };
433
+
434
+ const manifestRelPath = path.join(targetDir, ".agentboot-manifest.json");
435
+ const manifestAbsPath = path.join(repoPath, manifestRelPath);
436
+
437
+ if (!dryRun) {
438
+ ensureDir(path.dirname(manifestAbsPath), false);
439
+ fs.writeFileSync(manifestAbsPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
440
+ }
441
+
442
+ return manifestRelPath;
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // AB-28: PR mode (sync via git/gh)
447
+ // ---------------------------------------------------------------------------
448
+
449
+ function createSyncPR(
450
+ repoPath: string,
451
+ targetDir: string,
452
+ config: AgentBootConfig,
453
+ result: SyncResult
454
+ ): void {
455
+ const prConfig = config.sync?.pr;
456
+ const branchPrefix = prConfig?.branchPrefix ?? "agentboot/sync-";
457
+ const titleTemplate = prConfig?.titleTemplate ?? "chore: AgentBoot persona sync";
458
+
459
+ // Validate inputs to prevent injection
460
+ if (!/^[a-zA-Z0-9/_.-]+$/.test(branchPrefix)) {
461
+ result.errors.push(`Invalid branchPrefix: "${branchPrefix}" — only alphanumeric, /, _, ., - allowed`);
462
+ return;
463
+ }
464
+ if (!/^[a-zA-Z0-9 :/_.,!-]+$/.test(titleTemplate)) {
465
+ result.errors.push(`Invalid titleTemplate: "${titleTemplate}" — only alphanumeric, spaces, and common punctuation allowed`);
466
+ return;
467
+ }
468
+
469
+ // Check if there are actual changes
470
+ const diffResult = spawnSync("git", ["diff", "--quiet"], { cwd: repoPath, stdio: "pipe" });
471
+ const cachedResult = spawnSync("git", ["diff", "--cached", "--quiet"], { cwd: repoPath, stdio: "pipe" });
472
+ const untrackedResult = spawnSync("git", ["ls-files", "--others", "--exclude-standard", targetDir], { cwd: repoPath, stdio: "pipe" });
473
+ const untracked = untrackedResult.stdout?.toString().trim() ?? "";
474
+
475
+ if (diffResult.status === 0 && cachedResult.status === 0 && !untracked) {
476
+ return; // No changes
477
+ }
478
+
479
+ const dateSlug = new Date().toISOString().slice(0, 10);
480
+ let branch = `${branchPrefix}${dateSlug}`;
481
+
482
+ // Handle branch-already-exists by appending counter
483
+ const branchCheck = spawnSync("git", ["rev-parse", "--verify", branch], { cwd: repoPath, stdio: "pipe" });
484
+ if (branchCheck.status === 0) {
485
+ let counter = 2;
486
+ while (spawnSync("git", ["rev-parse", "--verify", `${branch}-${counter}`], { cwd: repoPath, stdio: "pipe" }).status === 0) {
487
+ counter++;
488
+ }
489
+ branch = `${branch}-${counter}`;
490
+ }
491
+
492
+ try {
493
+ const run = (cmd: string, args: string[]) => {
494
+ const r = spawnSync(cmd, args, { cwd: repoPath, stdio: "pipe" });
495
+ if (r.status !== 0) {
496
+ throw new Error(`${cmd} ${args.join(" ")} failed: ${r.stderr?.toString().trim()}`);
497
+ }
498
+ return r.stdout?.toString().trim() ?? "";
499
+ };
500
+
501
+ run("git", ["checkout", "-b", branch]);
502
+ // Only add paths that exist
503
+ const addPaths = [targetDir];
504
+ if (fs.existsSync(path.join(repoPath, ".github"))) {
505
+ addPaths.push(".github/");
506
+ }
507
+ // Root-level files written outside targetDir
508
+ for (const rootFile of [".mcp.json", "CLAUDE.md"]) {
509
+ if (fs.existsSync(path.join(repoPath, rootFile))) {
510
+ addPaths.push(rootFile);
511
+ }
512
+ }
513
+ run("git", ["add", ...addPaths]);
514
+ run("git", ["commit", "-m", titleTemplate]);
515
+ run("git", ["push", "-u", "origin", branch]);
516
+ const prOutput = run("gh", ["pr", "create", "--title", titleTemplate, "--body", "Automated AgentBoot sync"]);
517
+ result.prUrl = prOutput;
518
+ } catch (err: unknown) {
519
+ const errMsg = err instanceof Error ? err.message : String(err);
520
+ result.errors.push(`PR creation failed: ${errMsg}`);
521
+ }
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Print helpers
526
+ // ---------------------------------------------------------------------------
527
+
528
+ function printSyncResult(result: SyncResult): void {
529
+ const repoLabel = result.label ?? path.basename(result.repo);
530
+ const scopeParts: string[] = [result.platform ?? "claude"];
531
+ if (result.team) scopeParts.push(`${result.group}/${result.team}`);
532
+ else if (result.group) scopeParts.push(result.group);
533
+ const scope = scopeParts.join("/");
534
+ const dryRunTag = result.dryRun ? chalk.yellow(" [DRY RUN]") : "";
535
+
536
+ if (result.errors.length > 0) {
537
+ console.log(` ${chalk.red("✗")} ${repoLabel} (${scope})${dryRunTag}`);
538
+ for (const err of result.errors) {
539
+ console.log(chalk.red(` ${err}`));
540
+ }
541
+ return;
542
+ }
543
+
544
+ const written = result.filesWritten.length;
545
+ const skipped = result.filesSkipped.length;
546
+ const parts: string[] = [];
547
+ if (written > 0) parts.push(`${written} written`);
548
+ if (skipped > 0) parts.push(chalk.gray(`${skipped} unchanged`));
549
+
550
+ console.log(
551
+ ` ${chalk.green("✓")} ${repoLabel}${chalk.gray(` (${scope})`)} — ${parts.join(", ")}${dryRunTag}`
552
+ );
553
+
554
+ if (written > 0 && written <= 10) {
555
+ for (const f of result.filesWritten) {
556
+ console.log(chalk.gray(` + ${f}`));
557
+ }
558
+ } else if (written > 10) {
559
+ for (const f of result.filesWritten.slice(0, 5)) {
560
+ console.log(chalk.gray(` + ${f}`));
561
+ }
562
+ console.log(chalk.gray(` ... and ${written - 5} more`));
563
+ }
564
+
565
+ if (result.prUrl) {
566
+ console.log(chalk.cyan(` PR: ${result.prUrl}`));
567
+ }
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Main
572
+ // ---------------------------------------------------------------------------
573
+
574
+ async function main(): Promise<void> {
575
+ const argv = process.argv.slice(2);
576
+ const configPath = resolveConfigPath(argv, ROOT);
577
+ const isDryRun =
578
+ argv.includes("--dry-run") || argv.includes("--dryRun");
579
+ const modeIdx = argv.indexOf("--mode");
580
+ const cliMode = modeIdx !== -1 ? argv[modeIdx + 1] : undefined;
581
+
582
+ console.log(chalk.bold("\nAgentBoot — sync"));
583
+ console.log(chalk.gray(`Config: ${configPath}`));
584
+
585
+ if (isDryRun) {
586
+ console.log(chalk.yellow(" DRY RUN mode — no files will be written\n"));
587
+ } else {
588
+ console.log("");
589
+ }
590
+
591
+ const config = loadConfig(configPath);
592
+ const configDir = path.dirname(configPath);
593
+ const dryRun = isDryRun || (config.sync?.dryRun ?? false);
594
+
595
+ const reposPath = config.sync?.repos ?? "./repos.json";
596
+ const distPath = path.resolve(
597
+ configDir,
598
+ config.output?.distPath ?? "./dist"
599
+ );
600
+
601
+ // Check that dist/ exists and has been built.
602
+ if (!fs.existsSync(distPath)) {
603
+ console.error(
604
+ chalk.red(
605
+ `✗ dist/ not found at ${distPath}\n Run \`npm run build\` before syncing.`
606
+ )
607
+ );
608
+ process.exit(1);
609
+ }
610
+
611
+ // Load repos.
612
+ const repos = loadRepos(reposPath, configDir);
613
+
614
+ if (repos.length === 0) {
615
+ console.log(chalk.yellow("No repos in repos.json — nothing to sync."));
616
+ process.exit(0);
617
+ }
618
+
619
+ console.log(chalk.cyan(`Syncing to ${repos.length} repo${repos.length > 1 ? "s" : ""}...`));
620
+
621
+ // Validate all repo entries before writing anything.
622
+ const validationErrors: string[] = [];
623
+ for (const entry of repos) {
624
+ validationErrors.push(...validateRepoEntry(entry, config));
625
+ }
626
+
627
+ if (validationErrors.length > 0) {
628
+ console.log(chalk.red("\nRepos validation failed:"));
629
+ for (const err of validationErrors) {
630
+ console.log(chalk.red(` ✗ ${err}`));
631
+ }
632
+ process.exit(1);
633
+ }
634
+
635
+ // Determine sync mode: "local" (default) or "pr"
636
+ const isPrMode = cliMode === "pr" || (config.sync?.pr?.enabled === true);
637
+
638
+ // Sync each repo.
639
+ const results: SyncResult[] = [];
640
+ for (const entry of repos) {
641
+ const result = syncRepo(entry, distPath, config, dryRun);
642
+
643
+ // AB-28: Create PR if in PR mode and not dry-run
644
+ if (isPrMode && !dryRun && result.errors.length === 0 && result.filesWritten.length > 0) {
645
+ const targetDir = config.sync?.targetDir ?? ".claude";
646
+ createSyncPR(path.resolve(entry.path), targetDir, config, result);
647
+ }
648
+
649
+ results.push(result);
650
+ printSyncResult(result);
651
+ }
652
+
653
+ // Summary.
654
+ const totalWritten = results.reduce((acc, r) => acc + r.filesWritten.length, 0);
655
+ const totalSkipped = results.reduce((acc, r) => acc + r.filesSkipped.length, 0);
656
+ const failedRepos = results.filter((r) => r.errors.length > 0);
657
+
658
+ console.log("");
659
+
660
+ if (failedRepos.length > 0) {
661
+ console.log(
662
+ chalk.bold(
663
+ chalk.red(
664
+ `✗ Sync completed with errors: ` +
665
+ `${failedRepos.length} repo${failedRepos.length > 1 ? "s" : ""} failed`
666
+ )
667
+ )
668
+ );
669
+ process.exit(1);
670
+ }
671
+
672
+ const dryRunNote = dryRun ? chalk.yellow(" (dry run — nothing written)") : "";
673
+ console.log(
674
+ chalk.bold(
675
+ chalk.green("✓") +
676
+ ` Synced ${results.length} repo${results.length > 1 ? "s" : ""}` +
677
+ ` — ${totalWritten} file${totalWritten !== 1 ? "s" : ""} written, ` +
678
+ `${totalSkipped} unchanged` +
679
+ dryRunNote
680
+ )
681
+ );
682
+ }
683
+
684
+ main().catch((err: unknown) => {
685
+ console.error(chalk.red("Unexpected error:"), err);
686
+ process.exit(1);
687
+ });