@agent-native/skills 0.2.1 → 0.2.3

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/dist/index.js CHANGED
@@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import readline from "node:readline/promises";
7
6
  import { fileURLToPath } from "node:url";
8
7
  import { resolveAppForSkill } from "./built-in-apps.js";
9
8
  import { registerMcpServer } from "./connect.js";
@@ -26,6 +25,7 @@ Options:
26
25
  --with-github-action Add .github/workflows/pr-visual-recap.yml when visual-recap is installed
27
26
  --force Overwrite a different existing PR Visual Recap workflow
28
27
  --no-mcp Install skill files only; skip registering the app's MCP server
28
+ --no-connect Register MCP where possible but skip inline browser/device authentication
29
29
  -y, --yes Use defaults in non-interactive mode
30
30
  --dry-run Print intended writes without changing files
31
31
  --json Print the result as JSON
@@ -119,6 +119,10 @@ export function parseSkillsCliArgs(argv) {
119
119
  out.mcp = false;
120
120
  else if (arg === "--mcp")
121
121
  out.mcp = true;
122
+ else if (arg === "--no-connect" || arg === "--skip-connect")
123
+ out.connect = false;
124
+ else if (arg === "--connect")
125
+ out.connect = true;
122
126
  else if (arg.startsWith("-"))
123
127
  throw new Error(`Unknown option: ${arg}`);
124
128
  else if (!out.source)
@@ -167,6 +171,8 @@ function toCoreSkillsArgv(parsed) {
167
171
  out.push("--force");
168
172
  if (parsed.mcp === false)
169
173
  out.push("--no-mcp");
174
+ if (parsed.connect === false)
175
+ out.push("--no-connect");
170
176
  if (parsed.updateInstructions === true)
171
177
  out.push("--update-instructions");
172
178
  if (parsed.updateInstructions === false)
@@ -230,6 +236,9 @@ export async function runSkillsCli(argv, options = {}) {
230
236
  source.cleanup?.();
231
237
  }
232
238
  }
239
+ const stdoutLog = parsed.printJson || options.log
240
+ ? options.log
241
+ : (message) => process.stdout.write(`${message}\n`);
233
242
  const result = await installSkills({
234
243
  source: skillSource,
235
244
  skillNames: parsed.skillNames,
@@ -244,8 +253,15 @@ export async function runSkillsCli(argv, options = {}) {
244
253
  instructionFiles: parsed.instructionFiles,
245
254
  withGithubAction: parsed.withGithubAction,
246
255
  force: parsed.force,
247
- log: parsed.printJson ? undefined : options.log,
256
+ connect: parsed.connect,
257
+ quiet: parsed.printJson,
258
+ log: parsed.printJson ? undefined : stdoutLog,
248
259
  isInteractive: options.isInteractive,
260
+ promptSkills: options.promptSkills,
261
+ promptClients: options.promptClients,
262
+ promptScope: options.promptScope,
263
+ promptUpdateInstructions: options.promptUpdateInstructions,
264
+ promptGithubAction: options.promptGithubAction,
249
265
  telemetry,
250
266
  mcp: parsed.mcp,
251
267
  });
@@ -260,28 +276,10 @@ export async function runSkillsCli(argv, options = {}) {
260
276
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
261
277
  return;
262
278
  }
263
- const verb = parsed.dryRun ? "Would install" : "Installed";
264
- process.stdout.write([
265
- `${verb} ${result.skills.join(", ")} for ${result.clients.join(", ")} (${result.scope}).`,
266
- result.written.length
267
- ? `Skill files: ${result.written.join(", ")}`
268
- : "",
269
- result.instructionFiles.length
270
- ? `Managed instructions: ${result.instructionFiles.join(", ")}`
271
- : "",
272
- result.githubActionPath
273
- ? `PR Visual Recap workflow: ${result.githubActionPath}`
274
- : "",
275
- ...result.mcpServers.flatMap((server) => [
276
- `MCP server "${server.serverName}" ${parsed.dryRun ? "would be registered" : "registered"} for ${server.clients.join(", ")}${server.files.length ? `:\n ${server.files.join("\n ")}` : ""}`,
277
- ...server.guidance.map((line) => ` ${line}`),
278
- ]),
279
- parsed.dryRun
280
- ? ""
281
- : "Restart or reload selected agent clients if needed.",
282
- ]
283
- .filter(Boolean)
284
- .join("\n") + "\n");
279
+ await printInstallResult(result, {
280
+ baseDir: parsed.baseDir ?? options.baseDir ?? process.cwd(),
281
+ dryRun: parsed.dryRun,
282
+ });
285
283
  }
286
284
  catch (error) {
287
285
  telemetry.track("skills_cli failed", {
@@ -352,98 +350,126 @@ export async function installSkills(options) {
352
350
  });
353
351
  const scope = await resolveSelectedScope(options);
354
352
  options.telemetry?.track("skills_cli scope selected", { scope });
353
+ const skillNames = selected.map((skill) => skill.name);
354
+ const instructionBlocks = managedInstructionBlocksForSkills(skillNames);
355
+ const shouldUpdateInstructions = await shouldUpdateManagedInstructions(instructionBlocks, options);
356
+ const shouldWriteGithubAction = selected.some((skill) => skill.name === "visual-recap") &&
357
+ (options.withGithubAction ||
358
+ (await shouldPromptGithubAction(options, baseDir)));
359
+ const mcpApps = options.mcp === false ? [] : mcpAppsForSkills(skillNames);
360
+ const progress = await createInstallProgress(options, 1 +
361
+ (shouldUpdateInstructions ? 1 : 0) +
362
+ (shouldWriteGithubAction ? 1 : 0) +
363
+ mcpApps.length);
355
364
  const written = [];
356
- for (const client of clients) {
357
- const root = installRootForClient(client, scope, baseDir);
358
- for (const skill of selected) {
359
- const destination = path.join(root, skill.name);
360
- written.push(destination);
361
- if (!options.dryRun) {
362
- fs.rmSync(destination, { recursive: true, force: true });
363
- fs.mkdirSync(path.dirname(destination), { recursive: true });
364
- fs.cpSync(skill.dir, destination, { recursive: true });
365
+ let instructionFiles = [];
366
+ let githubActionPath;
367
+ const mcpServers = [];
368
+ try {
369
+ progress?.start("Installing skill files...");
370
+ for (const client of clients) {
371
+ const root = installRootForClient(client, scope, baseDir);
372
+ for (const skill of selected) {
373
+ const destination = path.join(root, skill.name);
374
+ written.push(destination);
375
+ if (!options.dryRun) {
376
+ fs.rmSync(destination, { recursive: true, force: true });
377
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
378
+ fs.cpSync(skill.dir, destination, { recursive: true });
379
+ }
365
380
  }
366
381
  }
367
- }
368
- options.telemetry?.track("skills_cli install completed", {
369
- skills: selected.map((skill) => skill.name).join(","),
370
- clients: clients.join(","),
371
- scope,
372
- writtenCount: written.length,
373
- dryRun: Boolean(options.dryRun),
374
- });
375
- const instructionFiles = await maybeUpdateInstructions(selected.map((skill) => skill.name), baseDir, options);
376
- if (instructionFiles.length) {
377
- options.telemetry?.track("skills_cli instructions updated", {
378
- fileCount: instructionFiles.length,
382
+ progress?.advance("Skill files installed");
383
+ options.telemetry?.track("skills_cli install completed", {
384
+ skills: skillNames.join(","),
385
+ clients: clients.join(","),
386
+ scope,
387
+ writtenCount: written.length,
388
+ dryRun: Boolean(options.dryRun),
379
389
  });
380
- }
381
- const githubActionPath = selected.some((skill) => skill.name === "visual-recap") &&
382
- (options.withGithubAction ||
383
- (await shouldPromptGithubAction(options, baseDir)))
384
- ? writePrVisualRecapWorkflow(baseDir, options)
385
- : undefined;
386
- if (githubActionPath) {
387
- options.telemetry?.track("skills_cli github action added");
388
- }
389
- // Register the hosted MCP server for app-backed skills (visual-plan /
390
- // visual-recap → Agent-Native Plan, assets, design-exploration) so the
391
- // agent can actually call them — not just read the SKILL.md. On by
392
- // default; `--no-mcp` installs the skill files only. One registration per
393
- // app, so visual-plan + visual-recap share a single "plan" server.
394
- const mcpServers = [];
395
- if (options.mcp !== false) {
396
- const mcpClients = clients.map((client) => client === "claude-code" ? "claude-code" : "codex");
397
- const seenApps = new Set();
398
- for (const skill of selected) {
399
- const app = resolveAppForSkill(skill.name);
400
- if (!app || seenApps.has(app.appId))
401
- continue;
402
- seenApps.add(app.appId);
403
- if (options.dryRun) {
404
- mcpServers.push({
405
- serverName: app.serverName,
406
- mcpUrl: app.mcpUrl,
407
- clients,
408
- files: [],
409
- authenticated: false,
410
- guidance: [],
390
+ if (shouldUpdateInstructions) {
391
+ progress?.message("Updating managed instructions...");
392
+ instructionFiles = writeManagedInstructions(instructionBlocks, baseDir, clients, scope, options);
393
+ progress?.advance("Managed instructions updated");
394
+ if (instructionFiles.length) {
395
+ options.telemetry?.track("skills_cli instructions updated", {
396
+ fileCount: instructionFiles.length,
411
397
  });
412
- continue;
413
398
  }
414
- const registration = await registerMcpServer({
415
- descriptor: {
416
- serverName: app.serverName,
417
- mcpUrl: app.mcpUrl,
418
- aliases: app.aliases,
419
- authMode: app.authMode,
420
- hostedUrl: app.hostedUrl,
421
- },
422
- clients: mcpClients,
423
- scope,
424
- baseDir,
425
- interactive: isInteractive(options),
426
- log,
427
- });
428
- mcpServers.push({
429
- serverName: app.serverName,
430
- mcpUrl: app.mcpUrl,
431
- clients,
432
- files: [...new Set(registration.written.map((entry) => entry.file))],
433
- authenticated: registration.authenticated,
434
- guidance: registration.guidance,
435
- });
436
- options.telemetry?.track("skills_cli mcp registered", {
437
- serverName: app.serverName,
438
- clients: clients.join(","),
439
- authenticated: registration.authenticated,
440
- });
441
399
  }
400
+ if (shouldWriteGithubAction) {
401
+ progress?.message("Writing PR Visual Recap workflow...");
402
+ githubActionPath = writePrVisualRecapWorkflow(baseDir, options);
403
+ progress?.advance("PR Visual Recap workflow ready");
404
+ if (githubActionPath) {
405
+ options.telemetry?.track("skills_cli github action added");
406
+ }
407
+ }
408
+ // Register the hosted MCP server for app-backed skills (visual-plan /
409
+ // visual-recap → Agent-Native Plan, assets, design-exploration) so the
410
+ // agent can actually call them — not just read the SKILL.md. On by
411
+ // default; `--no-mcp` installs the skill files only. One registration per
412
+ // app, so visual-plan + visual-recap share a single "plan" server.
413
+ if (mcpApps.length > 0) {
414
+ const mcpClients = clients.map((client) => client === "claude-code" ? "claude-code" : "codex");
415
+ for (const app of mcpApps) {
416
+ progress?.message(`Registering ${app.displayName} MCP server...`);
417
+ if (!options.dryRun) {
418
+ const registration = await registerMcpServer({
419
+ descriptor: {
420
+ serverName: app.serverName,
421
+ mcpUrl: app.mcpUrl,
422
+ aliases: app.aliases,
423
+ authMode: app.authMode,
424
+ hostedUrl: app.hostedUrl,
425
+ },
426
+ clients: mcpClients,
427
+ scope,
428
+ baseDir,
429
+ interactive: options.connect !== false && isInteractive(options),
430
+ log,
431
+ deviceFlowTimeoutMs: options.deviceFlowTimeoutMs,
432
+ });
433
+ mcpServers.push({
434
+ serverName: app.serverName,
435
+ mcpUrl: app.mcpUrl,
436
+ clients,
437
+ registeredClients: unique(registration.written.map((entry) => entry.client)),
438
+ files: [
439
+ ...new Set(registration.written.map((entry) => entry.file)),
440
+ ],
441
+ authenticated: registration.authenticated,
442
+ guidance: registration.guidance,
443
+ });
444
+ options.telemetry?.track("skills_cli mcp registered", {
445
+ serverName: app.serverName,
446
+ clients: clients.join(","),
447
+ authenticated: registration.authenticated,
448
+ });
449
+ }
450
+ else {
451
+ mcpServers.push({
452
+ serverName: app.serverName,
453
+ mcpUrl: app.mcpUrl,
454
+ clients,
455
+ registeredClients: clients,
456
+ files: [],
457
+ authenticated: false,
458
+ guidance: [],
459
+ });
460
+ }
461
+ progress?.advance(`${app.displayName} MCP server ready`);
462
+ }
463
+ }
464
+ progress?.stop("Installation complete");
465
+ }
466
+ catch (err) {
467
+ progress?.error("Installation failed");
468
+ throw err;
442
469
  }
443
- log(`Resolved ${selected.length} skill${selected.length === 1 ? "" : "s"} from ${source.root}.`);
444
470
  return {
445
471
  source: source.root,
446
- skills: selected.map((skill) => skill.name),
472
+ skills: skillNames,
447
473
  clients,
448
474
  scope,
449
475
  written,
@@ -471,6 +497,7 @@ function defaultArgs(command) {
471
497
  instructionFiles: [],
472
498
  withGithubAction: false,
473
499
  force: false,
500
+ connect: true,
474
501
  mcp: true,
475
502
  };
476
503
  }
@@ -506,6 +533,157 @@ function normalizeSkillName(value) {
506
533
  function unique(values) {
507
534
  return [...new Set(values)];
508
535
  }
536
+ function plural(count, singular, pluralForm = `${singular}s`) {
537
+ return `${count} ${count === 1 ? singular : pluralForm}`;
538
+ }
539
+ function shortenPathForOutput(file, baseDir) {
540
+ const resolved = path.resolve(file);
541
+ const home = process.env.HOME || os.homedir();
542
+ if (resolved === home || resolved.startsWith(`${home}${path.sep}`)) {
543
+ return `~${resolved.slice(home.length)}`;
544
+ }
545
+ const base = path.resolve(baseDir);
546
+ const relative = path.relative(base, resolved);
547
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
548
+ return `.${path.sep}${relative}`;
549
+ }
550
+ return file;
551
+ }
552
+ function summarizePaths(files, baseDir, max = 4) {
553
+ const shortened = unique(files).map((file) => shortenPathForOutput(file, baseDir));
554
+ if (shortened.length <= max)
555
+ return shortened.join(", ");
556
+ return `${shortened.slice(0, max).join(", ")} +${shortened.length - max} more`;
557
+ }
558
+ async function createInstallProgress(options, max) {
559
+ if (options.quiet || !isInteractive(options) || max <= 0)
560
+ return null;
561
+ const clack = await import("@clack/prompts");
562
+ const progress = clack.progress({ max, indicator: "timer" });
563
+ let active = false;
564
+ return {
565
+ start(message) {
566
+ active = true;
567
+ progress.start(message);
568
+ },
569
+ message(message) {
570
+ if (active)
571
+ progress.message(message);
572
+ },
573
+ advance(message) {
574
+ if (active)
575
+ progress.advance(1, message);
576
+ },
577
+ stop(message) {
578
+ if (!active)
579
+ return;
580
+ progress.stop(message);
581
+ active = false;
582
+ },
583
+ error(message) {
584
+ if (!active)
585
+ return;
586
+ progress.error(message);
587
+ active = false;
588
+ },
589
+ };
590
+ }
591
+ function mcpAppsForSkills(skillNames) {
592
+ const apps = [];
593
+ const seen = new Set();
594
+ for (const skillName of skillNames) {
595
+ const app = resolveAppForSkill(skillName);
596
+ if (!app || seen.has(app.appId))
597
+ continue;
598
+ seen.add(app.appId);
599
+ apps.push(app);
600
+ }
601
+ return apps;
602
+ }
603
+ function mcpStatus(server, dryRun) {
604
+ if (dryRun)
605
+ return `would register for ${server.clients.join(", ")}`;
606
+ const registered = server.registeredClients;
607
+ const pending = server.clients.filter((client) => !registered.includes(client));
608
+ const parts = [];
609
+ if (registered.length > 0) {
610
+ parts.push(`${server.authenticated ? "registered and authenticated" : "registered"} for ${registered.join(", ")}`);
611
+ }
612
+ if (pending.length > 0) {
613
+ parts.push(`authentication pending for ${pending.join(", ")}`);
614
+ }
615
+ return (parts.join("; ") ||
616
+ `authentication pending for ${server.clients.join(", ")}`);
617
+ }
618
+ async function printInstallResult(result, options) {
619
+ const clack = await import("@clack/prompts");
620
+ const verb = options.dryRun ? "Would install" : "Installed";
621
+ const summary = [
622
+ `Skills ${result.skills.join(", ") || "none"}`,
623
+ `Agents ${result.clients.join(", ") || "none"}`,
624
+ `Scope ${result.scope}`,
625
+ result.written.length
626
+ ? `Skill folders ${plural(result.written.length, "folder")} (${summarizePaths(result.written, options.baseDir)})`
627
+ : "",
628
+ ].filter(Boolean);
629
+ clack.note(summary.join("\n"), verb);
630
+ if (result.instructionFiles.length) {
631
+ clack.note(summarizePaths(result.instructionFiles, options.baseDir), "Managed instructions");
632
+ }
633
+ if (result.githubActionPath) {
634
+ clack.note(shortenPathForOutput(result.githubActionPath, options.baseDir), "PR Visual Recap workflow");
635
+ }
636
+ if (result.mcpServers.length) {
637
+ const mcpLines = result.mcpServers.map((server) => {
638
+ const status = mcpStatus(server, options.dryRun);
639
+ const files = server.files.length
640
+ ? ` (${summarizePaths(server.files, options.baseDir, 2)})`
641
+ : "";
642
+ return `${server.serverName}: ${status}${files}`;
643
+ });
644
+ clack.note(mcpLines.join("\n"), "MCP");
645
+ const guidance = result.mcpServers.flatMap((server) => server.guidance);
646
+ if (guidance.length) {
647
+ clack.note(guidance.join("\n"), "Next steps");
648
+ }
649
+ }
650
+ if (!options.dryRun) {
651
+ clack.note("Restart or reload selected agent clients if needed.", "Reload");
652
+ }
653
+ clack.outro(`${options.dryRun ? "Dry run complete" : "All set"} ✅`);
654
+ }
655
+ function compactPromptHint(value) {
656
+ const hint = value?.replace(/\s+/g, " ").trim() ?? "";
657
+ if (!hint)
658
+ return "Skill from BuilderIO/skills.";
659
+ if (hint.length <= 96)
660
+ return hint;
661
+ return `${hint.slice(0, 93).trimEnd()}...`;
662
+ }
663
+ function skillPromptOptions(entries) {
664
+ return entries.map((entry) => ({
665
+ value: entry.name,
666
+ label: entry.name,
667
+ hint: compactPromptHint(entry.description),
668
+ }));
669
+ }
670
+ async function promptForSkills(context) {
671
+ const clack = await import("@clack/prompts");
672
+ const result = await clack.multiselect({
673
+ message: "Which skills do you want to install?\n" +
674
+ " (space toggles, enter confirms)",
675
+ options: context.options,
676
+ initialValues: context.initialSkills,
677
+ required: true,
678
+ });
679
+ if (clack.isCancel(result)) {
680
+ clack.cancel("Cancelled.");
681
+ return null;
682
+ }
683
+ if (!Array.isArray(result))
684
+ return [];
685
+ return result.filter((value) => typeof value === "string");
686
+ }
509
687
  async function resolveSelectedSkills(entries, options) {
510
688
  const byName = new Map(entries.map((entry) => [entry.name, entry]));
511
689
  const requested = unique((options.skillNames ?? []).map(normalizeSkillName));
@@ -520,47 +698,87 @@ async function resolveSelectedSkills(entries, options) {
520
698
  }
521
699
  if (!isInteractive(options) || options.yes)
522
700
  return entries;
523
- const answer = await promptLine([
524
- "Which skills do you want to install?",
525
- ...entries.map((entry, index) => ` ${index + 1}. ${entry.name}${entry.description ? ` - ${entry.description}` : ""}`),
526
- "Enter numbers or names separated by commas, or press Enter for all: ",
527
- ].join("\n"));
528
- const trimmed = answer.trim();
529
- if (!trimmed)
530
- return entries;
531
- const selectedNames = trimmed
532
- .split(",")
533
- .map((part) => part.trim())
534
- .filter(Boolean)
535
- .map((part) => {
536
- const asNumber = Number(part);
537
- if (Number.isInteger(asNumber) &&
538
- asNumber >= 1 &&
539
- asNumber <= entries.length) {
540
- return entries[asNumber - 1].name;
541
- }
542
- return normalizeSkillName(part);
701
+ const prompt = options.promptSkills ?? promptForSkills;
702
+ const selectedNames = await prompt({
703
+ initialSkills: entries.map((entry) => entry.name),
704
+ options: skillPromptOptions(entries),
543
705
  });
706
+ if (!selectedNames || selectedNames.length === 0) {
707
+ throw new Error("Cancelled.");
708
+ }
544
709
  return resolveSelectedSkills(entries, {
545
710
  ...options,
546
711
  skillNames: selectedNames,
547
712
  });
548
713
  }
714
+ async function promptForScope(context) {
715
+ const clack = await import("@clack/prompts");
716
+ const result = await clack.select({
717
+ message: "Where do you want to install these skills?",
718
+ options: [
719
+ {
720
+ value: "project",
721
+ label: "Project",
722
+ hint: "This repo only (.agents / .claude in the current directory)",
723
+ },
724
+ {
725
+ value: "user",
726
+ label: "User",
727
+ hint: "Your home directory (~/.codex, ~/.claude), across projects",
728
+ },
729
+ ],
730
+ initialValue: context.initialScope,
731
+ });
732
+ if (clack.isCancel(result)) {
733
+ clack.cancel("Cancelled.");
734
+ return null;
735
+ }
736
+ return result === "user" ? "user" : "project";
737
+ }
549
738
  async function resolveSelectedScope(options) {
550
739
  if (options.scope)
551
740
  return options.scope;
552
741
  if (!isInteractive(options) || options.yes)
553
742
  return "user";
554
- const answer = await promptLine([
555
- "Where do you want to install these skills?",
556
- " 1. project - this repo only (.agents / .claude in the current directory)",
557
- " 2. user - your home directory, available across all projects",
558
- "Enter project or user [project]: ",
559
- ].join("\n"));
560
- const trimmed = answer.trim().toLowerCase();
561
- if (trimmed === "2" || trimmed === "user" || trimmed === "u")
562
- return "user";
563
- return "project";
743
+ const prompt = options.promptScope ?? promptForScope;
744
+ const selected = await prompt({ initialScope: "project" });
745
+ if (!selected)
746
+ throw new Error("Cancelled.");
747
+ return selected;
748
+ }
749
+ function clientPromptOptions() {
750
+ return [
751
+ {
752
+ value: "codex",
753
+ label: "Codex",
754
+ hint: "Install into Codex skill directories",
755
+ },
756
+ {
757
+ value: "claude-code",
758
+ label: "Claude Code",
759
+ hint: "Install into Claude Code skill directories",
760
+ },
761
+ ];
762
+ }
763
+ function normalizePromptClients(values) {
764
+ if (!Array.isArray(values))
765
+ return [];
766
+ return unique(values.filter((value) => value === "codex" || value === "claude-code"));
767
+ }
768
+ async function promptForClients(context) {
769
+ const clack = await import("@clack/prompts");
770
+ const result = await clack.multiselect({
771
+ message: "Install these skills for which local agents?\n" +
772
+ " (space toggles, enter confirms)",
773
+ options: context.options,
774
+ initialValues: context.initialClients,
775
+ required: true,
776
+ });
777
+ if (clack.isCancel(result)) {
778
+ clack.cancel("Cancelled.");
779
+ return null;
780
+ }
781
+ return normalizePromptClients(result);
564
782
  }
565
783
  async function resolveSelectedClients(options) {
566
784
  const requested = unique(options.clients ?? []);
@@ -568,9 +786,14 @@ async function resolveSelectedClients(options) {
568
786
  return requested;
569
787
  if (!isInteractive(options) || options.yes)
570
788
  return CLIENTS;
571
- const answer = await promptLine("Install for which clients? Enter codex, claude-code, or all [all]: ");
572
- const trimmed = answer.trim();
573
- return trimmed ? unique(normalizeClients(trimmed)) : CLIENTS;
789
+ const prompt = options.promptClients ?? promptForClients;
790
+ const selected = await prompt({
791
+ initialClients: CLIENTS,
792
+ options: clientPromptOptions(),
793
+ });
794
+ if (!selected || selected.length === 0)
795
+ throw new Error("Cancelled.");
796
+ return selected;
574
797
  }
575
798
  function isInteractive(options) {
576
799
  if (options.isInteractive)
@@ -579,18 +802,6 @@ function isInteractive(options) {
579
802
  return false;
580
803
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
581
804
  }
582
- async function promptLine(question) {
583
- const rl = readline.createInterface({
584
- input: process.stdin,
585
- output: process.stdout,
586
- });
587
- try {
588
- return await rl.question(question);
589
- }
590
- finally {
591
- rl.close();
592
- }
593
- }
594
805
  function installRootForClient(client, scope, baseDir) {
595
806
  const home = process.env.HOME || os.homedir();
596
807
  if (scope === "project") {
@@ -647,14 +858,34 @@ function skillEntry(dir) {
647
858
  return null;
648
859
  const body = fs.readFileSync(skillFile, "utf-8");
649
860
  const frontmatter = body.match(/^---\n([\s\S]*?)\n---/);
650
- const name = frontmatter?.[1]
651
- ?.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]
652
- ?.trim() ?? path.basename(dir);
653
- const description = frontmatter?.[1]
654
- ?.match(/^description:\s*(?:>-\s*)?(.+)$/m)?.[1]
655
- ?.trim();
861
+ const name = frontmatterField(frontmatter?.[1], "name") ?? path.basename(dir);
862
+ const description = frontmatterField(frontmatter?.[1], "description");
656
863
  return { name: normalizeSkillName(name), dir, description };
657
864
  }
865
+ function frontmatterField(frontmatter, field) {
866
+ if (!frontmatter)
867
+ return undefined;
868
+ const lines = frontmatter.split(/\r?\n/);
869
+ for (let i = 0; i < lines.length; i += 1) {
870
+ const match = lines[i].match(new RegExp(`^${field}:\\s*(.*)$`));
871
+ if (!match)
872
+ continue;
873
+ const raw = match[1].trim();
874
+ if (raw === ">-" || raw === ">" || raw === "|-" || raw === "|") {
875
+ const block = [];
876
+ for (let j = i + 1; j < lines.length; j += 1) {
877
+ const line = lines[j];
878
+ if (line.trim() && !/^\s/.test(line))
879
+ break;
880
+ block.push(line.replace(/^\s+/, ""));
881
+ }
882
+ const value = raw.startsWith("|") ? block.join("\n") : block.join(" ");
883
+ return value.replace(/\s+/g, " ").trim() || undefined;
884
+ }
885
+ return raw.replace(/^["']|["']$/g, "").trim() || undefined;
886
+ }
887
+ return undefined;
888
+ }
658
889
  async function materializeSource(input) {
659
890
  const local = path.resolve(input);
660
891
  if (fs.existsSync(local))
@@ -729,70 +960,49 @@ function parseGitHubSource(input) {
729
960
  }
730
961
  function instructionContentForSkill(skillName) {
731
962
  if (skillName === "quick-recap") {
732
- return `## Quick Recap Status Block
733
-
734
- Every response that completes a unit of work must end with one final status line:
735
-
736
- \`\`\`md
737
- 🟢 Actual concise status sentence
738
- \`\`\`
739
-
740
- Use 🟢 when the requested work is finished. Use 🟡 when non-routine follow-up work or a manual step remains, and name that pending item. Use 🔴 only when blocked on user input. Keep the status line under 100 characters. Put the status line at the very end of the response. Do not add \`---\`, spacer lines, or any content after it.
741
-
742
- Examples:
743
-
744
- \`\`\`md
745
- 🟢 Updated quick recap docs with output examples
746
- \`\`\`
747
-
748
- \`\`\`md
749
- 🟡 Code updated, set PROVIDER_WEBHOOK_SECRET before testing webhooks
750
- \`\`\`
751
-
752
- \`\`\`md
753
- 🔴 Need the production API key to continue
754
- \`\`\``;
963
+ return "When writing final response status indicators, use the /quick-recap skill always.";
755
964
  }
756
965
  if (skillName === "efficient-fable") {
757
- return `## Efficient Fable
758
-
759
- When operating as Claude Fable or another explicitly Fable-class expensive model, preserve Fable for the judgment layer: decomposition, architecture and product tradeoffs, synthesis, risk calls, and final review. Delegate token-heavy research, coding, testing, file inventory, repetitive edits, and independent implementation slices to cheaper subagents when available. Write delegated prompts as self-contained handoff packets with objective, scope, out-of-scope areas, expected evidence, verification commands, and stop conditions. For testing, Fable should suggest the validation direction and important scripts or browser checks, then lighter agents can run them, reduce logs, collect screenshots, and report exact failures and likely causes. Treat delegated reports as leads: Fable should verify important cited files, failures, and high-risk diffs before relying on them. Do not make unsupported quality or speed guarantees; frame savings as workload-dependent.`;
966
+ return "When operating as Claude Fable, use the /efficient-fable skill always.";
760
967
  }
761
968
  if (skillName === "efficient-frontier") {
762
- return `## Efficient Frontier
763
-
764
- When running any high-cost frontier model on a codebase-heavy task, act as the orchestrator and reviewer. Split independent research, search, summarization, coding, and testing work into cheaper/faster subagents when the host supports them, then spend frontier-model tokens on the plan, tradeoffs, integration decisions, validation strategy, and final quality pass. Delegated prompts should be self-contained: objective, repo path, scope, out-of-scope areas, expected evidence, verification commands, and stop conditions. For testing-heavy work, the frontier model should choose the scripts or browser flows that matter while lighter agents run checks, reduce output, and return the concrete signal. Treat delegated findings as leads and verify important claims before presenting them as facts.`;
969
+ return "When using a high-cost frontier model for codebase-heavy work, use the /efficient-frontier skill always.";
765
970
  }
766
971
  if (skillName === "stay-within-limits") {
767
- return `## Stay Within Limits
768
-
769
- Before starting long-running or parallel agent work, and between waves, check current 5-hour and weekly usage limits with the host's usage tool. For Claude Code, use npx -y ccusage@latest blocks --active --json when no better first-party signal is available. Keep waves to at most 3 parallel subagents by default. If either active 5-hour or weekly window is at or above 95%, do not launch more work; pause until the window clears. When a wake/resume tool is available, schedule a self-contained wake prompt for min(3600, secondsUntilWindowClears), re-check the actual block/window on wake, reschedule if still over budget, and only continue when safely below the threshold. The wake prompt should restate the remaining plan, usage check, wave throttle, verification steps, and any delegation scope or stop conditions needed for the next wave. Check between waves, not mid-wave.`;
972
+ return "When long-running or parallel work needs usage-limit checks, use the /stay-within-limits skill always.";
973
+ }
974
+ if (skillName === "visual-plan") {
975
+ return "When work needs an interactive visual plan before implementation, use the /visual-plan skill always.";
976
+ }
977
+ if (skillName === "visual-recap") {
978
+ return "When a PR, branch, commit, or diff needs an interactive visual recap, use the /visual-recap skill always.";
770
979
  }
771
980
  return null;
772
981
  }
773
- async function maybeUpdateInstructions(skillNames, baseDir, options) {
774
- const blocks = skillNames
982
+ function managedInstructionBlocksForSkills(skillNames) {
983
+ return skillNames
775
984
  .map((name) => instructionContentForSkill(name))
776
985
  .filter((block) => Boolean(block));
986
+ }
987
+ async function shouldUpdateManagedInstructions(blocks, options) {
777
988
  if (blocks.length === 0)
778
- return [];
989
+ return false;
779
990
  let shouldUpdate = options.updateInstructions;
780
- if (shouldUpdate === undefined) {
781
- if (options.yes)
782
- shouldUpdate = true;
783
- else if (isInteractive(options)) {
784
- const answer = await promptLine("Add managed AGENTS.md / CLAUDE.md instructions for always-on behavior? [Y/n] ");
785
- shouldUpdate = !/^n/i.test(answer.trim());
786
- }
787
- else {
788
- shouldUpdate = false;
789
- }
790
- }
791
- if (!shouldUpdate)
991
+ if (shouldUpdate !== undefined)
992
+ return shouldUpdate;
993
+ if (options.yes)
994
+ return true;
995
+ if (!isInteractive(options))
996
+ return false;
997
+ const prompt = options.promptUpdateInstructions ?? promptForUpdateInstructions;
998
+ return (await prompt()) === true;
999
+ }
1000
+ function writeManagedInstructions(blocks, baseDir, clients, scope, options) {
1001
+ if (blocks.length === 0)
792
1002
  return [];
793
- const files = resolveInstructionFiles(baseDir, options.instructionFiles);
1003
+ const files = resolveInstructionFiles(baseDir, options.instructionFiles, clients, scope);
794
1004
  const content = `${MANAGED_INSTRUCTIONS_START}
795
- ${blocks.join("\n\n")}
1005
+ ${blocks.join("\n")}
796
1006
  ${MANAGED_INSTRUCTIONS_END}`;
797
1007
  for (const file of files) {
798
1008
  if (options.dryRun)
@@ -801,10 +1011,30 @@ ${MANAGED_INSTRUCTIONS_END}`;
801
1011
  }
802
1012
  return files;
803
1013
  }
804
- function resolveInstructionFiles(baseDir, explicit) {
1014
+ async function maybeUpdateInstructions(skillNames, baseDir, options) {
1015
+ const blocks = managedInstructionBlocksForSkills(skillNames);
1016
+ const clients = options.clients?.length ? options.clients : CLIENTS;
1017
+ const scope = options.scope ?? "user";
1018
+ if (!(await shouldUpdateManagedInstructions(blocks, options)))
1019
+ return [];
1020
+ return writeManagedInstructions(blocks, baseDir, clients, scope, options);
1021
+ }
1022
+ function resolveInstructionFiles(baseDir, explicit, clients, scope) {
805
1023
  if (explicit && explicit.length > 0) {
806
1024
  return explicit.map((file) => path.resolve(baseDir, file));
807
1025
  }
1026
+ if (scope === "user") {
1027
+ const home = process.env.HOME || os.homedir();
1028
+ const files = [];
1029
+ if (clients.includes("codex")) {
1030
+ const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
1031
+ files.push(path.join(codexHome, "AGENTS.md"));
1032
+ }
1033
+ if (clients.includes("claude-code")) {
1034
+ files.push(path.join(home, ".claude", "CLAUDE.md"));
1035
+ }
1036
+ return unique(files);
1037
+ }
808
1038
  const candidates = ["AGENTS.md", "CLAUDE.md"].map((file) => path.join(baseDir, file));
809
1039
  const existing = candidates.filter((file) => fs.existsSync(file));
810
1040
  return existing.length > 0 ? existing : [path.join(baseDir, "AGENTS.md")];
@@ -829,8 +1059,36 @@ async function shouldPromptGithubAction(options, baseDir) {
829
1059
  if (fs.existsSync(path.join(baseDir, ".github", "workflows", "pr-visual-recap.yml"))) {
830
1060
  return false;
831
1061
  }
832
- const answer = await promptLine("Add the optional PR Visual Recap GitHub Action? [y/N] ");
833
- return /^y/i.test(answer.trim());
1062
+ const prompt = options.promptGithubAction ?? promptForGithubAction;
1063
+ return ((await prompt({
1064
+ workflowPath: path.join(".github", "workflows", "pr-visual-recap.yml"),
1065
+ })) === true);
1066
+ }
1067
+ async function promptForUpdateInstructions() {
1068
+ const clack = await import("@clack/prompts");
1069
+ const result = await clack.confirm({
1070
+ message: "Add managed AGENTS.md / CLAUDE.md instructions for always-on behavior?",
1071
+ initialValue: true,
1072
+ });
1073
+ if (clack.isCancel(result)) {
1074
+ clack.cancel("Skipped instruction update.");
1075
+ return null;
1076
+ }
1077
+ return Boolean(result);
1078
+ }
1079
+ async function promptForGithubAction(context) {
1080
+ const clack = await import("@clack/prompts");
1081
+ const result = await clack.confirm({
1082
+ message: "Optional: add automatic PR Visual Recaps? (GitHub Action)\n" +
1083
+ " Posts a human-friendly recap on every pull request.\n" +
1084
+ ` Writes ${context.workflowPath}.`,
1085
+ initialValue: false,
1086
+ });
1087
+ if (clack.isCancel(result)) {
1088
+ clack.cancel("Skipped PR Visual Recap workflow.");
1089
+ return null;
1090
+ }
1091
+ return Boolean(result);
834
1092
  }
835
1093
  const PR_VISUAL_RECAP_REUSABLE_WORKFLOW = `name: PR Visual Recap
836
1094