@codeharbor/agent-playbook 0.1.2 → 0.2.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 (3) hide show
  1. package/README.md +17 -3
  2. package/package.json +4 -3
  3. package/src/cli.js +1323 -31
package/src/cli.js CHANGED
@@ -10,6 +10,8 @@ const SKILLS_DIR_NAME = "skills";
10
10
  const DEFAULT_SESSION_DIR = "sessions";
11
11
  const LOCAL_CLI_DIR = "agent-playbook";
12
12
  const HOOK_SOURCE_VALUE = "agent-playbook";
13
+ const STATE_FILE_NAME = "state.json";
14
+ const DISABLED_DIR_NAME = ".disabled";
13
15
 
14
16
  const packageJson = readJsonSafe(path.join(__dirname, "..", "package.json"));
15
17
  const VERSION = packageJson.version || "0.0.0";
@@ -34,6 +36,8 @@ function main(argv, context) {
34
36
  return handleSessionLog(options);
35
37
  case "self-improve":
36
38
  return handleSelfImprove(options);
39
+ case "skills":
40
+ return handleSkills(options, parsed.positionals, context);
37
41
  case "help":
38
42
  case "--help":
39
43
  case "-h":
@@ -53,6 +57,7 @@ function printHelp() {
53
57
  ` ${APP_NAME} doctor [--project] [--repo <path>]`,
54
58
  ` ${APP_NAME} repair [--project] [--overwrite] [--repo <path>]`,
55
59
  ` ${APP_NAME} uninstall [--project] [--repo <path>]`,
60
+ ` ${APP_NAME} skills [list|info|add|remove|enable|disable|doctor|sync|upgrade|export|import]`,
56
61
  "",
57
62
  "Hook commands:",
58
63
  ` ${APP_NAME} session-log [--session-dir <path>]`,
@@ -62,7 +67,18 @@ function printHelp() {
62
67
  }
63
68
 
64
69
  function parseArgs(argv) {
65
- const valueFlags = new Set(["session-dir", "repo", "transcript-path", "cwd", "hook-source"]);
70
+ const valueFlags = new Set([
71
+ "session-dir",
72
+ "repo",
73
+ "transcript-path",
74
+ "cwd",
75
+ "hook-source",
76
+ "scope",
77
+ "target",
78
+ "format",
79
+ "source",
80
+ "output",
81
+ ]);
66
82
  const options = {};
67
83
  const positionals = [];
68
84
  let command = null;
@@ -128,6 +144,7 @@ function handleInit(options, context) {
128
144
 
129
145
  ensureDir(settings.claudeSkillsDir, options["dry-run"]);
130
146
  ensureDir(settings.codexSkillsDir, options["dry-run"]);
147
+ ensureDir(settings.geminiSkillsDir, options["dry-run"]);
131
148
 
132
149
  const manifest = {
133
150
  name: APP_NAME,
@@ -138,16 +155,20 @@ function handleInit(options, context) {
138
155
  links: {
139
156
  claude: [],
140
157
  codex: [],
158
+ gemini: [],
141
159
  },
142
160
  };
143
161
 
144
162
  let claudeLinks = { created: [], skipped: [] };
145
163
  let codexLinks = { created: [], skipped: [] };
164
+ let geminiLinks = { created: [], skipped: [] };
146
165
  if (settings.skillsSource) {
147
166
  claudeLinks = linkSkills(settings.skillsSource, settings.claudeSkillsDir, options, overwriteState);
148
167
  codexLinks = linkSkills(settings.skillsSource, settings.codexSkillsDir, options, overwriteState);
168
+ geminiLinks = linkSkills(settings.skillsSource, settings.geminiSkillsDir, options, overwriteState);
149
169
  manifest.links.claude = claudeLinks.created;
150
170
  manifest.links.codex = codexLinks.created;
171
+ manifest.links.gemini = geminiLinks.created;
151
172
 
152
173
  if (!options["dry-run"]) {
153
174
  writeJson(path.join(settings.claudeSkillsDir, ".agent-playbook.json"), manifest);
@@ -164,7 +185,7 @@ function handleInit(options, context) {
164
185
 
165
186
  updateCodexConfig(settings, options);
166
187
 
167
- printInitSummary(settings, hooksEnabled, options, claudeLinks, codexLinks, warnings);
188
+ printInitSummary(settings, hooksEnabled, options, claudeLinks, codexLinks, geminiLinks, warnings);
168
189
  return Promise.resolve();
169
190
  }
170
191
 
@@ -200,6 +221,7 @@ function handleUninstall(options, context) {
200
221
  if (manifest && manifest.links) {
201
222
  removeLinks(manifest.links.claude || []);
202
223
  removeLinks(manifest.links.codex || []);
224
+ removeLinks(manifest.links.gemini || []);
203
225
  safeUnlink(manifestPath);
204
226
  } else {
205
227
  console.log("No manifest found. Skipping link removal.");
@@ -266,39 +288,84 @@ async function handleSelfImprove(options) {
266
288
  console.error(`Self-improvement entry saved to ${entryPath}`);
267
289
  }
268
290
 
291
+ function handleSkills(options, positionals, context) {
292
+ const settings = resolveSettings(options, context || {});
293
+ const subcommand = positionals[0] || "list";
294
+ const args = positionals.slice(1);
295
+
296
+ switch (subcommand) {
297
+ case "list":
298
+ return handleSkillsList(options, args, settings);
299
+ case "info":
300
+ return handleSkillsInfo(options, args, settings);
301
+ case "add":
302
+ return handleSkillsAdd(options, args, settings);
303
+ case "remove":
304
+ return handleSkillsRemove(options, args, settings);
305
+ case "enable":
306
+ return handleSkillsEnable(options, args, settings);
307
+ case "disable":
308
+ return handleSkillsDisable(options, args, settings);
309
+ case "doctor":
310
+ return handleSkillsDoctor(options, args, settings);
311
+ case "sync":
312
+ return handleSkillsSync(options, args, settings);
313
+ case "upgrade":
314
+ return handleSkillsUpgrade(options, args, settings);
315
+ case "export":
316
+ return handleSkillsExport(options, args, settings);
317
+ case "import":
318
+ return handleSkillsImport(options, args, settings);
319
+ default:
320
+ console.error(`Unknown skills subcommand: ${subcommand}`);
321
+ return Promise.resolve();
322
+ }
323
+ }
324
+
269
325
  function resolveSettings(options, context) {
270
326
  const cwd = process.cwd();
271
- const repoRoot = options.repo ? path.resolve(options.repo) : findRepoRoot(cwd);
327
+ const repoRootDetected = options.repo ? path.resolve(options.repo) : findRepoRoot(cwd);
272
328
  const cliRoot =
273
329
  context && context.cliPath ? path.resolve(path.dirname(context.cliPath), "..") : null;
274
- const skillsSource = resolveSkillsSource([repoRoot || cwd, cliRoot]);
330
+ const skillsSource = resolveSkillsSource([repoRootDetected || cwd, cliRoot]);
275
331
  const projectMode = Boolean(options.project);
276
332
 
277
333
  const envClaudeDir = process.env.AGENT_PLAYBOOK_CLAUDE_DIR;
278
334
  const envCodexDir = process.env.AGENT_PLAYBOOK_CODEX_DIR;
279
- const claudeDir = envClaudeDir
280
- ? path.resolve(envClaudeDir)
281
- : projectMode
282
- ? path.join(repoRoot || cwd, ".claude")
283
- : path.join(os.homedir(), ".claude");
284
- const codexDir = envCodexDir
285
- ? path.resolve(envCodexDir)
286
- : projectMode
287
- ? path.join(repoRoot || cwd, ".codex")
288
- : path.join(os.homedir(), ".codex");
335
+ const envGeminiDir = process.env.AGENT_PLAYBOOK_GEMINI_DIR;
336
+ const globalClaudeDir = envClaudeDir ? path.resolve(envClaudeDir) : path.join(os.homedir(), ".claude");
337
+ const globalCodexDir = envCodexDir ? path.resolve(envCodexDir) : path.join(os.homedir(), ".codex");
338
+ const globalGeminiDir = envGeminiDir ? path.resolve(envGeminiDir) : path.join(os.homedir(), ".gemini");
339
+ const projectRoot = repoRootDetected || cwd;
340
+ const projectClaudeDir = repoRootDetected ? path.join(repoRootDetected, ".claude") : null;
341
+ const projectCodexDir = repoRootDetected ? path.join(repoRootDetected, ".codex") : null;
342
+ const projectGeminiDir = repoRootDetected ? path.join(repoRootDetected, ".gemini") : null;
343
+ const claudeDir = projectMode ? path.join(projectRoot, ".claude") : globalClaudeDir;
344
+ const codexDir = projectMode ? path.join(projectRoot, ".codex") : globalCodexDir;
345
+ const geminiDir = projectMode ? path.join(projectRoot, ".gemini") : globalGeminiDir;
289
346
 
290
347
  return {
291
348
  cwd,
292
- repoRoot: repoRoot || cwd,
349
+ repoRoot: repoRootDetected || cwd,
350
+ repoRootDetected,
293
351
  skillsSource,
294
352
  projectMode,
295
353
  cliPath: context && context.cliPath ? context.cliPath : null,
296
354
  claudeDir,
297
355
  codexDir,
356
+ geminiDir,
357
+ globalClaudeDir,
358
+ globalCodexDir,
359
+ globalGeminiDir,
360
+ projectClaudeDir,
361
+ projectCodexDir,
362
+ projectGeminiDir,
298
363
  claudeSkillsDir: path.join(claudeDir, SKILLS_DIR_NAME),
299
364
  codexSkillsDir: path.join(codexDir, SKILLS_DIR_NAME),
365
+ geminiSkillsDir: path.join(geminiDir, SKILLS_DIR_NAME),
300
366
  claudeSettingsPath: path.join(claudeDir, "settings.json"),
301
367
  codexConfigPath: path.join(codexDir, "config.toml"),
368
+ statePath: path.join(globalClaudeDir, LOCAL_CLI_DIR, STATE_FILE_NAME),
302
369
  };
303
370
  }
304
371
 
@@ -352,7 +419,16 @@ function createOverwriteState(options) {
352
419
  function promptYesNo(question, defaultYes) {
353
420
  const suffix = defaultYes ? "[Y/n]" : "[y/N]";
354
421
  process.stdout.write(`${question} ${suffix} `);
355
- const answer = readLineSync().toLowerCase();
422
+ let answer = "";
423
+ try {
424
+ answer = readLineSync().toLowerCase();
425
+ } catch (error) {
426
+ if (error && error.code === "EAGAIN") {
427
+ console.error("Warning: unable to read prompt input; skipping overwrite.");
428
+ return defaultYes;
429
+ }
430
+ throw error;
431
+ }
356
432
  if (!answer) {
357
433
  return defaultYes;
358
434
  }
@@ -362,14 +438,42 @@ function promptYesNo(question, defaultYes) {
362
438
  function readLineSync() {
363
439
  const buffer = Buffer.alloc(1024);
364
440
  let input = "";
365
- while (true) {
366
- const bytes = fs.readSync(0, buffer, 0, buffer.length, null);
367
- if (bytes <= 0) {
368
- break;
441
+ const ttyPath = process.platform === "win32" ? null : "/dev/tty";
442
+ let fd = 0;
443
+ let shouldClose = false;
444
+
445
+ if (ttyPath) {
446
+ try {
447
+ fd = fs.openSync(ttyPath, "r");
448
+ shouldClose = true;
449
+ } catch (error) {
450
+ fd = 0;
451
+ }
452
+ }
453
+
454
+ try {
455
+ while (true) {
456
+ let bytes = 0;
457
+ try {
458
+ bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
459
+ } catch (error) {
460
+ throw error;
461
+ }
462
+ if (bytes <= 0) {
463
+ break;
464
+ }
465
+ input += buffer.toString("utf8", 0, bytes);
466
+ if (input.includes("\n")) {
467
+ break;
468
+ }
369
469
  }
370
- input += buffer.toString("utf8", 0, bytes);
371
- if (input.includes("\n")) {
372
- break;
470
+ } finally {
471
+ if (shouldClose) {
472
+ try {
473
+ fs.closeSync(fd);
474
+ } catch (error) {
475
+ // Best-effort close; ignore failures.
476
+ }
373
477
  }
374
478
  }
375
479
  return input.trim();
@@ -400,6 +504,7 @@ function linkSkills(sourceDir, targetDir, options, overwriteState) {
400
504
  const skipped = [];
401
505
  const overwritten = [];
402
506
  const state = overwriteState || createOverwriteState(options);
507
+ const installMode = resolveInstallMode(options, "link");
403
508
  const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
404
509
 
405
510
  entries.forEach((entry) => {
@@ -433,11 +538,11 @@ function linkSkills(sourceDir, targetDir, options, overwriteState) {
433
538
  }
434
539
 
435
540
  if (options["dry-run"]) {
436
- created.push({ source: skillDir, target: targetPath, mode: options.copy ? "copy" : "link", dryRun: true });
541
+ created.push({ source: skillDir, target: targetPath, mode: installMode, dryRun: true });
437
542
  return;
438
543
  }
439
544
 
440
- if (options.copy) {
545
+ if (installMode === "copy") {
441
546
  fs.cpSync(skillDir, targetPath, { recursive: true });
442
547
  created.push({ source: skillDir, target: targetPath, mode: "copy" });
443
548
  return;
@@ -456,6 +561,1171 @@ function linkSkills(sourceDir, targetDir, options, overwriteState) {
456
561
  return { created, skipped, overwritten };
457
562
  }
458
563
 
564
+ function buildSkillEnvironment(settings) {
565
+ const projectRoot = settings.repoRootDetected;
566
+ const scopeDirs = {
567
+ project: projectRoot
568
+ ? {
569
+ claude: path.join(projectRoot, ".claude", SKILLS_DIR_NAME),
570
+ codex: path.join(projectRoot, ".codex", SKILLS_DIR_NAME),
571
+ gemini: path.join(projectRoot, ".gemini", SKILLS_DIR_NAME),
572
+ }
573
+ : null,
574
+ global: {
575
+ claude: path.join(settings.globalClaudeDir, SKILLS_DIR_NAME),
576
+ codex: path.join(settings.globalCodexDir, SKILLS_DIR_NAME),
577
+ gemini: path.join(settings.globalGeminiDir, SKILLS_DIR_NAME),
578
+ },
579
+ };
580
+
581
+ return {
582
+ projectRoot,
583
+ scopeDirs,
584
+ statePath: settings.statePath,
585
+ skillsSource: settings.skillsSource,
586
+ };
587
+ }
588
+
589
+ function normalizeScopeList(scopeValue, projectRoot, defaultScope) {
590
+ const warnings = [];
591
+ const value = String(scopeValue || defaultScope || "both").toLowerCase();
592
+ let scopes = [];
593
+ if (value === "both" || value === "all") {
594
+ scopes = ["project", "global"];
595
+ } else if (value === "project" || value === "repo") {
596
+ scopes = ["project"];
597
+ } else if (value === "global") {
598
+ scopes = ["global"];
599
+ } else {
600
+ warnings.push(`Unknown scope "${scopeValue}", defaulting to both.`);
601
+ scopes = ["project", "global"];
602
+ }
603
+
604
+ if (!projectRoot) {
605
+ if (scopes.includes("project")) {
606
+ warnings.push("Project scope requested but no repo root detected; skipping project scope.");
607
+ }
608
+ scopes = scopes.filter((scope) => scope !== "project");
609
+ }
610
+
611
+ if (!scopes.length) {
612
+ scopes = ["global"];
613
+ }
614
+
615
+ return { scopes, warnings };
616
+ }
617
+
618
+ function normalizeTargetList(targetValue, defaultTarget) {
619
+ const warnings = [];
620
+ const value = String(targetValue || defaultTarget || "both").toLowerCase();
621
+ let targets = [];
622
+ if (value === "both" || value === "all") {
623
+ targets = ["claude", "codex", "gemini"];
624
+ } else if (value === "claude" || value === "codex" || value === "gemini") {
625
+ targets = [value];
626
+ } else {
627
+ warnings.push(`Unknown target "${targetValue}", defaulting to all.`);
628
+ targets = ["claude", "codex", "gemini"];
629
+ }
630
+ return { targets, warnings };
631
+ }
632
+
633
+ function resolveInstallMode(options, fallbackMode) {
634
+ if (options && options.link) {
635
+ return "link";
636
+ }
637
+ if (options && options.copy) {
638
+ return "copy";
639
+ }
640
+ return fallbackMode || "link";
641
+ }
642
+
643
+ function createEmptyState() {
644
+ return {
645
+ version: "1",
646
+ updated_at: new Date().toISOString(),
647
+ skills: [],
648
+ };
649
+ }
650
+
651
+ function loadStateFile(statePath) {
652
+ if (!fs.existsSync(statePath)) {
653
+ return createEmptyState();
654
+ }
655
+ try {
656
+ const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
657
+ if (!parsed || typeof parsed !== "object") {
658
+ throw new Error("Invalid state file");
659
+ }
660
+ if (!Array.isArray(parsed.skills)) {
661
+ parsed.skills = [];
662
+ }
663
+ if (!parsed.version) {
664
+ parsed.version = "1";
665
+ }
666
+ if (!parsed.updated_at) {
667
+ parsed.updated_at = new Date().toISOString();
668
+ }
669
+ return parsed;
670
+ } catch (error) {
671
+ console.error("Warning: unable to parse state.json, recreating state file.");
672
+ return createEmptyState();
673
+ }
674
+ }
675
+
676
+ function saveStateFile(statePath, state, dryRun) {
677
+ if (dryRun) {
678
+ return;
679
+ }
680
+ ensureDir(path.dirname(statePath), false);
681
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
682
+ }
683
+
684
+ function stateKey(name, scope, target) {
685
+ return `${target}:${scope}:${name}`;
686
+ }
687
+
688
+ function indexStateEntries(state) {
689
+ const map = new Map();
690
+ (state.skills || []).forEach((entry) => {
691
+ if (!entry || !entry.name) {
692
+ return;
693
+ }
694
+ map.set(stateKey(entry.name, entry.scope, entry.target), entry);
695
+ });
696
+ return map;
697
+ }
698
+
699
+ function listBuiltInSkills(skillsSource) {
700
+ if (!skillsSource || !fs.existsSync(skillsSource)) {
701
+ return [];
702
+ }
703
+ return fs
704
+ .readdirSync(skillsSource, { withFileTypes: true })
705
+ .filter((entry) => entry.isDirectory())
706
+ .map((entry) => entry.name)
707
+ .filter((name) => fs.existsSync(path.join(skillsSource, name, "SKILL.md")))
708
+ .sort();
709
+ }
710
+
711
+ function resolveSkillInput(input, env) {
712
+ if (!input) {
713
+ return { error: "Missing skill name or path." };
714
+ }
715
+
716
+ const resolvedPath = path.resolve(input);
717
+ if (fs.existsSync(resolvedPath)) {
718
+ const stat = fs.statSync(resolvedPath);
719
+ const skillDir = stat.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
720
+ const skillFile = path.join(skillDir, "SKILL.md");
721
+ if (!fs.existsSync(skillFile)) {
722
+ return { error: `SKILL.md not found in ${skillDir}` };
723
+ }
724
+ return { name: path.basename(skillDir), sourceDir: skillDir, kind: "path" };
725
+ }
726
+
727
+ const skillsSource = env.skillsSource;
728
+ if (!skillsSource) {
729
+ return { error: "No bundled skills directory found. Use a local path instead." };
730
+ }
731
+
732
+ const candidate = path.join(skillsSource, input);
733
+ if (!fs.existsSync(path.join(candidate, "SKILL.md"))) {
734
+ const available = listBuiltInSkills(skillsSource);
735
+ const sample = available.length ? ` Available: ${available.join(", ")}` : "";
736
+ return { error: `Skill "${input}" not found in bundled skills.${sample}` };
737
+ }
738
+
739
+ return { name: input, sourceDir: candidate, kind: "name" };
740
+ }
741
+
742
+ function scanSkills(scopeDirs, scopes, targets, stateIndex) {
743
+ const records = [];
744
+ const warnings = [];
745
+
746
+ scopes.forEach((scope) => {
747
+ const dirs = scopeDirs[scope];
748
+ if (!dirs) {
749
+ warnings.push(`Scope "${scope}" not available.`);
750
+ return;
751
+ }
752
+ targets.forEach((target) => {
753
+ const dirPath = dirs[target];
754
+ if (!dirPath) {
755
+ warnings.push(`Target "${target}" not available for scope "${scope}".`);
756
+ return;
757
+ }
758
+ records.push(...scanSkillDir(dirPath, scope, target, stateIndex));
759
+ });
760
+ });
761
+
762
+ markDuplicates(records);
763
+ return { records, warnings };
764
+ }
765
+
766
+ function scanSkillDir(dirPath, scope, target, stateIndex) {
767
+ if (!dirPath || !fs.existsSync(dirPath)) {
768
+ return [];
769
+ }
770
+
771
+ const records = [];
772
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
773
+ const disabledDir = path.join(dirPath, DISABLED_DIR_NAME);
774
+
775
+ entries.forEach((entry) => {
776
+ if (entry.name.startsWith(".") || entry.name === DISABLED_DIR_NAME) {
777
+ return;
778
+ }
779
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) {
780
+ return;
781
+ }
782
+ records.push(buildSkillRecord(entry.name, path.join(dirPath, entry.name), scope, target, false, stateIndex));
783
+ });
784
+
785
+ if (fs.existsSync(disabledDir)) {
786
+ const disabledEntries = fs.readdirSync(disabledDir, { withFileTypes: true });
787
+ disabledEntries.forEach((entry) => {
788
+ if (entry.name.startsWith(".")) {
789
+ return;
790
+ }
791
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) {
792
+ return;
793
+ }
794
+ records.push(
795
+ buildSkillRecord(entry.name, path.join(disabledDir, entry.name), scope, target, true, stateIndex)
796
+ );
797
+ });
798
+ }
799
+
800
+ return records;
801
+ }
802
+
803
+ function buildSkillRecord(name, entryPath, scope, target, disabled, stateIndex) {
804
+ const record = {
805
+ name,
806
+ scope,
807
+ target,
808
+ path: entryPath,
809
+ mode: "unknown",
810
+ status: "ok",
811
+ disabled: Boolean(disabled),
812
+ managed: false,
813
+ source: "",
814
+ duplicate: false,
815
+ };
816
+
817
+ try {
818
+ const stat = fs.lstatSync(entryPath);
819
+ if (stat.isSymbolicLink()) {
820
+ record.mode = "link";
821
+ try {
822
+ record.source = fs.realpathSync(entryPath);
823
+ } catch (error) {
824
+ record.status = "broken";
825
+ }
826
+ } else if (stat.isDirectory()) {
827
+ record.mode = "copy";
828
+ } else {
829
+ record.status = "missing";
830
+ }
831
+ } catch (error) {
832
+ record.status = "missing";
833
+ }
834
+
835
+ if (record.status === "ok") {
836
+ const skillFile = path.join(entryPath, "SKILL.md");
837
+ if (!fs.existsSync(skillFile)) {
838
+ record.status = "missing-skill-file";
839
+ }
840
+ }
841
+
842
+ if (record.disabled) {
843
+ record.status = "disabled";
844
+ }
845
+
846
+ if (stateIndex) {
847
+ const entry = stateIndex.get(stateKey(name, scope, target));
848
+ if (entry) {
849
+ record.managed = true;
850
+ if (entry.source && !record.source) {
851
+ record.source = entry.source;
852
+ }
853
+ if (entry.mode && record.mode === "unknown") {
854
+ record.mode = entry.mode;
855
+ }
856
+ }
857
+ }
858
+
859
+ return record;
860
+ }
861
+
862
+ function markDuplicates(records) {
863
+ const counts = new Map();
864
+ records.forEach((record) => {
865
+ const key = `${record.target}:${record.name}`;
866
+ counts.set(key, (counts.get(key) || 0) + 1);
867
+ });
868
+ records.forEach((record) => {
869
+ const key = `${record.target}:${record.name}`;
870
+ if (counts.get(key) > 1) {
871
+ record.duplicate = true;
872
+ }
873
+ });
874
+ }
875
+
876
+ function formatSkillStatus(record) {
877
+ const tags = [];
878
+ if (record.status && record.status !== "ok") {
879
+ tags.push(record.status);
880
+ }
881
+ if (record.duplicate) {
882
+ tags.push("duplicate");
883
+ }
884
+ if (!tags.length) {
885
+ tags.push("ok");
886
+ }
887
+ return tags.join(",");
888
+ }
889
+
890
+ function printSkillList(records, format) {
891
+ const isJson = String(format || "").toLowerCase() === "json";
892
+ if (!records.length) {
893
+ if (isJson) {
894
+ console.log("[]");
895
+ return;
896
+ }
897
+ console.log("No skills found.");
898
+ return;
899
+ }
900
+
901
+ const sorted = [...records].sort((a, b) => {
902
+ if (a.name !== b.name) {
903
+ return a.name.localeCompare(b.name);
904
+ }
905
+ if (a.target !== b.target) {
906
+ return a.target.localeCompare(b.target);
907
+ }
908
+ return a.scope.localeCompare(b.scope);
909
+ });
910
+
911
+ if (isJson) {
912
+ console.log(JSON.stringify(sorted, null, 2));
913
+ return;
914
+ }
915
+
916
+ const headers = ["Name", "Target", "Scope", "Mode", "Status", "Managed", "Source", "Path"];
917
+ const rows = sorted.map((record) => [
918
+ record.name,
919
+ record.target,
920
+ record.scope,
921
+ record.mode,
922
+ formatSkillStatus(record),
923
+ record.managed ? "yes" : "no",
924
+ record.source || "-",
925
+ record.path,
926
+ ]);
927
+ const widths = headers.map((header, index) =>
928
+ Math.max(header.length, ...rows.map((row) => String(row[index]).length))
929
+ );
930
+
931
+ const formatRow = (row) =>
932
+ row
933
+ .map((cell, index) => {
934
+ const value = String(cell);
935
+ return index === row.length - 1 ? value : value.padEnd(widths[index]);
936
+ })
937
+ .join(" ");
938
+
939
+ console.log(formatRow(headers));
940
+ console.log(formatRow(headers.map((header) => "-".repeat(header.length))));
941
+ rows.forEach((row) => console.log(formatRow(row)));
942
+ }
943
+
944
+ function resolveSkillMatches(name, options, env, stateIndex, defaultScope) {
945
+ const scopeInfo = normalizeScopeList(options.scope, env.projectRoot, defaultScope || "both");
946
+ const targetInfo = normalizeTargetList(options.target, "both");
947
+ const scan = scanSkills(env.scopeDirs, scopeInfo.scopes, targetInfo.targets, stateIndex);
948
+ const matches = scan.records.filter((record) => record.name === name);
949
+ return { matches, scopeInfo, targetInfo, warnings: [...scopeInfo.warnings, ...targetInfo.warnings] };
950
+ }
951
+
952
+ function installSkill(sourceDir, targetPath, installOptions) {
953
+ const mode = installOptions && installOptions.mode ? installOptions.mode : "link";
954
+ const dryRun = installOptions && installOptions.dryRun;
955
+ const overwrite = installOptions && installOptions.overwrite;
956
+
957
+ if (overwrite && fs.existsSync(targetPath)) {
958
+ if (!dryRun) {
959
+ safeUnlink(targetPath);
960
+ }
961
+ }
962
+
963
+ if (dryRun) {
964
+ return { mode, dryRun: true };
965
+ }
966
+
967
+ ensureDir(path.dirname(targetPath), false);
968
+
969
+ if (mode === "copy") {
970
+ fs.cpSync(sourceDir, targetPath, { recursive: true });
971
+ return { mode: "copy" };
972
+ }
973
+
974
+ const linkType = process.platform === "win32" ? "junction" : "dir";
975
+ try {
976
+ fs.symlinkSync(sourceDir, targetPath, linkType);
977
+ return { mode: "link" };
978
+ } catch (error) {
979
+ fs.cpSync(sourceDir, targetPath, { recursive: true });
980
+ return { mode: "copy", fallback: "symlink_failed" };
981
+ }
982
+ }
983
+
984
+ function handleSkillsList(options, args, settings) {
985
+ const env = buildSkillEnvironment(settings);
986
+ const scopeInfo = normalizeScopeList(options.scope, env.projectRoot, "both");
987
+ const targetInfo = normalizeTargetList(options.target, "both");
988
+ const state = loadStateFile(env.statePath);
989
+ const stateIndex = indexStateEntries(state);
990
+
991
+ const scan = scanSkills(env.scopeDirs, scopeInfo.scopes, targetInfo.targets, stateIndex);
992
+ [...scopeInfo.warnings, ...targetInfo.warnings, ...scan.warnings].forEach((warning) =>
993
+ console.error(`Warning: ${warning}`)
994
+ );
995
+ printSkillList(scan.records, options.format);
996
+ return Promise.resolve();
997
+ }
998
+
999
+ function handleSkillsInfo(options, args, settings) {
1000
+ const name = args[0];
1001
+ if (!name) {
1002
+ console.error("Usage: agent-playbook skills info <name>");
1003
+ process.exitCode = 1;
1004
+ return Promise.resolve();
1005
+ }
1006
+
1007
+ const env = buildSkillEnvironment(settings);
1008
+ const scopeInfo = normalizeScopeList(options.scope, env.projectRoot, "both");
1009
+ const targetInfo = normalizeTargetList(options.target, "both");
1010
+ const state = loadStateFile(env.statePath);
1011
+ const stateIndex = indexStateEntries(state);
1012
+ const scan = scanSkills(env.scopeDirs, scopeInfo.scopes, targetInfo.targets, stateIndex);
1013
+ [...scopeInfo.warnings, ...targetInfo.warnings, ...scan.warnings].forEach((warning) =>
1014
+ console.error(`Warning: ${warning}`)
1015
+ );
1016
+
1017
+ const matches = scan.records.filter((record) => record.name === name);
1018
+ if (!matches.length) {
1019
+ console.error(`Skill not found: ${name}`);
1020
+ process.exitCode = 1;
1021
+ return Promise.resolve();
1022
+ }
1023
+
1024
+ printSkillList(matches, options.format);
1025
+ return Promise.resolve();
1026
+ }
1027
+
1028
+ function handleSkillsAdd(options, args, settings) {
1029
+ const input = args[0];
1030
+ if (!input) {
1031
+ console.error("Usage: agent-playbook skills add <name|path>");
1032
+ process.exitCode = 1;
1033
+ return Promise.resolve();
1034
+ }
1035
+
1036
+ const env = buildSkillEnvironment(settings);
1037
+ const defaultScope = env.projectRoot ? "project" : "global";
1038
+ const scopeInfo = normalizeScopeList(options.scope, env.projectRoot, defaultScope);
1039
+ const targetInfo = normalizeTargetList(options.target, "both");
1040
+ const resolved = resolveSkillInput(input, env);
1041
+ const installMode = resolveInstallMode(options, "link");
1042
+
1043
+ if (resolved.error) {
1044
+ console.error(resolved.error);
1045
+ process.exitCode = 1;
1046
+ return Promise.resolve();
1047
+ }
1048
+
1049
+ [...scopeInfo.warnings, ...targetInfo.warnings].forEach((warning) =>
1050
+ console.error(`Warning: ${warning}`)
1051
+ );
1052
+
1053
+ const state = loadStateFile(env.statePath);
1054
+ const stateIndex = indexStateEntries(state);
1055
+ const overwriteState = createOverwriteState(options);
1056
+ const now = new Date().toISOString();
1057
+ const created = [];
1058
+ const skipped = [];
1059
+
1060
+ scopeInfo.scopes.forEach((scope) => {
1061
+ const dirs = env.scopeDirs[scope];
1062
+ if (!dirs) {
1063
+ return;
1064
+ }
1065
+ targetInfo.targets.forEach((target) => {
1066
+ const targetDir = dirs[target];
1067
+ if (!targetDir) {
1068
+ return;
1069
+ }
1070
+ ensureDir(targetDir, options["dry-run"]);
1071
+ const targetPath = path.join(targetDir, resolved.name);
1072
+ if (fs.existsSync(targetPath)) {
1073
+ if (!shouldOverwriteExisting(options, overwriteState, targetPath)) {
1074
+ skipped.push({ scope, target, path: targetPath });
1075
+ return;
1076
+ }
1077
+ if (!options["dry-run"]) {
1078
+ safeUnlink(targetPath);
1079
+ }
1080
+ }
1081
+
1082
+ const install = installSkill(resolved.sourceDir, targetPath, {
1083
+ mode: installMode,
1084
+ dryRun: options["dry-run"],
1085
+ });
1086
+ created.push({ scope, target, path: targetPath, mode: install.mode });
1087
+
1088
+ const key = stateKey(resolved.name, scope, target);
1089
+ const entry = stateIndex.get(key) || {
1090
+ name: resolved.name,
1091
+ scope,
1092
+ target,
1093
+ managed_by: "apb",
1094
+ installed_at: now,
1095
+ };
1096
+ entry.source = resolved.sourceDir;
1097
+ entry.mode = install.mode;
1098
+ entry.disabled = false;
1099
+ entry.updated_at = now;
1100
+ stateIndex.set(key, entry);
1101
+ });
1102
+ });
1103
+
1104
+ state.skills = Array.from(stateIndex.values());
1105
+ state.updated_at = now;
1106
+ saveStateFile(env.statePath, state, options["dry-run"]);
1107
+
1108
+ console.log(`Added skill "${resolved.name}".`);
1109
+ if (created.length) {
1110
+ created.forEach((item) =>
1111
+ console.log(`- ${item.scope}/${item.target}: ${item.path} (${item.mode})`)
1112
+ );
1113
+ }
1114
+ if (skipped.length) {
1115
+ skipped.forEach((item) => console.log(`- Skipped ${item.scope}/${item.target}: ${item.path}`));
1116
+ }
1117
+ if (options["dry-run"]) {
1118
+ console.log("- Dry run: no changes written.");
1119
+ }
1120
+
1121
+ return Promise.resolve();
1122
+ }
1123
+
1124
+ function handleSkillsRemove(options, args, settings) {
1125
+ const name = args[0];
1126
+ if (!name) {
1127
+ console.error("Usage: agent-playbook skills remove <name>");
1128
+ process.exitCode = 1;
1129
+ return Promise.resolve();
1130
+ }
1131
+
1132
+ const env = buildSkillEnvironment(settings);
1133
+ const state = loadStateFile(env.statePath);
1134
+ const stateIndex = indexStateEntries(state);
1135
+ const matchesInfo = resolveSkillMatches(name, options, env, stateIndex, "both");
1136
+ matchesInfo.warnings.forEach((warning) => console.error(`Warning: ${warning}`));
1137
+
1138
+ const matches = matchesInfo.matches;
1139
+ const hasFilters = Boolean(options.scope || options.target);
1140
+ if (!matches.length) {
1141
+ const stateKeys = Array.from(stateIndex.keys()).filter((key) => key.endsWith(`:${name}`));
1142
+ if (stateKeys.length) {
1143
+ stateKeys.forEach((key) => stateIndex.delete(key));
1144
+ state.skills = Array.from(stateIndex.values());
1145
+ state.updated_at = new Date().toISOString();
1146
+ saveStateFile(env.statePath, state, options["dry-run"]);
1147
+ console.log(`Removed ${stateKeys.length} state entries for "${name}".`);
1148
+ return Promise.resolve();
1149
+ }
1150
+ console.error(`Skill not found: ${name}`);
1151
+ process.exitCode = 1;
1152
+ return Promise.resolve();
1153
+ }
1154
+
1155
+ if (!hasFilters && matches.length > 1) {
1156
+ console.error(`Multiple matches for "${name}". Use --scope or --target to disambiguate.`);
1157
+ matches.forEach((match) =>
1158
+ console.error(`- ${match.scope}/${match.target}: ${match.path}`)
1159
+ );
1160
+ process.exitCode = 1;
1161
+ return Promise.resolve();
1162
+ }
1163
+
1164
+ const removed = [];
1165
+ const skipped = [];
1166
+
1167
+ matches.forEach((match) => {
1168
+ const key = stateKey(match.name, match.scope, match.target);
1169
+ const managed = stateIndex.has(key);
1170
+ if (!managed && !options.force) {
1171
+ skipped.push({ scope: match.scope, target: match.target, path: match.path });
1172
+ return;
1173
+ }
1174
+ if (!options["dry-run"]) {
1175
+ safeUnlink(match.path);
1176
+ }
1177
+ stateIndex.delete(key);
1178
+ removed.push({ scope: match.scope, target: match.target, path: match.path });
1179
+ });
1180
+
1181
+ state.skills = Array.from(stateIndex.values());
1182
+ state.updated_at = new Date().toISOString();
1183
+ saveStateFile(env.statePath, state, options["dry-run"]);
1184
+
1185
+ if (removed.length) {
1186
+ removed.forEach((item) => console.log(`Removed ${item.scope}/${item.target}: ${item.path}`));
1187
+ }
1188
+ if (skipped.length) {
1189
+ skipped.forEach((item) =>
1190
+ console.log(`Skipped unmanaged ${item.scope}/${item.target}: ${item.path} (use --force)`)
1191
+ );
1192
+ }
1193
+ if (options["dry-run"]) {
1194
+ console.log("- Dry run: no changes written.");
1195
+ }
1196
+
1197
+ return Promise.resolve();
1198
+ }
1199
+
1200
+ function handleSkillsDisable(options, args, settings) {
1201
+ const name = args[0];
1202
+ if (!name) {
1203
+ console.error("Usage: agent-playbook skills disable <name>");
1204
+ process.exitCode = 1;
1205
+ return Promise.resolve();
1206
+ }
1207
+
1208
+ const env = buildSkillEnvironment(settings);
1209
+ const state = loadStateFile(env.statePath);
1210
+ const stateIndex = indexStateEntries(state);
1211
+ const matchesInfo = resolveSkillMatches(name, options, env, stateIndex, "both");
1212
+ matchesInfo.warnings.forEach((warning) => console.error(`Warning: ${warning}`));
1213
+
1214
+ const candidates = matchesInfo.matches.filter((match) => !match.disabled);
1215
+ if (!candidates.length) {
1216
+ console.error(`No enabled skill found for "${name}".`);
1217
+ process.exitCode = 1;
1218
+ return Promise.resolve();
1219
+ }
1220
+
1221
+ const hasFilters = Boolean(options.scope || options.target);
1222
+ if (!hasFilters && candidates.length > 1) {
1223
+ console.error(`Multiple matches for "${name}". Use --scope or --target to disambiguate.`);
1224
+ candidates.forEach((match) =>
1225
+ console.error(`- ${match.scope}/${match.target}: ${match.path}`)
1226
+ );
1227
+ process.exitCode = 1;
1228
+ return Promise.resolve();
1229
+ }
1230
+
1231
+ const overwriteState = createOverwriteState(options);
1232
+ const now = new Date().toISOString();
1233
+ const disabled = [];
1234
+
1235
+ candidates.forEach((match) => {
1236
+ const skillsRoot = path.dirname(match.path);
1237
+ const disabledDir = path.join(skillsRoot, DISABLED_DIR_NAME);
1238
+ const disabledPath = path.join(disabledDir, match.name);
1239
+ if (fs.existsSync(disabledPath)) {
1240
+ if (!shouldOverwriteExisting(options, overwriteState, disabledPath)) {
1241
+ return;
1242
+ }
1243
+ if (!options["dry-run"]) {
1244
+ safeUnlink(disabledPath);
1245
+ }
1246
+ }
1247
+ ensureDir(disabledDir, options["dry-run"]);
1248
+ if (!options["dry-run"]) {
1249
+ fs.renameSync(match.path, disabledPath);
1250
+ }
1251
+ disabled.push({ scope: match.scope, target: match.target, path: disabledPath });
1252
+
1253
+ const key = stateKey(match.name, match.scope, match.target);
1254
+ const entry = stateIndex.get(key);
1255
+ if (entry) {
1256
+ entry.disabled = true;
1257
+ entry.updated_at = now;
1258
+ }
1259
+ });
1260
+
1261
+ state.skills = Array.from(stateIndex.values());
1262
+ state.updated_at = now;
1263
+ saveStateFile(env.statePath, state, options["dry-run"]);
1264
+
1265
+ disabled.forEach((item) => console.log(`Disabled ${item.scope}/${item.target}: ${item.path}`));
1266
+ if (options["dry-run"]) {
1267
+ console.log("- Dry run: no changes written.");
1268
+ }
1269
+
1270
+ return Promise.resolve();
1271
+ }
1272
+
1273
+ function handleSkillsEnable(options, args, settings) {
1274
+ const name = args[0];
1275
+ if (!name) {
1276
+ console.error("Usage: agent-playbook skills enable <name>");
1277
+ process.exitCode = 1;
1278
+ return Promise.resolve();
1279
+ }
1280
+
1281
+ const env = buildSkillEnvironment(settings);
1282
+ const state = loadStateFile(env.statePath);
1283
+ const stateIndex = indexStateEntries(state);
1284
+ const matchesInfo = resolveSkillMatches(name, options, env, stateIndex, "both");
1285
+ matchesInfo.warnings.forEach((warning) => console.error(`Warning: ${warning}`));
1286
+
1287
+ const candidates = matchesInfo.matches.filter((match) => match.disabled);
1288
+ if (!candidates.length) {
1289
+ console.error(`No disabled skill found for "${name}".`);
1290
+ process.exitCode = 1;
1291
+ return Promise.resolve();
1292
+ }
1293
+
1294
+ const hasFilters = Boolean(options.scope || options.target);
1295
+ if (!hasFilters && candidates.length > 1) {
1296
+ console.error(`Multiple matches for "${name}". Use --scope or --target to disambiguate.`);
1297
+ candidates.forEach((match) =>
1298
+ console.error(`- ${match.scope}/${match.target}: ${match.path}`)
1299
+ );
1300
+ process.exitCode = 1;
1301
+ return Promise.resolve();
1302
+ }
1303
+
1304
+ const overwriteState = createOverwriteState(options);
1305
+ const now = new Date().toISOString();
1306
+ const enabled = [];
1307
+
1308
+ candidates.forEach((match) => {
1309
+ const skillsRoot = path.dirname(path.dirname(match.path));
1310
+ const targetPath = path.join(skillsRoot, match.name);
1311
+ if (fs.existsSync(targetPath)) {
1312
+ if (!shouldOverwriteExisting(options, overwriteState, targetPath)) {
1313
+ return;
1314
+ }
1315
+ if (!options["dry-run"]) {
1316
+ safeUnlink(targetPath);
1317
+ }
1318
+ }
1319
+ if (!options["dry-run"]) {
1320
+ fs.renameSync(match.path, targetPath);
1321
+ }
1322
+ enabled.push({ scope: match.scope, target: match.target, path: targetPath });
1323
+
1324
+ const key = stateKey(match.name, match.scope, match.target);
1325
+ const entry = stateIndex.get(key);
1326
+ if (entry) {
1327
+ entry.disabled = false;
1328
+ entry.updated_at = now;
1329
+ }
1330
+ });
1331
+
1332
+ state.skills = Array.from(stateIndex.values());
1333
+ state.updated_at = now;
1334
+ saveStateFile(env.statePath, state, options["dry-run"]);
1335
+
1336
+ enabled.forEach((item) => console.log(`Enabled ${item.scope}/${item.target}: ${item.path}`));
1337
+ if (options["dry-run"]) {
1338
+ console.log("- Dry run: no changes written.");
1339
+ }
1340
+
1341
+ return Promise.resolve();
1342
+ }
1343
+
1344
+ function checkSkillPath(targetPath) {
1345
+ if (!fs.existsSync(targetPath)) {
1346
+ return "missing";
1347
+ }
1348
+ try {
1349
+ const stat = fs.lstatSync(targetPath);
1350
+ if (stat.isSymbolicLink()) {
1351
+ try {
1352
+ fs.realpathSync(targetPath);
1353
+ } catch (error) {
1354
+ return "broken";
1355
+ }
1356
+ }
1357
+ const skillFile = path.join(targetPath, "SKILL.md");
1358
+ if (!fs.existsSync(skillFile)) {
1359
+ return "missing-skill-file";
1360
+ }
1361
+ } catch (error) {
1362
+ return "missing";
1363
+ }
1364
+ return "ok";
1365
+ }
1366
+
1367
+ function handleSkillsDoctor(options, args, settings) {
1368
+ const env = buildSkillEnvironment(settings);
1369
+ const scopeInfo = normalizeScopeList(options.scope, env.projectRoot, "both");
1370
+ const targetInfo = normalizeTargetList(options.target, "both");
1371
+ const state = loadStateFile(env.statePath);
1372
+ const stateIndex = indexStateEntries(state);
1373
+ const scan = scanSkills(env.scopeDirs, scopeInfo.scopes, targetInfo.targets, stateIndex);
1374
+
1375
+ const issues = [];
1376
+ const duplicateKeys = new Set();
1377
+
1378
+ scan.records.forEach((record) => {
1379
+ if (record.status === "broken" || record.status === "missing-skill-file") {
1380
+ issues.push(`${record.scope}/${record.target}/${record.name}: ${record.status}`);
1381
+ }
1382
+ if (record.duplicate) {
1383
+ const key = `${record.target}:${record.name}`;
1384
+ if (!duplicateKeys.has(key)) {
1385
+ duplicateKeys.add(key);
1386
+ issues.push(`${record.target}/${record.name}: duplicate across scopes`);
1387
+ }
1388
+ }
1389
+ if (!record.managed) {
1390
+ issues.push(`${record.scope}/${record.target}/${record.name}: unmanaged skill`);
1391
+ }
1392
+ });
1393
+
1394
+ state.skills.forEach((entry) => {
1395
+ const dirs = env.scopeDirs[entry.scope];
1396
+ if (!dirs) {
1397
+ return;
1398
+ }
1399
+ const root = dirs[entry.target];
1400
+ if (!root) {
1401
+ return;
1402
+ }
1403
+ const activePath = path.join(root, entry.name);
1404
+ const disabledPath = path.join(root, DISABLED_DIR_NAME, entry.name);
1405
+ const pathToCheck = entry.disabled ? disabledPath : activePath;
1406
+ const status = checkSkillPath(pathToCheck);
1407
+ if (status !== "ok") {
1408
+ issues.push(`${entry.scope}/${entry.target}/${entry.name}: managed entry ${status}`);
1409
+ }
1410
+ });
1411
+
1412
+ if (issues.length) {
1413
+ console.error("Issues detected:");
1414
+ issues.forEach((issue) => console.error(`- ${issue}`));
1415
+ process.exitCode = 1;
1416
+ } else {
1417
+ console.log("No critical issues detected.");
1418
+ }
1419
+
1420
+ if (options.fix) {
1421
+ const now = new Date().toISOString();
1422
+ const overwrite = true;
1423
+ let fixedCount = 0;
1424
+
1425
+ state.skills.forEach((entry) => {
1426
+ const dirs = env.scopeDirs[entry.scope];
1427
+ if (!dirs) {
1428
+ return;
1429
+ }
1430
+ const root = dirs[entry.target];
1431
+ if (!root) {
1432
+ return;
1433
+ }
1434
+ const activePath = path.join(root, entry.name);
1435
+ const disabledPath = path.join(root, DISABLED_DIR_NAME, entry.name);
1436
+ if (entry.disabled) {
1437
+ const activeStatus = checkSkillPath(activePath);
1438
+ const disabledStatus = checkSkillPath(disabledPath);
1439
+ if (activeStatus === "ok") {
1440
+ if (disabledStatus === "ok") {
1441
+ if (!options["dry-run"]) {
1442
+ safeUnlink(disabledPath);
1443
+ }
1444
+ }
1445
+ entry.disabled = false;
1446
+ entry.updated_at = now;
1447
+ fixedCount += 1;
1448
+ return;
1449
+ }
1450
+ if (disabledStatus === "ok") {
1451
+ return;
1452
+ }
1453
+ if (!fs.existsSync(path.dirname(disabledPath))) {
1454
+ ensureDir(path.dirname(disabledPath), options["dry-run"]);
1455
+ }
1456
+ if (entry.source && fs.existsSync(entry.source)) {
1457
+ installSkill(entry.source, disabledPath, {
1458
+ mode: entry.mode || "link",
1459
+ dryRun: options["dry-run"],
1460
+ overwrite,
1461
+ });
1462
+ entry.updated_at = now;
1463
+ fixedCount += 1;
1464
+ }
1465
+ return;
1466
+ }
1467
+ const activeStatus = checkSkillPath(activePath);
1468
+ if (activeStatus === "ok") {
1469
+ return;
1470
+ }
1471
+ if (entry.source && fs.existsSync(entry.source)) {
1472
+ installSkill(entry.source, activePath, {
1473
+ mode: entry.mode || "link",
1474
+ dryRun: options["dry-run"],
1475
+ overwrite,
1476
+ });
1477
+ entry.updated_at = now;
1478
+ fixedCount += 1;
1479
+ }
1480
+ });
1481
+
1482
+ state.updated_at = now;
1483
+ saveStateFile(env.statePath, state, options["dry-run"]);
1484
+ console.log(`Fixed ${fixedCount} managed entries.`);
1485
+ if (options["dry-run"]) {
1486
+ console.log("- Dry run: no changes written.");
1487
+ }
1488
+ }
1489
+
1490
+ return Promise.resolve();
1491
+ }
1492
+
1493
+ function handleSkillsSync(options, args, settings) {
1494
+ const env = buildSkillEnvironment(settings);
1495
+ const state = loadStateFile(env.statePath);
1496
+ const now = new Date().toISOString();
1497
+ let changed = false;
1498
+
1499
+ const nextSkills = [];
1500
+ state.skills.forEach((entry) => {
1501
+ const dirs = env.scopeDirs[entry.scope];
1502
+ if (!dirs) {
1503
+ changed = true;
1504
+ return;
1505
+ }
1506
+ const root = dirs[entry.target];
1507
+ if (!root) {
1508
+ changed = true;
1509
+ return;
1510
+ }
1511
+ const activePath = path.join(root, entry.name);
1512
+ const disabledPath = path.join(root, DISABLED_DIR_NAME, entry.name);
1513
+ if (entry.disabled) {
1514
+ if (fs.existsSync(disabledPath)) {
1515
+ nextSkills.push(entry);
1516
+ return;
1517
+ }
1518
+ if (fs.existsSync(activePath)) {
1519
+ entry.disabled = false;
1520
+ entry.updated_at = now;
1521
+ nextSkills.push(entry);
1522
+ changed = true;
1523
+ return;
1524
+ }
1525
+ changed = true;
1526
+ return;
1527
+ }
1528
+ if (fs.existsSync(activePath)) {
1529
+ nextSkills.push(entry);
1530
+ return;
1531
+ }
1532
+ if (fs.existsSync(disabledPath)) {
1533
+ entry.disabled = true;
1534
+ entry.updated_at = now;
1535
+ nextSkills.push(entry);
1536
+ changed = true;
1537
+ return;
1538
+ }
1539
+ changed = true;
1540
+ });
1541
+
1542
+ state.skills = nextSkills;
1543
+ state.updated_at = now;
1544
+ if (changed) {
1545
+ saveStateFile(env.statePath, state, options["dry-run"]);
1546
+ console.log("State synchronized.");
1547
+ } else {
1548
+ console.log("State already in sync.");
1549
+ }
1550
+ if (options["dry-run"]) {
1551
+ console.log("- Dry run: no changes written.");
1552
+ }
1553
+ return Promise.resolve();
1554
+ }
1555
+
1556
+ function handleSkillsUpgrade(options, args, settings) {
1557
+ const env = buildSkillEnvironment(settings);
1558
+ const state = loadStateFile(env.statePath);
1559
+ const overwrite = true;
1560
+ const now = new Date().toISOString();
1561
+ const sourceRoot = options.source ? path.resolve(options.source) : env.skillsSource;
1562
+ const defaultMode = resolveInstallMode(options, "link");
1563
+ let upgraded = 0;
1564
+ let skipped = 0;
1565
+
1566
+ state.skills.forEach((entry) => {
1567
+ if (entry.disabled) {
1568
+ skipped += 1;
1569
+ return;
1570
+ }
1571
+ const dirs = env.scopeDirs[entry.scope];
1572
+ if (!dirs) {
1573
+ skipped += 1;
1574
+ return;
1575
+ }
1576
+ const root = dirs[entry.target];
1577
+ if (!root) {
1578
+ skipped += 1;
1579
+ return;
1580
+ }
1581
+ const activePath = path.join(root, entry.name);
1582
+ let sourceDir = entry.source;
1583
+ if (sourceRoot) {
1584
+ const candidate = path.join(sourceRoot, entry.name);
1585
+ if (fs.existsSync(path.join(candidate, "SKILL.md"))) {
1586
+ sourceDir = candidate;
1587
+ }
1588
+ }
1589
+ if (!sourceDir || !fs.existsSync(sourceDir)) {
1590
+ skipped += 1;
1591
+ return;
1592
+ }
1593
+
1594
+ const install = installSkill(sourceDir, activePath, {
1595
+ mode: entry.mode || defaultMode,
1596
+ dryRun: options["dry-run"],
1597
+ overwrite,
1598
+ });
1599
+ entry.source = sourceDir;
1600
+ entry.mode = install.mode;
1601
+ entry.updated_at = now;
1602
+ entry.installed_at = now;
1603
+ upgraded += 1;
1604
+ });
1605
+
1606
+ state.updated_at = now;
1607
+ saveStateFile(env.statePath, state, options["dry-run"]);
1608
+ console.log(`Upgraded ${upgraded} managed skills. Skipped ${skipped}.`);
1609
+ if (options["dry-run"]) {
1610
+ console.log("- Dry run: no changes written.");
1611
+ }
1612
+ return Promise.resolve();
1613
+ }
1614
+
1615
+ function handleSkillsExport(options, args, settings) {
1616
+ const outputPath = options.output;
1617
+ if (!outputPath) {
1618
+ console.error("Usage: agent-playbook skills export --output <file>");
1619
+ process.exitCode = 1;
1620
+ return Promise.resolve();
1621
+ }
1622
+
1623
+ const env = buildSkillEnvironment(settings);
1624
+ const state = loadStateFile(env.statePath);
1625
+ const resolved = path.resolve(outputPath);
1626
+ ensureDir(path.dirname(resolved), options["dry-run"]);
1627
+ if (!options["dry-run"]) {
1628
+ fs.writeFileSync(resolved, JSON.stringify(state, null, 2));
1629
+ }
1630
+ console.log(`Exported state to ${resolved}`);
1631
+ if (options["dry-run"]) {
1632
+ console.log("- Dry run: no changes written.");
1633
+ }
1634
+ return Promise.resolve();
1635
+ }
1636
+
1637
+ function handleSkillsImport(options, args, settings) {
1638
+ const inputPath = args[0];
1639
+ if (!inputPath) {
1640
+ console.error("Usage: agent-playbook skills import <file>");
1641
+ process.exitCode = 1;
1642
+ return Promise.resolve();
1643
+ }
1644
+
1645
+ const resolved = path.resolve(inputPath);
1646
+ if (!fs.existsSync(resolved)) {
1647
+ console.error(`Import file not found: ${resolved}`);
1648
+ process.exitCode = 1;
1649
+ return Promise.resolve();
1650
+ }
1651
+
1652
+ let imported;
1653
+ try {
1654
+ imported = JSON.parse(fs.readFileSync(resolved, "utf8"));
1655
+ } catch (error) {
1656
+ console.error("Invalid import file.");
1657
+ process.exitCode = 1;
1658
+ return Promise.resolve();
1659
+ }
1660
+
1661
+ const env = buildSkillEnvironment(settings);
1662
+ const overwriteState = createOverwriteState(options);
1663
+ const now = new Date().toISOString();
1664
+ const defaultMode = resolveInstallMode(options, "link");
1665
+ const skills = Array.isArray(imported.skills) ? imported.skills : [];
1666
+ const state = {
1667
+ version: imported.version || "1",
1668
+ updated_at: now,
1669
+ skills: skills,
1670
+ };
1671
+
1672
+ let applied = 0;
1673
+ let skipped = 0;
1674
+ skills.forEach((entry) => {
1675
+ if (!entry || !entry.name || !entry.scope || !entry.target) {
1676
+ skipped += 1;
1677
+ return;
1678
+ }
1679
+ if (entry.disabled) {
1680
+ return;
1681
+ }
1682
+ const dirs = env.scopeDirs[entry.scope];
1683
+ if (!dirs) {
1684
+ skipped += 1;
1685
+ return;
1686
+ }
1687
+ const root = dirs[entry.target];
1688
+ if (!root) {
1689
+ skipped += 1;
1690
+ return;
1691
+ }
1692
+ const targetPath = path.join(root, entry.name);
1693
+ let sourceDir = entry.source;
1694
+ if (options.source) {
1695
+ const candidate = path.join(path.resolve(options.source), entry.name);
1696
+ if (fs.existsSync(path.join(candidate, "SKILL.md"))) {
1697
+ sourceDir = candidate;
1698
+ }
1699
+ }
1700
+ if (!sourceDir || !fs.existsSync(sourceDir)) {
1701
+ skipped += 1;
1702
+ return;
1703
+ }
1704
+ if (fs.existsSync(targetPath)) {
1705
+ if (!shouldOverwriteExisting(options, overwriteState, targetPath)) {
1706
+ skipped += 1;
1707
+ return;
1708
+ }
1709
+ if (!options["dry-run"]) {
1710
+ safeUnlink(targetPath);
1711
+ }
1712
+ }
1713
+ installSkill(sourceDir, targetPath, {
1714
+ mode: entry.mode || defaultMode,
1715
+ dryRun: options["dry-run"],
1716
+ });
1717
+ entry.updated_at = now;
1718
+ applied += 1;
1719
+ });
1720
+
1721
+ saveStateFile(env.statePath, state, options["dry-run"]);
1722
+ console.log(`Imported state (${applied} applied, ${skipped} skipped).`);
1723
+ if (options["dry-run"]) {
1724
+ console.log("- Dry run: no changes written.");
1725
+ }
1726
+ return Promise.resolve();
1727
+ }
1728
+
459
1729
  function ensureLocalCli(settings, context, options) {
460
1730
  const baseDir = settings.projectMode ? settings.claudeDir : settings.claudeDir;
461
1731
  const cliRoot = path.join(baseDir, LOCAL_CLI_DIR);
@@ -904,6 +2174,7 @@ function collectStatus(settings) {
904
2174
  codexConfigPath: settings.codexConfigPath,
905
2175
  claudeSkillsDir: settings.claudeSkillsDir,
906
2176
  codexSkillsDir: settings.codexSkillsDir,
2177
+ geminiSkillsDir: settings.geminiSkillsDir,
907
2178
  claudeSettingsReadable: claudeSettings !== null || !fs.existsSync(settings.claudeSettingsPath),
908
2179
  codexBlockPresent: hasCodexBlock(settings.codexConfigPath),
909
2180
  hooksInstalled: hasHooks(settings.claudeSettingsPath),
@@ -911,6 +2182,7 @@ function collectStatus(settings) {
911
2182
  localCliPresent: fs.existsSync(path.join(settings.claudeDir, LOCAL_CLI_DIR, "bin", "agent-playbook.js")),
912
2183
  claudeSkillCount: countSkills(settings.claudeSkillsDir),
913
2184
  codexSkillCount: countSkills(settings.codexSkillsDir),
2185
+ geminiSkillCount: countSkills(settings.geminiSkillsDir),
914
2186
  };
915
2187
  }
916
2188
 
@@ -958,27 +2230,37 @@ function printStatus(status) {
958
2230
  console.log(`- Codex config: ${status.codexConfigPath}`);
959
2231
  console.log(`- Claude skills: ${status.claudeSkillsDir}`);
960
2232
  console.log(`- Codex skills: ${status.codexSkillsDir}`);
2233
+ console.log(`- Gemini skills: ${status.geminiSkillsDir}`);
961
2234
  console.log(`- Claude skills count: ${status.claudeSkillCount}`);
962
2235
  console.log(`- Codex skills count: ${status.codexSkillCount}`);
2236
+ console.log(`- Gemini skills count: ${status.geminiSkillCount}`);
963
2237
  console.log(`- Hooks installed: ${status.hooksInstalled ? "yes" : "no"}`);
964
2238
  console.log(`- Manifest present: ${status.manifestPresent ? "yes" : "no"}`);
965
2239
  console.log(`- Local CLI present: ${status.localCliPresent ? "yes" : "no"}`);
966
2240
  console.log(`- Codex config block: ${status.codexBlockPresent ? "yes" : "no"}`);
967
2241
  }
968
2242
 
969
- function printInitSummary(settings, hooksEnabled, options, claudeLinks, codexLinks, warnings) {
2243
+ function printInitSummary(settings, hooksEnabled, options, claudeLinks, codexLinks, geminiLinks, warnings) {
970
2244
  console.log("Init complete.");
971
2245
  console.log(`- Claude skills: ${settings.claudeSkillsDir}`);
972
2246
  console.log(`- Codex skills: ${settings.codexSkillsDir}`);
2247
+ console.log(`- Gemini skills: ${settings.geminiSkillsDir}`);
973
2248
  console.log(`- Hooks: ${hooksEnabled ? "enabled" : "disabled"}`);
974
- console.log(`- Linked skills: ${claudeLinks.created.length + codexLinks.created.length}`);
2249
+ const linkedCount =
2250
+ claudeLinks.created.length + codexLinks.created.length + (geminiLinks ? geminiLinks.created.length : 0);
2251
+ console.log(`- Linked skills: ${linkedCount}`);
975
2252
  const overwrittenCount =
976
2253
  (claudeLinks.overwritten ? claudeLinks.overwritten.length : 0) +
977
- (codexLinks.overwritten ? codexLinks.overwritten.length : 0);
2254
+ (codexLinks.overwritten ? codexLinks.overwritten.length : 0) +
2255
+ (geminiLinks && geminiLinks.overwritten ? geminiLinks.overwritten.length : 0);
978
2256
  if (overwrittenCount) {
979
2257
  console.log(`- Overwritten skills: ${overwrittenCount}`);
980
2258
  }
981
- if (claudeLinks.skipped.length || codexLinks.skipped.length) {
2259
+ if (
2260
+ claudeLinks.skipped.length ||
2261
+ codexLinks.skipped.length ||
2262
+ (geminiLinks && geminiLinks.skipped.length)
2263
+ ) {
982
2264
  console.log("- Some skills were skipped due to existing paths.");
983
2265
  }
984
2266
  if (warnings && warnings.length) {
@@ -1144,6 +2426,7 @@ function handleRepair(options, context) {
1144
2426
  if (!options["dry-run"]) {
1145
2427
  ensureDir(settings.claudeSkillsDir, false);
1146
2428
  ensureDir(settings.codexSkillsDir, false);
2429
+ ensureDir(settings.geminiSkillsDir, false);
1147
2430
  }
1148
2431
 
1149
2432
  if (!status.localCliPresent) {
@@ -1168,6 +2451,7 @@ function handleRepair(options, context) {
1168
2451
  if (settings.skillsSource) {
1169
2452
  linkSkills(settings.skillsSource, settings.claudeSkillsDir, options, overwriteState);
1170
2453
  linkSkills(settings.skillsSource, settings.codexSkillsDir, options, overwriteState);
2454
+ linkSkills(settings.skillsSource, settings.geminiSkillsDir, options, overwriteState);
1171
2455
  if (!options["dry-run"]) {
1172
2456
  const manifestPath = path.join(settings.claudeSkillsDir, ".agent-playbook.json");
1173
2457
  if (!fs.existsSync(manifestPath)) {
@@ -1177,12 +2461,20 @@ function handleRepair(options, context) {
1177
2461
  installedAt: new Date().toISOString(),
1178
2462
  repairedAt: new Date().toISOString(),
1179
2463
  repoRoot: settings.repoRoot,
1180
- links: { claude: [], codex: [] },
2464
+ links: { claude: [], codex: [], gemini: [] },
1181
2465
  });
1182
2466
  }
1183
2467
  }
1184
2468
  }
1185
2469
 
1186
- printInitSummary(settings, true, options, { created: [], skipped: [] }, { created: [], skipped: [] }, warnings);
2470
+ printInitSummary(
2471
+ settings,
2472
+ true,
2473
+ options,
2474
+ { created: [], skipped: [] },
2475
+ { created: [], skipped: [] },
2476
+ { created: [], skipped: [] },
2477
+ warnings
2478
+ );
1187
2479
  return Promise.resolve();
1188
2480
  }