agentboot 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # AgentBoot
2
2
 
3
- **Convention over configuration for agentic development teams.**
3
+ **Bootstrap your agentic development teams.**
4
4
 
5
- AgentBoot is a build tool that compiles AI agent personas and distributes them across your organization's repositories. Define once, deploy everywhere. The Spring Boot of AI agent governance.
5
+ AgentBoot is a build tool that compiles AI agent personas and distributes them across your organization's repositories. Define once, deploy everywhere. Convention over configuration for AI agent governance.
6
6
 
7
7
  ## The Problem
8
8
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentboot",
3
- "version": "0.4.3",
4
- "description": "Convention over configuration for agentic development teams. The Spring Boot of Claude Code governance.",
3
+ "version": "0.4.4",
4
+ "description": "Bootstrap your agentic development teams. Convention over configuration for AI agent governance.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mike Saavedra <mike@agentboot.dev>",
7
7
  "homepage": "https://agentboot.dev",
package/scripts/cli.ts CHANGED
@@ -27,7 +27,7 @@ import path from "node:path";
27
27
  import fs from "node:fs";
28
28
  import chalk from "chalk";
29
29
  import { createHash } from "node:crypto";
30
- import { loadConfig, type MarketplaceManifest, type MarketplaceEntry } from "./lib/config.js";
30
+ import { loadConfig, stripJsoncComments, type MarketplaceManifest, type MarketplaceEntry } from "./lib/config.js";
31
31
 
32
32
  // ---------------------------------------------------------------------------
33
33
  // Paths
@@ -615,12 +615,12 @@ Add to \`agentboot.config.json\`:
615
615
  # }
616
616
 
617
617
  INPUT=$(cat)
618
- EVENT_NAME=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
618
+ EVENT_NAME=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty')
619
619
 
620
620
  # TODO: Add your compliance logic here
621
621
  # Example: block a tool if a condition is met
622
622
  # if [ "$EVENT_NAME" = "PreToolUse" ]; then
623
- # TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
623
+ # TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')
624
624
  # if [ "$TOOL" = "Bash" ]; then
625
625
  # echo '{"decision":"block","reason":"Bash tool is restricted by policy"}' >&2
626
626
  # exit 2
@@ -694,15 +694,16 @@ program
694
694
  }
695
695
  if (!isJson) console.log(chalk.bold("\nAgentBoot — doctor\n"));
696
696
  const cwd = process.cwd();
697
- let issues = 0;
697
+ let issuesFound = 0;
698
+ let issuesFixed = 0;
698
699
 
699
700
  interface DoctorCheck { name: string; status: "ok" | "fail" | "warn"; message: string; fixable?: boolean; fixed?: boolean }
700
701
  const checks: DoctorCheck[] = [];
701
702
 
702
703
  function ok(msg: string) { checks.push({ name: msg, status: "ok", message: msg }); if (!isJson) console.log(` ${chalk.green("✓")} ${msg}`); }
703
- function fail(msg: string, fixable = false) { issues++; checks.push({ name: msg, status: "fail", message: msg, fixable }); if (!isJson) console.log(` ${chalk.red("✗")} ${msg}${fixable && !fixMode ? chalk.gray(" (fixable with --fix)") : ""}`); }
704
+ function fail(msg: string, fixable = false) { issuesFound++; checks.push({ name: msg, status: "fail", message: msg, fixable }); if (!isJson) console.log(` ${chalk.red("✗")} ${msg}${fixable && !fixMode ? chalk.gray(" (fixable with --fix)") : ""}`); }
704
705
  function warn(msg: string, fixable = false) { checks.push({ name: msg, status: "warn", message: msg, fixable }); if (!isJson) console.log(` ${chalk.yellow("⚠")} ${msg}${fixable && !fixMode ? chalk.gray(" (fixable with --fix)") : ""}`); }
705
- function fixed(msg: string) { checks.push({ name: msg, status: "ok", message: msg, fixed: true }); if (!isJson) console.log(` ${chalk.green("✓")} ${msg} ${chalk.cyan(dryRun ? "(would fix)" : "(fixed)")}`); }
706
+ function fixed(msg: string) { issuesFound++; issuesFixed++; checks.push({ name: msg, status: "ok", message: msg, fixed: true }); if (!isJson) console.log(` ${chalk.green("✓")} ${msg} ${chalk.cyan(dryRun ? "(would fix)" : "(fixed)")}`); }
706
707
 
707
708
  // 1. Environment
708
709
  if (!isJson) console.log(chalk.cyan("Environment"));
@@ -733,6 +734,12 @@ program
733
734
  const config = loadConfig(configPath);
734
735
  ok(`Config parses successfully (org: ${config.org})`);
735
736
 
737
+ // Check for orgDisplayName
738
+ if (!config.orgDisplayName || config.orgDisplayName === config.org) {
739
+ warn(`orgDisplayName not set — compiled output will use "${config.org}" as the display name`);
740
+ if (!isJson) console.log(chalk.gray(` Set it with: agentboot config orgDisplayName "Your Org Name"`));
741
+ }
742
+
736
743
  // Helper: generate a minimal SKILL.md scaffold
737
744
  function scaffoldSkillMd(name: string): string {
738
745
  return [
@@ -756,6 +763,7 @@ program
756
763
  const enabledPersonas = config.personas?.enabled ?? [];
757
764
  const personasDir = path.join(cwd, "core", "personas");
758
765
  let personaIssues = 0;
766
+ let personasScaffolded = 0;
759
767
  for (const p of enabledPersonas) {
760
768
  const pDir = path.join(personasDir, p);
761
769
  if (!fs.existsSync(pDir)) {
@@ -766,6 +774,7 @@ program
766
774
  const personaConfig = { traits: config.traits?.enabled ?? [] };
767
775
  fs.writeFileSync(path.join(pDir, "persona.config.json"), JSON.stringify(personaConfig, null, 2) + "\n", "utf-8");
768
776
  }
777
+ personasScaffolded++;
769
778
  fixed(`Scaffolded persona: ${p}`);
770
779
  } else {
771
780
  personaIssues++; fail(`Persona not found: ${p}`, true);
@@ -775,18 +784,24 @@ program
775
784
  if (!dryRun) {
776
785
  fs.writeFileSync(path.join(pDir, "SKILL.md"), scaffoldSkillMd(p), "utf-8");
777
786
  }
787
+ personasScaffolded++;
778
788
  fixed(`Created missing SKILL.md for: ${p}`);
779
789
  } else {
780
790
  personaIssues++; fail(`Missing SKILL.md: ${p}`, true);
781
791
  }
782
792
  }
783
793
  }
784
- if (personaIssues === 0) ok(`All ${enabledPersonas.length} enabled personas found`);
794
+ if (personaIssues === 0 && personasScaffolded === 0) {
795
+ ok(`All ${enabledPersonas.length} enabled personas found`);
796
+ } else if (personaIssues === 0 && personasScaffolded > 0) {
797
+ ok(`All ${enabledPersonas.length} enabled personas found (${personasScaffolded} scaffolded)`);
798
+ }
785
799
 
786
800
  // Check traits
787
801
  const enabledTraits = config.traits?.enabled ?? [];
788
802
  const traitsDir = path.join(cwd, "core", "traits");
789
803
  let traitIssues = 0;
804
+ let traitsScaffolded = 0;
790
805
  for (const t of enabledTraits) {
791
806
  if (!fs.existsSync(path.join(traitsDir, `${t}.md`))) {
792
807
  if (fixMode) {
@@ -795,13 +810,18 @@ program
795
810
  const traitContent = `# ${t}\n\nTODO: Define this trait.\n`;
796
811
  fs.writeFileSync(path.join(traitsDir, `${t}.md`), traitContent, "utf-8");
797
812
  }
813
+ traitsScaffolded++;
798
814
  fixed(`Created missing trait: ${t}.md`);
799
815
  } else {
800
816
  traitIssues++; fail(`Trait not found: ${t}`, true);
801
817
  }
802
818
  }
803
819
  }
804
- if (traitIssues === 0) ok(`All ${enabledTraits.length} enabled traits found`);
820
+ if (traitIssues === 0 && traitsScaffolded === 0) {
821
+ ok(`All ${enabledTraits.length} enabled traits found`);
822
+ } else if (traitIssues === 0 && traitsScaffolded > 0) {
823
+ ok(`All ${enabledTraits.length} enabled traits found (${traitsScaffolded} scaffolded)`);
824
+ }
805
825
 
806
826
  // Check core directories
807
827
  const coreDirs = ["core/personas", "core/traits", "core/instructions", "core/gotchas"];
@@ -865,14 +885,16 @@ program
865
885
 
866
886
  if (!isJson) console.log("");
867
887
 
888
+ const issuesRemaining = issuesFound - issuesFixed;
889
+
868
890
  if (isJson) {
869
- console.log(JSON.stringify({ issues, checks }, null, 2));
870
- process.exit(issues > 0 ? 1 : 0);
891
+ console.log(JSON.stringify({ issues: issuesRemaining, issuesFound, issuesFixed, checks }, null, 2));
892
+ process.exit(issuesRemaining > 0 ? 1 : 0);
871
893
  }
872
894
 
873
- if (issues > 0) {
895
+ if (issuesRemaining > 0) {
874
896
  const fixableCount = checks.filter(c => c.fixable && !c.fixed).length;
875
- console.log(chalk.bold(chalk.red(`✗ ${issues} issue${issues !== 1 ? "s" : ""} found`)));
897
+ console.log(chalk.bold(chalk.red(`✗ ${issuesRemaining} issue${issuesRemaining !== 1 ? "s" : ""} found`)));
876
898
  if (fixableCount > 0) {
877
899
  console.log(chalk.gray(` ${fixableCount} fixable — run \`agentboot doctor --fix\`\n`));
878
900
  } else {
@@ -880,9 +902,8 @@ program
880
902
  }
881
903
  process.exit(1);
882
904
  } else {
883
- const fixedCount = checks.filter(c => c.fixed).length;
884
- if (fixedCount > 0) {
885
- console.log(chalk.bold(chalk.green(`✓ All checks passed (${fixedCount} issue${fixedCount !== 1 ? "s" : ""} ${dryRun ? "would be " : ""}fixed)\n`)));
905
+ if (issuesFixed > 0) {
906
+ console.log(chalk.bold(chalk.green(`✓ All checks passed (${issuesFixed} issue${issuesFixed !== 1 ? "s" : ""} ${dryRun ? "would be " : ""}fixed)\n`)));
886
907
  } else {
887
908
  console.log(chalk.bold(chalk.green("✓ All checks passed\n")));
888
909
  }
@@ -1374,9 +1395,9 @@ program
1374
1395
 
1375
1396
  program
1376
1397
  .command("config")
1377
- .description("View configuration (read-only)")
1378
- .argument("[key]", "config key (e.g., personas.enabled)")
1379
- .argument("[value]", "not yet supported")
1398
+ .description("Read or write configuration values")
1399
+ .argument("[key]", "config key (e.g., org, orgDisplayName, personas.enabled)")
1400
+ .argument("[value]", "value to set (strings only — edit agentboot.config.json for complex values)")
1380
1401
  .action((key: string | undefined, value: string | undefined, _opts, cmd) => {
1381
1402
  const globalOpts = cmd.optsWithGlobals();
1382
1403
  const cwd = process.cwd();
@@ -1413,10 +1434,61 @@ program
1413
1434
  process.exit(0);
1414
1435
  }
1415
1436
 
1416
- console.error(chalk.red(`Config writes are not yet supported. Edit agentboot.config.json directly.`));
1417
- console.error(chalk.gray(` agentboot config ${key} ← read a value`));
1418
- console.error(chalk.gray(` agentboot config ← show full config`));
1419
- process.exit(1);
1437
+ // Write a config value
1438
+ const raw = fs.readFileSync(configPath, "utf-8");
1439
+
1440
+ // Detect JSONC comments — writing back would destroy them
1441
+ const stripped = stripJsoncComments(raw);
1442
+ if (stripped !== raw) {
1443
+ console.error(chalk.red("Config file contains comments (JSONC)."));
1444
+ console.error(chalk.gray(" Writing would remove all comments. Edit the file directly:\n"));
1445
+ console.error(chalk.gray(` ${configPath}\n`));
1446
+ process.exit(1);
1447
+ }
1448
+
1449
+ let config: Record<string, unknown>;
1450
+ try {
1451
+ config = JSON.parse(stripped);
1452
+ } catch {
1453
+ console.error(chalk.red("Failed to parse config for writing."));
1454
+ process.exit(1);
1455
+ }
1456
+
1457
+ const keys = key.split(".");
1458
+ let target: Record<string, unknown> = config;
1459
+ for (let i = 0; i < keys.length - 1; i++) {
1460
+ const k = keys[i]!;
1461
+ if (target[k] === undefined) {
1462
+ // Auto-create intermediate objects
1463
+ target[k] = {};
1464
+ target = target[k] as Record<string, unknown>;
1465
+ } else if (typeof target[k] === "object" && !Array.isArray(target[k]) && target[k] !== null) {
1466
+ target = target[k] as Record<string, unknown>;
1467
+ } else {
1468
+ console.error(chalk.red(`Cannot write to ${key}: "${k}" exists but is ${typeof target[k]}, not an object.`));
1469
+ console.error(chalk.gray(" Edit agentboot.config.json directly.\n"));
1470
+ process.exit(1);
1471
+ }
1472
+ }
1473
+
1474
+ const finalKey = keys[keys.length - 1]!;
1475
+ const oldValue = target[finalKey];
1476
+
1477
+ // Guard against overwriting non-string values (arrays, objects, numbers, booleans)
1478
+ if (oldValue !== undefined && typeof oldValue !== "string") {
1479
+ console.error(chalk.red(`Cannot overwrite ${key}: existing value is ${typeof oldValue}, not a string.`));
1480
+ console.error(chalk.gray(" Edit agentboot.config.json directly for non-string values.\n"));
1481
+ process.exit(1);
1482
+ }
1483
+
1484
+ target[finalKey] = value;
1485
+
1486
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1487
+ if (oldValue !== undefined) {
1488
+ console.log(chalk.green(` ${key}: ${JSON.stringify(oldValue)} → ${JSON.stringify(value)}`));
1489
+ } else {
1490
+ console.log(chalk.green(` ${key}: ${JSON.stringify(value)} (added)`));
1491
+ }
1420
1492
  });
1421
1493
 
1422
1494
  // ---- export (AB-40) -------------------------------------------------------
@@ -1043,10 +1043,15 @@ exit 0
1043
1043
  const includeDevId = config.telemetry?.includeDevId ?? false;
1044
1044
 
1045
1045
  let devIdBlock = "";
1046
- if (includeDevId === "hashed") {
1046
+ if (includeDevId === "hashed" || includeDevId === "email") {
1047
+ if (includeDevId === "email") {
1048
+ log(chalk.yellow(` ⚠ telemetry.includeDevId "email" now defaults to hashed for privacy.`));
1049
+ log(chalk.yellow(` Use "email-raw" to explicitly include raw emails (not recommended).`));
1050
+ }
1047
1051
  devIdBlock = `DEV_ID=$(git config user.email 2>/dev/null | shasum -a 256 | cut -d' ' -f1)`;
1048
- } else if (includeDevId === "email") {
1049
- log(chalk.yellow(` ⚠ telemetry.includeDevId is "email" — raw emails will be in telemetry logs. Consider "hashed" for privacy.`));
1052
+ } else if (includeDevId === "email-raw") {
1053
+ log(chalk.yellow(` ⚠ telemetry.includeDevId is "email-raw" — raw emails will be in telemetry logs.`));
1054
+ log(chalk.yellow(` Consider "hashed" for privacy compliance (GDPR, data minimization).`));
1050
1055
  devIdBlock = `DEV_ID=$(git config user.email 2>/dev/null || echo "unknown")`;
1051
1056
  } else {
1052
1057
  devIdBlock = `DEV_ID=""`;
@@ -171,7 +171,7 @@ export interface TelemetryConfig {
171
171
  enabled?: boolean;
172
172
  /** How to identify developers in telemetry.
173
173
  * false = no developer ID, "hashed" = SHA-256 of email, "email" = raw email. */
174
- includeDevId?: false | "hashed" | "email";
174
+ includeDevId?: false | "hashed" | "email" | "email-raw";
175
175
  /** Path to NDJSON log file. Default: ~/.agentboot/telemetry.ndjson */
176
176
  logPath?: string;
177
177
  /** Never include raw prompt content in telemetry. Design invariant. */
@@ -396,10 +396,15 @@ export function loadConfig(configPath: string): AgentBootConfig {
396
396
  ["sync.repos", parsed.sync?.repos],
397
397
  ["output.distPath", parsed.output?.distPath],
398
398
  ["personas.customDir", parsed.personas?.customDir],
399
+ ["telemetry.logPath", parsed.telemetry?.logPath],
399
400
  ];
400
401
  for (const [fieldName, value] of pathFields) {
401
- if (typeof value === "string" && value.includes("..")) {
402
- throw new Error(`"${fieldName}" must not contain ".." path segments`);
402
+ if (typeof value === "string") {
403
+ // Check for .. path traversal (normalized for both separators)
404
+ const normalized = value.replace(/\\/g, "/");
405
+ if (normalized.split("/").includes("..")) {
406
+ throw new Error(`"${fieldName}" must not contain ".." path segments`);
407
+ }
403
408
  }
404
409
  }
405
410
 
@@ -917,7 +917,7 @@ function analyzeOverlap(
917
917
  return matches;
918
918
  }
919
919
 
920
- export { analyzeOverlap, normalizeContent, jaccardSimilarity, scanPath };
920
+ export { analyzeOverlap, normalizeContent, jaccardSimilarity, scanPath, applyPlan, buildClassificationPrompt, ALLOWED_CLASSIFICATION_DIRS };
921
921
 
922
922
  // ---------------------------------------------------------------------------
923
923
  // Hub finder
@@ -142,13 +142,21 @@ function detectGhAuthenticated(): boolean {
142
142
  }
143
143
 
144
144
  /**
145
- * Shallow scan sibling directories for agentboot.config.json or .claude/.
146
- * Returns list of found paths with their type.
145
+ * Scan nearby directories for agentboot.config.json or .claude/.
146
+ * Checks: the parent directory itself, then sibling directories.
147
+ * This supports both sibling layouts (hub next to spokes) and parent layouts
148
+ * (hub is the parent directory containing spoke repos).
147
149
  */
148
- function scanSiblings(cwd: string): Array<{ path: string; type: "hub" | "claude" }> {
150
+ export function scanNearby(cwd: string): Array<{ path: string; type: "hub" | "claude" }> {
149
151
  const parent = path.dirname(cwd);
150
152
  const results: Array<{ path: string; type: "hub" | "claude" }> = [];
151
153
 
154
+ // Check parent directory itself (supports hub-as-parent layout)
155
+ if (fs.existsSync(path.join(parent, "agentboot.config.json"))) {
156
+ results.push({ path: parent, type: "hub" });
157
+ }
158
+
159
+ // Check sibling directories
152
160
  try {
153
161
  for (const entry of fs.readdirSync(parent)) {
154
162
  const siblingPath = path.join(parent, entry);
@@ -206,11 +214,11 @@ function searchGitHubOrg(org: string): string | null {
206
214
  // Scaffold helpers
207
215
  // ---------------------------------------------------------------------------
208
216
 
209
- export function scaffoldHub(targetDir: string, orgName: string): void {
217
+ export function scaffoldHub(targetDir: string, orgSlug: string, orgDisplayName?: string): void {
210
218
  // agentboot.config.json
211
219
  const configContent = JSON.stringify({
212
- org: orgName,
213
- orgDisplayName: orgName,
220
+ org: orgSlug,
221
+ orgDisplayName: orgDisplayName ?? orgSlug,
214
222
  groups: {},
215
223
  personas: {
216
224
  enabled: ["code-reviewer", "security-reviewer", "test-generator", "test-data-expert"],
@@ -246,6 +254,11 @@ export function scaffoldHub(targetDir: string, orgName: string): void {
246
254
 
247
255
  function runBuild(hubDir: string): boolean {
248
256
  console.log(chalk.cyan("\n Compiling personas..."));
257
+ console.log(chalk.gray(
258
+ " This reads your traits and personas from core/, composes them, and\n" +
259
+ " writes compiled output to dist/. The dist/ folder is what gets\n" +
260
+ " deployed to your repos.\n"
261
+ ));
249
262
  const result = spawnSync("agentboot", ["build"], {
250
263
  cwd: hubDir,
251
264
  encoding: "utf-8",
@@ -253,10 +266,10 @@ function runBuild(hubDir: string): boolean {
253
266
  });
254
267
 
255
268
  if (result.status === 0) {
256
- console.log(chalk.green(" Compiled successfully."));
269
+ console.log(chalk.green(" Build complete."));
257
270
  return true;
258
271
  } else {
259
- console.log(chalk.yellow(" Build skipped — run `agentboot build` from the personas repo."));
272
+ console.log(chalk.yellow(" Build did not complete you can run `agentboot build` later."));
260
273
  return false;
261
274
  }
262
275
  }
@@ -270,6 +283,150 @@ function runSync(hubDir: string): boolean {
270
283
  return result.status === 0;
271
284
  }
272
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Hub target validation
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Validate and potentially adjust the hub target directory.
292
+ *
293
+ * If the target directory exists and looks like it already has content (a git
294
+ * repo, source files, etc.), we don't want to scaffold hub files into it —
295
+ * that would pollute an existing project. Instead, offer to create a `personas`
296
+ * subdirectory.
297
+ */
298
+ async function validateHubTarget(initialDir: string): Promise<string> {
299
+ let hubDir = initialDir;
300
+
301
+ // eslint-disable-next-line no-constant-condition
302
+ while (true) {
303
+ // If the directory doesn't exist, it will be created fresh — no issue.
304
+ if (!fs.existsSync(hubDir)) {
305
+ fs.mkdirSync(hubDir, { recursive: true });
306
+ return hubDir;
307
+ }
308
+
309
+ // If it already has agentboot.config.json, it's already a hub — bail.
310
+ if (fs.existsSync(path.join(hubDir, "agentboot.config.json"))) {
311
+ console.log(chalk.yellow("\n This directory already has agentboot.config.json."));
312
+ console.log(chalk.gray(" Run `agentboot doctor` to check your configuration.\n"));
313
+ throw new AgentBootError(0);
314
+ }
315
+
316
+ // Check if the directory has existing content that suggests it's not an
317
+ // empty directory intended for a new personas repo.
318
+ const entries = fs.readdirSync(hubDir).filter(e => !e.startsWith(".") || e === ".git");
319
+ const hasGit = fs.existsSync(path.join(hubDir, ".git"));
320
+ const hasPackageJson = fs.existsSync(path.join(hubDir, "package.json"));
321
+ const hasSrc = fs.existsSync(path.join(hubDir, "src"));
322
+
323
+ const hasExistingContent = entries.length > 0 && (hasGit || hasPackageJson || hasSrc);
324
+
325
+ if (!hasExistingContent) {
326
+ // Empty or near-empty directory — fine to use directly.
327
+ return hubDir;
328
+ }
329
+
330
+ // The directory has content. Warn and offer alternatives.
331
+ const dirName = path.basename(hubDir);
332
+ const personasPath = path.join(hubDir, "personas");
333
+
334
+ console.log(chalk.yellow(
335
+ `\n "${dirName}" already has content (${entries.length} items).` +
336
+ `\n Scaffolding here would mix persona source code with existing files.\n`
337
+ ));
338
+
339
+ const choice = await select({
340
+ message: "Where should the personas repo live?",
341
+ choices: [
342
+ { name: `Create ${personasPath} (recommended)`, value: "sub" },
343
+ { name: "Choose a different location", value: "custom" },
344
+ { name: `Use ${hubDir} anyway (not recommended)`, value: "here" },
345
+ ],
346
+ });
347
+
348
+ if (choice === "sub") {
349
+ if (!fs.existsSync(personasPath)) {
350
+ fs.mkdirSync(personasPath, { recursive: true });
351
+ }
352
+ return personasPath;
353
+ } else if (choice === "custom") {
354
+ const customPath = await input({
355
+ message: "Path for the personas repo:",
356
+ default: personasPath,
357
+ });
358
+ hubDir = path.resolve(customPath);
359
+ continue; // re-validate the new target
360
+ }
361
+
362
+ // "here" — user insists, proceed with original path
363
+ return hubDir;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Nudge toward the convention of naming the hub repo "personas".
369
+ *
370
+ * This is an educational moment, not a gate. The user can proceed with any
371
+ * name — but we explain why "personas" is the convention and what they gain
372
+ * by following it.
373
+ */
374
+ async function nudgePersonasConvention(hubDir: string): Promise<string> {
375
+ const dirName = path.basename(hubDir);
376
+
377
+ // Already named "personas" — nothing to do.
378
+ if (dirName === "personas") return hubDir;
379
+
380
+ console.log(chalk.cyan(
381
+ `\n Convention: name this repo "personas"\n\n`
382
+ ) + chalk.gray(
383
+ ` AgentBoot follows a convention-over-configuration philosophy. When every\n` +
384
+ ` org names their hub repo "personas", several things work automatically:\n\n` +
385
+ ` - \`agentboot install\` auto-discovers it by scanning for "personas" in\n` +
386
+ ` your GitHub org and sibling directories\n` +
387
+ ` - New team members know where to look without being told\n` +
388
+ ` - Docs, examples, and community answers all reference the same path\n` +
389
+ ` - \`gh repo clone <org>/personas\` works across every AgentBoot org\n\n` +
390
+ ` You chose "${dirName}" — that works fine. This is a recommendation,\n` +
391
+ ` not a requirement.\n`
392
+ ));
393
+
394
+ const choice = await select({
395
+ message: `Keep "${dirName}" or rename to "personas"?`,
396
+ choices: [
397
+ { name: `Rename to ${path.join(path.dirname(hubDir), "personas")} (recommended)`, value: "rename" },
398
+ { name: `Keep "${dirName}"`, value: "keep" },
399
+ ],
400
+ });
401
+
402
+ if (choice === "rename") {
403
+ const personasDir = path.join(path.dirname(hubDir), "personas");
404
+ if (fs.existsSync(personasDir)) {
405
+ console.log(chalk.yellow(` ${personasDir} already exists. Keeping "${dirName}".`));
406
+ return hubDir;
407
+ }
408
+ // If the original dir was just created (empty), rename it.
409
+ // If it had content, we can't rename safely — keep it.
410
+ try {
411
+ const entries = fs.readdirSync(hubDir);
412
+ if (entries.length === 0) {
413
+ fs.rmdirSync(hubDir);
414
+ fs.mkdirSync(personasDir, { recursive: true });
415
+ return personasDir;
416
+ } else {
417
+ // Directory has content (from scaffold or prior step) — rename via fs.rename
418
+ fs.renameSync(hubDir, personasDir);
419
+ return personasDir;
420
+ }
421
+ } catch {
422
+ console.log(chalk.yellow(` Could not rename. Keeping "${dirName}".`));
423
+ return hubDir;
424
+ }
425
+ }
426
+
427
+ return hubDir;
428
+ }
429
+
273
430
  // ---------------------------------------------------------------------------
274
431
  // Path 1: Create a new personas repo (Architect)
275
432
  // ---------------------------------------------------------------------------
@@ -326,15 +483,18 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
326
483
  }
327
484
  }
328
485
 
329
- // Create directory if needed
330
- if (!fs.existsSync(hubDir)) {
331
- fs.mkdirSync(hubDir, { recursive: true });
332
- }
486
+ // Validate target directory if it exists and has content, offer to create
487
+ // a personas subdirectory instead of scaffolding into an existing repo.
488
+ hubDir = await validateHubTarget(hubDir);
333
489
 
334
- // Step 1.2: Org detection
335
- let orgName = opts.org ?? detection.gitOrg ?? null;
490
+ // Nudge toward the "personas" naming convention if the user chose a
491
+ // different name. This is educational, not enforced.
492
+ hubDir = await nudgePersonasConvention(hubDir);
336
493
 
337
- if (!orgName) {
494
+ // Step 1.2: Org detection — slug (machine identifier) and display name (human label)
495
+ let orgSlug = opts.org ?? detection.gitOrg ?? null;
496
+
497
+ if (!orgSlug) {
338
498
  // Try detecting from the hub dir's git remote
339
499
  try {
340
500
  const gitResult = spawnSync("git", ["remote", "get-url", "origin"], {
@@ -344,25 +504,39 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
344
504
  });
345
505
  if (gitResult.stdout) {
346
506
  const match = gitResult.stdout.trim().match(/[/:]([\w.-]+)\//);
347
- if (match) orgName = match[1]!;
507
+ if (match) orgSlug = match[1]!;
348
508
  }
349
509
  } catch { /* no git */ }
350
510
  }
351
511
 
352
- if (orgName) {
512
+ if (orgSlug) {
353
513
  const useDetected = await confirm({
354
- message: `Use "${orgName}" as your org name?`,
514
+ message: `Use "${orgSlug}" as your org identifier?`,
355
515
  default: true,
356
516
  });
357
517
  if (!useDetected) {
358
- orgName = await input({ message: "Org name:" });
518
+ orgSlug = await input({ message: "Org identifier (lowercase, used in package names and paths):" });
359
519
  }
360
520
  } else {
361
- orgName = await input({
362
- message: "Org name (GitHub org or username):",
521
+ orgSlug = await input({
522
+ message: "Org identifier (GitHub org, username, or slug — lowercase, no spaces):",
363
523
  });
364
524
  }
365
525
 
526
+ // Normalize slug: lowercase, replace spaces with hyphens
527
+ orgSlug = orgSlug.toLowerCase().replace(/\s+/g, "-");
528
+
529
+ // Derive a default display name from the slug
530
+ const defaultDisplayName = orgSlug
531
+ .split(/[-_]/)
532
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
533
+ .join(" ");
534
+
535
+ const orgDisplayName = await input({
536
+ message: "Org display name (shown in compiled output):",
537
+ default: defaultDisplayName,
538
+ });
539
+
366
540
  // Step 1.3: Scan for existing content nearby (with permission)
367
541
  const shouldScan = await confirm({
368
542
  message: "Scan nearby directories for existing AI agent content?",
@@ -370,7 +544,7 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
370
544
  });
371
545
 
372
546
  if (shouldScan) {
373
- const siblings = scanSiblings(hubDir !== cwd ? cwd : hubDir);
547
+ const siblings = scanNearby(hubDir !== cwd ? cwd : hubDir);
374
548
  const claudeSiblings = siblings.filter(s => s.type === "claude");
375
549
 
376
550
  if (claudeSiblings.length > 0) {
@@ -390,9 +564,9 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
390
564
  }
391
565
 
392
566
  // Step 1.4: Scaffold
393
- console.log(chalk.bold(`\n Creating personas repo for ${orgName}...\n`));
567
+ console.log(chalk.bold(`\n Creating personas repo for ${orgDisplayName}...\n`));
394
568
 
395
- scaffoldHub(hubDir, orgName);
569
+ scaffoldHub(hubDir, orgSlug, orgDisplayName);
396
570
 
397
571
  console.log(chalk.green(" Source code:"));
398
572
  console.log(chalk.gray(" core/personas/ 4 personas (code-reviewer, security-reviewer, ...)"));
@@ -400,29 +574,72 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
400
574
  console.log(chalk.gray(" core/instructions/ 2 always-on instruction sets"));
401
575
  console.log(chalk.gray(" core/gotchas/ (empty — add domain knowledge here)"));
402
576
  console.log(chalk.green("\n Build configuration:"));
403
- console.log(chalk.gray(` agentboot.config.json org: "${orgName}"`));
577
+ console.log(chalk.gray(` agentboot.config.json org: "${orgSlug}", displayName: "${orgDisplayName}"`));
404
578
  console.log(chalk.gray(" repos.json (empty — register your repos here)"));
405
579
 
406
- // Step 1.5: Auto-build
407
- const hasNodeModules = fs.existsSync(path.join(hubDir, "node_modules"));
408
- if (hasNodeModules) {
409
- runBuild(hubDir);
580
+ // Step 1.5: Build
581
+ //
582
+ // AgentBoot is a build tool. The personas repo contains source code (traits,
583
+ // personas, instructions) that gets compiled into deployable output. This is
584
+ // like compiling TypeScript to JavaScript — the source is what you edit, the
585
+ // output is what gets deployed.
586
+ //
587
+ // If the user is running `agentboot install`, then agentboot is already
588
+ // available (globally or via npx). We can always attempt a build.
589
+
590
+ let buildSucceeded = false;
591
+ const shouldBuild = await confirm({
592
+ message: "Compile personas now? (builds the deployable output)",
593
+ default: true,
594
+ });
595
+
596
+ if (shouldBuild) {
597
+ buildSucceeded = runBuild(hubDir);
410
598
  } else {
411
- console.log(chalk.gray("\n Run `npm install && agentboot build` to compile personas."));
599
+ console.log(chalk.gray(
600
+ "\n You can compile later by running:\n\n" +
601
+ ` cd ${hubDir}\n` +
602
+ " agentboot build\n"
603
+ ));
412
604
  }
413
605
 
414
606
  // Step 1.6: Register first repo (optional)
607
+ //
608
+ // A "target repo" is any codebase where you want AI agent governance.
609
+ // Registering it adds it to repos.json — the list of repos that receive
610
+ // compiled personas when you run `agentboot sync`.
611
+ //
612
+ // The personas repo and target repos can be anywhere on your filesystem.
613
+ // They don't need to be siblings or in the same parent directory.
614
+
615
+ let registeredRepo = false;
616
+ let registeredRepoName = "";
617
+ let registeredRepoPath = "";
618
+
619
+ console.log(chalk.bold("\n Register a target repo\n"));
620
+ console.log(chalk.gray(
621
+ " A target repo is any codebase where you want AgentBoot personas deployed.\n" +
622
+ " It can be anywhere on your filesystem — it does not need to be next to\n" +
623
+ " this personas repo.\n"
624
+ ));
625
+
415
626
  const registerRepo = await confirm({
416
627
  message: "Register your first target repo now?",
417
628
  default: true,
418
629
  });
419
630
 
420
631
  if (registerRepo) {
421
- const repoPathInput = await input(
422
- detection.looksLikeCodeRepo
423
- ? { message: "Path to a local repo:", default: cwd }
424
- : { message: "Path to a local repo:" }
425
- );
632
+ let promptOpts: { message: string; default?: string };
633
+ if (detection.looksLikeCodeRepo) {
634
+ promptOpts = {
635
+ message: `Path to target repo (absolute or relative):`,
636
+ default: cwd,
637
+ };
638
+ } else {
639
+ promptOpts = { message: "Path to target repo (absolute or relative):" };
640
+ }
641
+
642
+ const repoPathInput = await input(promptOpts);
426
643
  const repoPath = path.resolve(repoPathInput);
427
644
 
428
645
  if (!fs.existsSync(repoPath)) {
@@ -452,51 +669,76 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
452
669
  repos.push({ path: repoPath, label: repoName });
453
670
  fs.writeFileSync(reposJsonPath, JSON.stringify(repos, null, 2) + "\n", "utf-8");
454
671
  console.log(chalk.green(`\n Added ${repoName} to repos.json.`));
672
+ registeredRepo = true;
673
+ registeredRepoName = repoName;
674
+ registeredRepoPath = repoPath;
455
675
 
456
676
  // Check for existing .claude/ content
457
677
  if (fs.existsSync(path.join(repoPath, ".claude"))) {
458
678
  console.log(chalk.gray(
459
- ` This repo has existing .claude/ content. On first sync, it will be\n` +
460
- ` archived to .claude/.agentboot-archive/. You can restore it with\n` +
461
- ` agentboot uninstall.`
679
+ `\n This repo has existing .claude/ content. On first sync, AgentBoot\n` +
680
+ ` will archive it to .claude/.agentboot-archive/ before deploying.\n` +
681
+ ` You can restore the original content anytime with: agentboot uninstall`
462
682
  ));
463
683
  }
464
684
 
465
- // Offer to sync
466
- if (!opts.noSync && hasNodeModules && fs.existsSync(path.join(hubDir, "dist"))) {
685
+ // Offer to sync — only if build succeeded (dist/ exists)
686
+ if (!opts.noSync && buildSucceeded && fs.existsSync(path.join(hubDir, "dist"))) {
687
+ console.log(chalk.gray(
688
+ `\n Sync deploys the compiled personas to ${repoName}'s .claude/ directory.\n` +
689
+ ` This writes files locally — it does not commit or push. You review\n` +
690
+ ` the output before committing.`
691
+ ));
692
+
467
693
  const shouldSync = await confirm({
468
- message: "Deploy personas to this repo now?",
694
+ message: `Deploy personas to ${repoName} now?`,
469
695
  default: true,
470
696
  });
471
697
 
472
698
  if (shouldSync) {
473
699
  console.log(chalk.cyan("\n Syncing..."));
474
700
  if (runSync(hubDir)) {
475
- console.log(chalk.green("\n Personas deployed. Try: /review-code"));
701
+ console.log(chalk.green(`\n Personas deployed to ${repoPath}/.claude/`));
702
+ console.log(chalk.gray(
703
+ `\n To activate them, commit the .claude/ directory:\n\n` +
704
+ ` cd ${repoPath}\n` +
705
+ ` git add .claude/\n` +
706
+ ` git commit -m "chore: deploy AgentBoot personas"\n\n` +
707
+ ` Then open Claude Code in that repo and try: /review-code`
708
+ ));
476
709
  }
477
710
  }
711
+ } else if (!buildSucceeded) {
712
+ console.log(chalk.gray(
713
+ `\n Repo registered. To deploy personas, build first:\n\n` +
714
+ ` cd ${hubDir}\n` +
715
+ ` agentboot build && agentboot sync`
716
+ ));
478
717
  }
479
718
  }
480
719
  }
481
720
 
482
- // Step 1.7: Governance recommendations
483
- console.log(chalk.bold("\n Recommendations for your personas repo:\n"));
484
- console.log(chalk.gray(
485
- " Branch protection:\n" +
486
- " Enable branch protection on 'main' with required PR reviews.\n" +
487
- " Persona changes should go through code review.\n"
488
- ));
489
- console.log(chalk.gray(
490
- " Contributing path:\n" +
491
- " Developers who use personas daily are your best contributors.\n" +
492
- " A low-friction PR workflow lets them improve the prompts they know best.\n"
493
- ));
494
- console.log(chalk.gray(
495
- " CI validation:\n" +
496
- " Add `agentboot validate --strict` to your CI pipeline.\n"
497
- ));
721
+ // Step 1.7: Summary and next steps
722
+ //
723
+ // Context-aware: the summary reflects what actually happened during install,
724
+ // so the user knows exactly where they are and what to do next.
725
+
726
+ console.log(chalk.bold("\n ─────────────────────────────────────────────"));
727
+ console.log(chalk.bold(`\n ${chalk.green("✓")} AgentBoot setup complete\n`));
728
+
729
+ // What was created
730
+ console.log(chalk.cyan(" What was created:\n"));
731
+ console.log(chalk.gray(` Personas repo: ${hubDir}`));
732
+ console.log(chalk.gray(` Config: ${hubDir}/agentboot.config.json`));
733
+ console.log(chalk.gray(` Org: ${orgSlug} (${orgDisplayName})`));
734
+ if (buildSucceeded) {
735
+ console.log(chalk.gray(` Compiled output: ${hubDir}/dist/`));
736
+ }
737
+ if (registeredRepo) {
738
+ console.log(chalk.gray(` Target repo: ${registeredRepoPath} (${registeredRepoName})`));
739
+ }
498
740
 
499
- // Step 1.8: Detect whether hub has a remote
741
+ // Remote status
500
742
  let hubHasRemote = false;
501
743
  try {
502
744
  const remoteResult = spawnSync("git", ["remote", "get-url", "origin"], {
@@ -507,22 +749,47 @@ async function path1CreateHub(cwd: string, opts: InstallOptions, detection: Dete
507
749
  hubHasRemote = remoteResult.status === 0 && !!remoteResult.stdout?.trim();
508
750
  } catch { /* no git or no remote */ }
509
751
 
510
- // Step 1.9: Next steps
511
- console.log(chalk.bold(`${chalk.green("✓")} Persona source code lives at: ${hubDir}\n`));
752
+ if (!hubHasRemote) {
753
+ console.log(chalk.gray(" Remote: none (local only fine for evaluation)"));
754
+ }
755
+
756
+ // Context-aware next steps
757
+ console.log(chalk.cyan("\n What to do next:\n"));
758
+
759
+ let step = 1;
760
+
761
+ if (!buildSucceeded) {
762
+ console.log(chalk.gray(` ${step}. Build personas: cd ${hubDir} && agentboot build`));
763
+ step++;
764
+ }
765
+
766
+ if (!registeredRepo) {
767
+ console.log(chalk.gray(` ${step}. Register a repo: agentboot install (from your code repo)`));
768
+ console.log(chalk.gray(` Or edit: ${hubDir}/repos.json`));
769
+ step++;
770
+ } else if (buildSucceeded && !fs.existsSync(path.join(registeredRepoPath, ".claude", ".agentboot-manifest.json"))) {
771
+ console.log(chalk.gray(` ${step}. Deploy personas: cd ${hubDir} && agentboot sync`));
772
+ step++;
773
+ }
774
+
775
+ console.log(chalk.gray(` ${step}. Try it out: Open your repo in Claude Code and run /review-code`));
776
+ step++;
777
+ console.log(chalk.gray(` ${step}. Customize: Edit personas in ${hubDir}/core/personas/`));
778
+ step++;
779
+ console.log(chalk.gray(` ${step}. Import existing: agentboot import --path <dir>`));
780
+ step++;
512
781
 
513
782
  if (!hubHasRemote) {
514
- console.log(chalk.cyan(
515
- " This repo has no remote — that's fine for evaluation.\n" +
516
- " Everything works locally. When your org is ready:\n\n" +
517
- " gh repo create <org>/personas --source . --private --push\n"
518
- ));
783
+ console.log(chalk.gray(` ${step}. Push when ready: gh repo create ${orgSlug}/personas --source . --private --push`));
784
+ step++;
519
785
  }
520
786
 
521
- console.log(chalk.gray(" Next steps:"));
522
- console.log(chalk.gray(" 1. Review personas: Browse core/personas/ and customize"));
523
- console.log(chalk.gray(" 2. Add more repos: Edit repos.json or run `agentboot install` from a repo"));
524
- console.log(chalk.gray(" 3. Import existing: agentboot import --path ~/work/"));
525
- console.log(chalk.gray(" 4. Set up CI: agentboot validate --strict in your pipeline\n"));
787
+ // Governance — brief, not a wall
788
+ console.log(chalk.cyan("\n Governance tips:\n"));
789
+ console.log(chalk.gray(" - Enable branch protection on main (persona changes deserve review)"));
790
+ console.log(chalk.gray(" - Add `agentboot validate --strict` to CI"));
791
+ console.log(chalk.gray(" - Encourage developers to contribute they know the prompts best"));
792
+ console.log("");
526
793
  }
527
794
 
528
795
  // ---------------------------------------------------------------------------
@@ -537,7 +804,7 @@ async function path2ConnectToHub(cwd: string, opts: InstallOptions, detection: D
537
804
  console.log(chalk.gray("\n Looking for your org's personas repo...\n"));
538
805
 
539
806
  // Check siblings
540
- const siblings = scanSiblings(cwd);
807
+ const siblings = scanNearby(cwd);
541
808
  const hubSiblings = siblings.filter(s => s.type === "hub");
542
809
 
543
810
  if (hubSiblings.length === 1) {
package/scripts/sync.ts CHANGED
@@ -595,15 +595,21 @@ function validateRepoEntry(entry: RepoEntry, config: AgentBootConfig): string[]
595
595
  );
596
596
  }
597
597
 
598
- // Validate repo path safety
598
+ // Validate repo path safety — resolve symlinks to check the real target
599
599
  const resolvedPath = path.resolve(entry.path);
600
+ let realPath = resolvedPath;
601
+ try {
602
+ if (fs.existsSync(resolvedPath)) {
603
+ realPath = fs.realpathSync(resolvedPath);
604
+ }
605
+ } catch { /* permission denied or broken symlink — use resolved path */ }
600
606
  const dangerousPaths = ["/", "/etc", "/usr", "/var", "/tmp", "/home", "/root", "/bin", "/sbin", "/lib", "/opt"];
601
- if (dangerousPaths.includes(resolvedPath)) {
607
+ if (dangerousPaths.includes(realPath)) {
602
608
  errors.push(
603
- `[${label}] Repo path "${resolvedPath}" is a system directory — refusing to sync`
609
+ `[${label}] Repo path "${realPath}" resolves to a system directory — refusing to sync`
604
610
  );
605
611
  }
606
- if (fs.existsSync(resolvedPath) && !fs.existsSync(path.join(resolvedPath, ".git"))) {
612
+ if (fs.existsSync(realPath) && !fs.existsSync(path.join(realPath, ".git"))) {
607
613
  // Warn but don't block — temp dirs in tests and some workflows don't have .git
608
614
  console.warn(
609
615
  chalk.yellow(` ⚠ [${label}] Repo path "${resolvedPath}" has no .git directory — is this a git repo?`)
@@ -298,7 +298,7 @@ function checkSkillFrontmatter(config: AgentBootConfig, configDir: string): Chec
298
298
  * Detect regex patterns likely to cause catastrophic backtracking.
299
299
  * Rejects patterns with nested quantifiers like (a+)+, (a*)*b, etc.
300
300
  */
301
- function isUnsafeRegex(pattern: string): boolean {
301
+ export function isUnsafeRegex(pattern: string): boolean {
302
302
  // Reject patterns longer than 200 chars
303
303
  if (pattern.length > 200) return true;
304
304
  // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc.
@@ -308,7 +308,7 @@ function isUnsafeRegex(pattern: string): boolean {
308
308
  return false;
309
309
  }
310
310
 
311
- function buildSecretPatterns(config: AgentBootConfig): RegExp[] {
311
+ export function buildSecretPatterns(config: AgentBootConfig): RegExp[] {
312
312
  const configPatterns: RegExp[] = [];
313
313
  for (const p of config.validation?.secretPatterns ?? []) {
314
314
  if (isUnsafeRegex(p)) {
@@ -438,7 +438,11 @@ async function main(): Promise<void> {
438
438
  }
439
439
  }
440
440
 
441
- main().catch((err: unknown) => {
442
- console.error(chalk.red("Unexpected error:"), err);
443
- process.exit(1);
444
- });
441
+ // Only run main() when executed directly, not when imported for testing
442
+ const isDirectRun = process.argv[1]?.includes("validate");
443
+ if (isDirectRun) {
444
+ main().catch((err: unknown) => {
445
+ console.error(chalk.red("Unexpected error:"), err);
446
+ process.exit(1);
447
+ });
448
+ }
@@ -75,7 +75,7 @@ function Features() {
75
75
  />
76
76
  <Feature
77
77
  title="Convention Over Configuration"
78
- description="Sensible defaults for everything. Edit one config file. The Spring Boot of AI agent governance."
78
+ description="Sensible defaults for everything. Edit one config file. Bootstrap your agentic development teams."
79
79
  />
80
80
  </div>
81
81
  </section>