skillrepo 3.2.0 → 4.0.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 (39) hide show
  1. package/README.md +90 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-session-sync.mjs +1 -1
  7. package/src/commands/init.mjs +435 -111
  8. package/src/commands/list.mjs +1 -1
  9. package/src/commands/remove.mjs +10 -2
  10. package/src/commands/uninstall.mjs +1 -1
  11. package/src/commands/update.mjs +15 -3
  12. package/src/lib/agent-registry.mjs +215 -0
  13. package/src/lib/cli-config.mjs +146 -44
  14. package/src/lib/detect-agents.mjs +112 -0
  15. package/src/lib/file-write.mjs +162 -77
  16. package/src/lib/mcp-merge.mjs +17 -36
  17. package/src/lib/mergers/gitignore.mjs +55 -28
  18. package/src/lib/paths.mjs +27 -25
  19. package/src/lib/prompt-multiselect.mjs +324 -0
  20. package/src/lib/sync.mjs +18 -19
  21. package/src/test/commands/add.test.mjs +18 -3
  22. package/src/test/commands/init-picker.test.mjs +144 -0
  23. package/src/test/commands/init.test.mjs +228 -42
  24. package/src/test/commands/remove.test.mjs +4 -1
  25. package/src/test/commands/update.test.mjs +13 -3
  26. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  27. package/src/test/e2e/cli-commands.test.mjs +39 -13
  28. package/src/test/integration/file-write.integration.test.mjs +31 -10
  29. package/src/test/lib/agent-registry.test.mjs +215 -0
  30. package/src/test/lib/cli-config.test.mjs +222 -38
  31. package/src/test/lib/detect-agents.test.mjs +336 -0
  32. package/src/test/lib/file-write-placement.test.mjs +264 -0
  33. package/src/test/lib/file-write.test.mjs +231 -30
  34. package/src/test/lib/mcp-merge.test.mjs +23 -15
  35. package/src/test/lib/paths.test.mjs +53 -17
  36. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  37. package/src/test/lib/sync.test.mjs +157 -0
  38. package/src/lib/detect-ides.mjs +0 -44
  39. package/src/test/detect-ides.test.mjs +0 -65
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884).
2
+ * `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884),
3
+ * Phase 3 detection picker (#1236).
3
4
  *
4
5
  * First-run command. Replaces the v2.0.0 init that consumed the
5
6
  * deprecated `/api/v1/setup` endpoint and wrote hook-delivery
@@ -8,12 +9,30 @@
8
9
  * 1. Collect credentials (--key | env var | interactive prompt)
9
10
  * 2. Validate the key via POST /api/v1/auth/validate
10
11
  * 3. Write ~/.claude/skillrepo/config.json via config.mjs
11
- * 4. Detect installed IDEs (.claude/, .cursor/, .vscode/, ~/.codeium/windsurf/)
12
+ * 4. Multi-signal agent detection + two-row picker (#1236)
12
13
  * 5. Run MCP auto-merge for detected IDEs (user-confirmed unless --yes)
13
14
  * 6. Install Claude Code SessionStart hook (v3.1.0 #884)
14
15
  * 7. Run the first library sync via sync.mjs
15
16
  * 8. Print summary
16
17
  *
18
+ * Step 4 details (#1236):
19
+ *
20
+ * - When `--agent` is passed (including `--agent none`), the picker
21
+ * is skipped entirely and the explicit list is used.
22
+ * - Otherwise, multi-signal detection runs against the registry.
23
+ * Each row of the picker (Claude Code / Other agents) is
24
+ * pre-checked when its target's signal fires. Fresh clone with no
25
+ * detection at all → both rows pre-checked anyway (the product's
26
+ * job is to set things up; defaulting to "do nothing" is the bug
27
+ * we are fixing). When detection fires for one target only, the
28
+ * other row is left unchecked so the picker nudges the user toward
29
+ * what was found.
30
+ * - `--yes` skips the render and uses the pre-checked rows. With
31
+ * no signals, that means BOTH targets — running `init --yes` on
32
+ * a fresh clone and writing nothing is broken automation.
33
+ * - The "None" row produces vendors = `[]`, the same sentinel
34
+ * `--agent none` returns. Mutually exclusive with the other rows.
35
+ *
17
36
  * Key differences from v2.0.0:
18
37
  *
19
38
  * - Uses /api/v1/auth/validate (not /api/v1/setup)
@@ -21,8 +40,10 @@
21
40
  * - MCP merge is interactive with per-vendor prompts
22
41
  * - NO rules-delivery files (.claude/rules/, .claude/skillrepo*.md) —
23
42
  * those died with the hooks in #835
24
- * - NO silent vendor fallback if nothing is detected, refuse with
25
- * a clear --ide hint
43
+ * - The Phase-3 picker REPLACED the old "no agent targets detected
44
+ * refuse" branch. Fresh clones now configure both default
45
+ * targets; the user can still opt out via the "None" row or
46
+ * `--agent none`.
26
47
  * - Idempotent: re-running with a valid existing config re-runs
27
48
  * detection + MCP merge prompts + sync, but does NOT re-prompt
28
49
  * for a key. A stale (401) key triggers automatic re-prompt.
@@ -33,32 +54,40 @@
33
54
  * Flags:
34
55
  * --key/-k <key> Access key (overrides config + env)
35
56
  * --url/-u <url> SkillRepo server URL (overrides config + env)
36
- * --yes/-y Non-interactive: skip all prompts (MCP merge + IDE confirm)
57
+ * --yes/-y Non-interactive: skip all prompts (MCP merge + picker)
37
58
  * --force Re-prompt for a new key even if config exists and is valid
38
59
  * --global Run first sync in global mode (~/.claude/skills/)
39
- * --ide <list> Override detected IDEs (comma-separated)
60
+ * --agent <list> Override detected agent targets (comma-separated)
40
61
  * --json JSON summary output
41
62
  */
42
63
 
64
+ import { existsSync, readdirSync } from "node:fs";
65
+
43
66
  import { validateAccessKey } from "../lib/http.mjs";
44
- import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
67
+ import { detectAgents } from "../lib/detect-agents.mjs";
68
+ import { AGENT_REGISTRY, AGENTS_COHORT_KEYS } from "../lib/agent-registry.mjs";
45
69
  import { readConfig, writeConfig } from "../lib/config.mjs";
46
70
  import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
47
71
  import {
48
72
  detectTransientRunner,
49
73
  globalInstallCommandFor,
50
74
  } from "../lib/transient-runners.mjs";
51
- import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
75
+ import { mergeMcpForVendors } from "../lib/mcp-merge.mjs";
52
76
  import { runSync } from "../lib/sync.mjs";
77
+ import {
78
+ placementTargetsFor,
79
+ describePlacementTarget,
80
+ } from "../lib/file-write.mjs";
53
81
  import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
54
82
  import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
55
83
  import { installSessionSyncHook } from "./init-session-sync.mjs";
56
84
  import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
57
85
  import {
58
- promptSecret,
59
- promptWithBrowserOpen,
60
- confirm,
61
- } from "../lib/prompt.mjs";
86
+ claudeSkillsProjectRoot,
87
+ agentsSkillsProjectRoot,
88
+ } from "../lib/paths.mjs";
89
+ import { promptWithBrowserOpen } from "../lib/prompt.mjs";
90
+ import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
62
91
  import {
63
92
  CliError,
64
93
  authError,
@@ -67,6 +96,26 @@ import {
67
96
  } from "../lib/errors.mjs";
68
97
  import { cliAuthUrl } from "../lib/constants.mjs";
69
98
 
99
+ /**
100
+ * Format the user-visible "configured X, Y" line for the init step-4
101
+ * success message. Resolves the vendor list to its placement targets,
102
+ * deduplicates via placementTargetsFor, and labels each with a
103
+ * readable path. Falls back to vendor display names if path resolution
104
+ * throws (e.g., a vendor was passed without a corresponding target —
105
+ * shouldn't happen post-validation, but the fallback prevents a
106
+ * step-4 success line from blowing up the rest of init).
107
+ */
108
+ function describeVendors(vendors, global) {
109
+ try {
110
+ const targets = placementTargetsFor({ vendors, global: !!global });
111
+ return targets.map(describePlacementTarget).join(", ");
112
+ } catch {
113
+ return vendors
114
+ .map((v) => AGENT_REGISTRY.find((e) => e.key === v)?.displayName ?? v)
115
+ .join(", ");
116
+ }
117
+ }
118
+
70
119
  // Local print helpers that use the INJECTED stdout/stderr streams.
71
120
  // The prompt.mjs `print*` helpers write to process.stdout directly,
72
121
  // which collides with the stream-injection pattern used by every
@@ -155,6 +204,16 @@ export async function runInit(argv, io = {}, deps = {}) {
155
204
 
156
205
  const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
157
206
 
207
+ // Capture detection state EARLY — before step 3 writes
208
+ // `~/.claude/skillrepo/config.json`, which would itself create
209
+ // `~/.claude/` and self-fire the Claude Code home-trace signal.
210
+ // Detection has to reflect what's on disk before init touches
211
+ // anything. The result feeds the step 4 picker; it's only used
212
+ // when the user did NOT pass `--agent` (otherwise the picker is
213
+ // skipped). Capturing unconditionally is cheap (a handful of
214
+ // `existsSync` probes) and keeps the code simple.
215
+ const earlyDetections = detectAgents();
216
+
158
217
  // `--json` is for non-interactive consumers (CI scripts, programmatic
159
218
  // callers). Without `--yes`, init would hang at step 5 (MCP merge
160
219
  // per-vendor confirm prompt) waiting for stdin that no one is going
@@ -303,7 +362,10 @@ export async function runInit(argv, io = {}, deps = {}) {
303
362
  // three entries they should add manually. The config is already
304
363
  // saved at this point, so we don't abort init.
305
364
  try {
306
- const gitignoreResult = mergeGitignore();
365
+ const gitignoreResult = mergeGitignore({
366
+ vendors: flags.vendors ?? undefined,
367
+ global: flags.global,
368
+ });
307
369
  if (gitignoreResult.action === "created") {
308
370
  p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
309
371
  } else if (gitignoreResult.action === "updated") {
@@ -316,61 +378,58 @@ export async function runInit(argv, io = {}, deps = {}) {
316
378
  p.warning(
317
379
  `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
318
380
  `Add these entries manually: .env.local, .claude/skills/, ` +
319
- `.claude/settings.local.json`,
381
+ `.agents/skills/, .claude/settings.local.json`,
320
382
  );
321
383
  }
322
384
  p.blank();
323
385
 
324
- // ── Step 4: Detect IDEs ──────────────────────────────────────
325
- p.step(4, 7, "Detecting IDEs");
386
+ // ── Step 4: Detection + two-row picker (#1236) ──────────────
387
+ p.step(4, 7, "Configuring targets");
326
388
 
327
- // The v2.0.0 CLI had a silent fallback to [claudeCode, cursor]
328
- // when nothing was detected. The v3.0.0 CLI removes that the
329
- // user must opt in via --ide. This catches headless CI scenarios
330
- // early rather than silently installing configs the user didn't
331
- // ask for.
389
+ // `flags.vendors === []` is the `--agent none` sentinel from
390
+ // parseAgentList. The user explicitly opted out of placement, so
391
+ // skip the picker entirely. Steps 5-7 downstream check
392
+ // `vendors.length === 0` to skip MCP merge, session-sync, and the
393
+ // first library sync.
394
+ //
395
+ // Any other non-null `flags.vendors` means the user passed an
396
+ // explicit `--agent` list — also skip the picker.
332
397
  let vendors;
333
- if (flags.vendors) {
398
+ if (Array.isArray(flags.vendors) && flags.vendors.length === 0) {
334
399
  vendors = flags.vendors;
335
- p.success(`Using explicit --ide list: ${vendors.join(", ")}`);
336
- } else {
337
- const detected = detectIdes();
338
- const detectedKeys = Object.entries(detected)
339
- .filter(([, v]) => v)
340
- .map(([k]) => k);
341
- const ideList = formatDetectedIdes(detected);
342
- for (const ide of ideList) {
343
- if (ide.detected) p.success(ide.name);
344
- }
345
- if (detectedKeys.length === 0) {
346
- // Print a copy-pasteable MCP config blob so users whose IDEs
347
- // aren't supported for auto-detection can still wire up MCP
348
- // manually. Then refuse so headless CI scenarios fail loudly
349
- // unless the user opts in with --ide. This call makes
350
- // printManualMcpInstructions live code — it was exported
351
- // but unused after the initial PR3b draft.
352
- const mcpUrl = `${serverUrl}/api/mcp`;
353
- printManualMcpInstructions(mcpUrl, { stdout });
354
-
355
- throw validationError("No IDEs detected in this directory.", {
356
- hint:
357
- "The JSON above is a copy-pasteable MCP config for any IDE. " +
358
- "Or run init from inside a project with .claude/, .cursor/, .vscode/, " +
359
- "or with --ide <vendor> (claude, cursor, windsurf, vscode).",
360
- });
400
+ p.success("Skipping placement (--agent none)");
401
+ } else if (flags.vendors) {
402
+ vendors = flags.vendors;
403
+ if (vendors.length === 0) {
404
+ p.success("Skipping placement (--agent none).");
405
+ } else {
406
+ p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
361
407
  }
362
- // Confirm the detected list unless --yes
363
- if (!yes) {
364
- const names = ideList
365
- .filter((i) => i.detected)
366
- .map((i) => i.name)
367
- .join(", ");
368
- const ok = await confirm(`Configure for: ${names}?`, true);
369
- if (!ok) {
370
- throw validationError("Cancelled. Pass --ide <vendor> to target specific IDEs.");
371
- }
408
+ } else {
409
+ vendors = await runDetectionPicker({
410
+ yes,
411
+ json: flags.json,
412
+ stdout,
413
+ detections: earlyDetections,
414
+ });
415
+ if (vendors.length === 0) {
416
+ p.success("Skipping placement (None selected).");
417
+ } else {
418
+ p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
372
419
  }
373
- vendors = detectedKeys;
420
+ }
421
+
422
+ // Validate the (vendor, --global) combination upfront. Catches cases
423
+ // like `--global --agent copilot` (Copilot has no personal scope)
424
+ // before any side-effectful work runs. Without this check, the
425
+ // validation error would fire from inside runSync and be swallowed
426
+ // by the partial-state try/catch below — exiting 0 with a warning
427
+ // when the user's invocation was structurally invalid.
428
+ if (Array.isArray(vendors) && vendors.length > 0) {
429
+ placementTargetsFor({
430
+ vendors: effectiveVendors({ vendors, global: flags.global }),
431
+ global: flags.global,
432
+ });
374
433
  }
375
434
  p.blank();
376
435
 
@@ -415,9 +474,15 @@ export async function runInit(argv, io = {}, deps = {}) {
415
474
  // because the hook's `skillrepo update` calls expect the config
416
475
  // to already be written (step 3).
417
476
  p.step(6, 7, "Session sync");
477
+ // Install the Claude Code SessionStart hook only when Claude Code
478
+ // is actually a target. Pre-#1249 this condition also included
479
+ // `Boolean(flags.global)` because bare `--global` historically
480
+ // routed to Claude; after #1249's effectiveVendors fix, `--global
481
+ // --agent windsurf` legitimately targets Windsurf only, and we
482
+ // must NOT silently add a Claude hook for users who never picked
483
+ // Claude. Manual E2E sweep caught this regression.
418
484
  const claudeTargeted =
419
- Boolean(flags.global) ||
420
- (Array.isArray(vendors) && vendors.includes("claudeCode"));
485
+ Array.isArray(vendors) && vendors.includes("claudeCode");
421
486
  const sessionSync = await installSessionSyncHook({
422
487
  noSessionSync,
423
488
  claudeTargeted,
@@ -436,62 +501,87 @@ export async function runInit(argv, io = {}, deps = {}) {
436
501
  p.step(7, 7, "Pulling library");
437
502
  let syncSummary;
438
503
  let syncFailedReason = null;
439
- try {
440
- // In --json mode, suppress runSync's warning prints to stdout
441
- // by passing a black-hole stream. (Its warnings go to stderr
442
- // by default, which stays visible.)
443
- const syncIo = flags.json
444
- ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
445
- : io;
446
- syncSummary = await runSync({
447
- serverUrl,
448
- apiKey,
449
- vendors: effectiveVendors({ vendors, global: flags.global }),
450
- global: flags.global,
451
- io: syncIo,
452
- });
453
- } catch (err) {
454
- // A sync failure after the rest of init succeeded is a
455
- // partial-state concern: config IS saved, MCP IS configured,
456
- // and the access key IS validated — only the skill files
457
- // haven't been fetched yet. The right recovery is `skillrepo
458
- // update`, not a re-init. Surface the warning and exit 0 so
459
- // the user sees the "run update later" message as actionable
460
- // guidance rather than as a failed-command contradiction.
461
- //
462
- // Round-2 review caught the prior behavior (warn + unconditional
463
- // rethrow) as an inconsistent contract: the warning told users
464
- // to retry with `update`, but the non-zero exit code made it
465
- // look like the whole init had failed.
466
- p.warning(
467
- `Config saved but first sync failed: ${err.message}. ` +
468
- `Run \`skillrepo update\` later to retry.`,
469
- );
470
- syncFailedReason = err.message;
471
- // Synthesize a zero-delta summary matching the SyncSummary
472
- // typedef in sync.mjs (added, updated, removed, notModified,
473
- // syncedAt). No `unchanged` or `failed` field — those were
474
- // phantom fields the cross-review flagged; failure is signaled
475
- // via `syncFailedReason` locally and via `sync.failureReason`
476
- // in the --json output below.
504
+ // `--agent none` short-circuits the first sync: the user opted
505
+ // out of placement, and the credential write + gitignore + MCP
506
+ // bookkeeping that init also does are still complete. Calling
507
+ // runSync with an empty vendor list would throw inside
508
+ // placementTargetsFor (no vendors specified), and even if it
509
+ // didn't, fetching skill files we have nowhere to write is
510
+ // pure waste.
511
+ const skipFirstSync =
512
+ Array.isArray(vendors) && vendors.length === 0 && !flags.global;
513
+ if (skipFirstSync) {
514
+ p.success("Skipped first sync (--agent none).");
477
515
  syncSummary = {
478
516
  added: 0,
479
517
  updated: 0,
480
518
  removed: 0,
481
519
  notModified: false,
482
- // On a synthesized failure summary we genuinely don't know
483
- // whether the sync WOULD have been full or delta the network
484
- // call never completed. Architect review (v3.1.1) flagged that
485
- // emitting `fullSync: false` here is misleading for --json
486
- // consumers: it looks like a legitimate "delta sync returned
487
- // zero" signal. Using `null` makes the unknown-state
488
- // explicit — any typed consumer must handle it separately
489
- // from true/false. The always-present `sync.failureReason`
490
- // field is still the authoritative "did the sync fail"
491
- // indicator; fullSync is just additional context.
520
+ // Same null-fullSync rationale as the failure-recovery branch:
521
+ // the network call never ran, so reporting `false` would be a
522
+ // legitimate-looking "delta returned zero" lie. `null` makes
523
+ // the unknown-state explicit.
492
524
  fullSync: null,
493
525
  syncedAt: new Date().toISOString(),
494
526
  };
527
+ } else {
528
+ try {
529
+ // In --json mode, suppress runSync's warning prints to stdout
530
+ // by passing a black-hole stream. (Its warnings go to stderr
531
+ // by default, which stays visible.)
532
+ const syncIo = flags.json
533
+ ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
534
+ : io;
535
+ syncSummary = await runSync({
536
+ serverUrl,
537
+ apiKey,
538
+ vendors: effectiveVendors({ vendors, global: flags.global }),
539
+ global: flags.global,
540
+ io: syncIo,
541
+ });
542
+ } catch (err) {
543
+ // A sync failure after the rest of init succeeded is a
544
+ // partial-state concern: config IS saved, MCP IS configured,
545
+ // and the access key IS validated — only the skill files
546
+ // haven't been fetched yet. The right recovery is `skillrepo
547
+ // update`, not a re-init. Surface the warning and exit 0 so
548
+ // the user sees the "run update later" message as actionable
549
+ // guidance rather than as a failed-command contradiction.
550
+ //
551
+ // Round-2 review caught the prior behavior (warn + unconditional
552
+ // rethrow) as an inconsistent contract: the warning told users
553
+ // to retry with `update`, but the non-zero exit code made it
554
+ // look like the whole init had failed.
555
+ p.warning(
556
+ `Config saved but first sync failed: ${err.message}. ` +
557
+ `Run \`skillrepo update\` later to retry.`,
558
+ );
559
+ syncFailedReason = err.message;
560
+ // Synthesize a zero-delta summary matching the SyncSummary
561
+ // typedef in sync.mjs (added, updated, removed, notModified,
562
+ // syncedAt). No `unchanged` or `failed` field — those were
563
+ // phantom fields the cross-review flagged; failure is signaled
564
+ // via `syncFailedReason` locally and via `sync.failureReason`
565
+ // in the --json output below.
566
+ syncSummary = {
567
+ added: 0,
568
+ updated: 0,
569
+ removed: 0,
570
+ notModified: false,
571
+ // On a synthesized failure summary we genuinely don't know
572
+ // whether the sync WOULD have been full or delta — the network
573
+ // call never completed. Architect review (v3.1.1) flagged that
574
+ // emitting `fullSync: false` here is misleading for --json
575
+ // consumers: it looks like a legitimate "delta sync returned
576
+ // zero" signal. Using `null` makes the unknown-state
577
+ // explicit — any typed consumer must handle it separately
578
+ // from true/false. The always-present `sync.failureReason`
579
+ // field is still the authoritative "did the sync fail"
580
+ // indicator; fullSync is just additional context.
581
+ fullSync: null,
582
+ syncedAt: new Date().toISOString(),
583
+ };
584
+ }
495
585
  }
496
586
 
497
587
  const zeroDeltas =
@@ -609,10 +699,10 @@ export async function runInit(argv, io = {}, deps = {}) {
609
699
 
610
700
  /**
611
701
  * Parse init-specific flags. Uses resolveFlags for the common ones
612
- * (--key/--url/--global/--ide/--json) and pulls out --yes/-y + --force.
702
+ * (--key/--url/--global/--agent/--json) and pulls out --yes/-y + --force.
613
703
  */
614
704
  function parseInitFlags(argv) {
615
- // resolveFlags handles --key/--url/--global/--ide/--json and
705
+ // resolveFlags handles --key/--url/--global/--agent/--json and
616
706
  // rejects unknown flags via its acceptPositional callback. We
617
707
  // intercept --yes, --force, and --no-session-sync as "positional-
618
708
  // shaped" flags via the callback so we don't need to pre-filter
@@ -654,3 +744,237 @@ function parseInitFlags(argv) {
654
744
 
655
745
  return { flags, yes, force, noSessionSync };
656
746
  }
747
+
748
+ /**
749
+ * Build the picker rows from a detection result.
750
+ *
751
+ * Both Claude Code and the cohort row are pre-checked when ANY signal
752
+ * fires for that target. On a fresh clone with no detection, both
753
+ * rows are still pre-checked — the product's reason for existing is
754
+ * to set up skills, and defaulting to "do nothing" because env vars
755
+ * didn't fire is the bug we are fixing.
756
+ *
757
+ * @param {ReturnType<typeof detectAgents>} detections
758
+ * @returns {{ items: import("../lib/prompt-multiselect.mjs").MultiSelectItem[], claudeReason: string|null, cohortReasons: string[] }}
759
+ */
760
+ function buildPickerItems(detections) {
761
+ const claudeDetection = detections.find((d) => d.key === "claudeCode");
762
+ const cohortDetections = detections.filter((d) =>
763
+ AGENTS_COHORT_KEYS.includes(d.key),
764
+ );
765
+ const cohortDetectedNames = cohortDetections
766
+ .filter((d) => d.detected)
767
+ .map((d) => d.displayName);
768
+
769
+ const claudeDetected = Boolean(claudeDetection?.detected);
770
+ const cohortDetected = cohortDetectedNames.length > 0;
771
+
772
+ // Idempotent re-run hint: if the target dir already exists with
773
+ // content, annotate the row.
774
+ const claudeAlreadyConfigured = directoryHasContent(claudeSkillsProjectRoot());
775
+ const cohortAlreadyConfigured = directoryHasContent(agentsSkillsProjectRoot());
776
+
777
+ const claudeHint = formatHint({
778
+ detectionReason: claudeDetected ? claudeDetection.reason : null,
779
+ pathLabel: ".claude/skills/",
780
+ alreadyConfigured: claudeAlreadyConfigured,
781
+ });
782
+ const cohortHint = formatHint({
783
+ detectionReason: cohortDetected ? formatCohortBrands(cohortDetectedNames) : null,
784
+ pathLabel: ".agents/skills/",
785
+ alreadyConfigured: cohortAlreadyConfigured,
786
+ });
787
+
788
+ // Three-state pre-check policy:
789
+ // 1. Detection fires for both targets → both rows pre-checked.
790
+ // 2. Detection fires for one target only → only that row pre-checked
791
+ // (the other stays empty so the picker nudges the user toward
792
+ // what we actually found).
793
+ // 3. No detection anywhere (fresh clone) → both rows pre-checked
794
+ // (the product's job is to set things up; defaulting to "do
795
+ // nothing" is the bug we are fixing).
796
+ const anySignal = claudeDetected || cohortDetected;
797
+ const claudePreChecked = anySignal ? claudeDetected : true;
798
+ const cohortPreChecked = anySignal ? cohortDetected : true;
799
+
800
+ return {
801
+ claudeReason: claudeDetected ? claudeDetection.reason : null,
802
+ cohortReasons: cohortDetectedNames,
803
+ items: [
804
+ {
805
+ key: "claude",
806
+ label: "Claude Code",
807
+ hint: claudeHint,
808
+ preChecked: claudePreChecked,
809
+ },
810
+ {
811
+ key: "agents",
812
+ label: "Other agents",
813
+ hint: cohortHint,
814
+ preChecked: cohortPreChecked,
815
+ },
816
+ {
817
+ key: "none",
818
+ label: "None — I'll configure manually",
819
+ preChecked: false,
820
+ },
821
+ ],
822
+ };
823
+ }
824
+
825
+ /**
826
+ * Format the cohort detection reason. Up to 3 brand names listed,
827
+ * then "+N more" — keeps the picker hint readable when most cohort
828
+ * vendors fire at once.
829
+ *
830
+ * @param {string[]} names
831
+ */
832
+ export function formatCohortBrands(names) {
833
+ if (names.length <= 3) return names.join(", ");
834
+ return `${names.slice(0, 3).join(", ")} +${names.length - 3} more`;
835
+ }
836
+
837
+ /**
838
+ * Build the per-row hint string. Combines the path label, the
839
+ * detection reason (or the "no signal" fallback), and the
840
+ * already-configured annotation when applicable.
841
+ *
842
+ * Exported for unit-test surface area (#1252 / QA round): the
843
+ * `(already configured — re-checking will refresh)` annotation has
844
+ * historically been only end-to-end-tested via the picker, which
845
+ * locks the wording to its rendering layer rather than to the data
846
+ * shape callers expect. Direct testing of this helper means a future
847
+ * refactor that splits picker rendering from row construction can't
848
+ * silently drop the annotation without breaking the targeted unit
849
+ * test.
850
+ *
851
+ * @param {object} args
852
+ * @param {string|null} args.detectionReason
853
+ * @param {string} args.pathLabel
854
+ * @param {boolean} args.alreadyConfigured
855
+ */
856
+ export function formatHint({ detectionReason, pathLabel, alreadyConfigured }) {
857
+ const detectionPart = detectionReason
858
+ ? `(detected: ${detectionReason})`
859
+ : "(not detected — leave checked if you use one)";
860
+ const configuredPart = alreadyConfigured
861
+ ? " (already configured — re-checking will refresh)"
862
+ : "";
863
+ return `${pathLabel} ${detectionPart}${configuredPart}`;
864
+ }
865
+
866
+ /**
867
+ * `true` when `dir` exists and contains at least one entry. Used to
868
+ * annotate the picker rows with the idempotent "already configured"
869
+ * hint. We don't validate skill layout here — any content under the
870
+ * target dir means a prior run wrote there.
871
+ *
872
+ * Exported alongside `formatHint` for direct unit-test coverage of
873
+ * the idempotent re-run annotation path. See `formatHint` for the
874
+ * rationale.
875
+ *
876
+ * @param {string} dir
877
+ */
878
+ export function directoryHasContent(dir) {
879
+ if (!existsSync(dir)) return false;
880
+ try {
881
+ const entries = readdirSync(dir);
882
+ return entries.length > 0;
883
+ } catch {
884
+ // Permission denied, race, etc. — treat as "no content" so the
885
+ // row is annotated as fresh. Annotation is informational; a
886
+ // false negative just shows the row without the "already
887
+ // configured" hint, which is harmless.
888
+ return false;
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Run the two-row picker and translate the user's selection into a
894
+ * vendors list. The output matches what `--agent` produces:
895
+ *
896
+ * - "claude" selected → ["claudeCode"]
897
+ * - "agents" selected → cohort keys
898
+ * - "claude" + "agents" selected → ["claudeCode", ...cohort]
899
+ * - "none" selected (alone) → []
900
+ * - "none" + anything else → rejected (validation error)
901
+ *
902
+ * Under `--yes`, the picker render is skipped: the pre-checked rows
903
+ * become the selection. With no detection, that means both default
904
+ * rows pre-checked → both targets configured. Spec rationale: writing
905
+ * a few KB the user didn't strictly need is trivial; CI running
906
+ * `init --yes` on a fresh clone and writing nothing is broken
907
+ * automation.
908
+ *
909
+ * @param {object} args
910
+ * @param {boolean} args.yes
911
+ * @param {boolean} args.json - True under --json: suppress the human
912
+ * explanatory header so stdout stays parseable.
913
+ * @param {NodeJS.WritableStream} args.stdout
914
+ * @param {ReturnType<typeof detectAgents>} args.detections - Captured
915
+ * BEFORE step 3 writes `~/.claude/skillrepo/config.json`
916
+ * (which would itself create `~/.claude/` and self-fire the
917
+ * Claude Code home-trace signal).
918
+ * @returns {Promise<string[]>}
919
+ */
920
+ async function runDetectionPicker({ yes, json, stdout, detections }) {
921
+ const { items } = buildPickerItems(detections);
922
+
923
+ // Print the explanatory header before the picker (or before the
924
+ // pre-checked auto-select under --yes). Keeps the user grounded
925
+ // in WHAT will be written WHERE. Suppressed under --json so the
926
+ // final JSON blob is the only thing on stdout.
927
+ if (!json) {
928
+ stdout.write(
929
+ "\n This will write skills to your project under:\n" +
930
+ " .claude/skills/ (for Claude Code)\n" +
931
+ " .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
932
+ "Cline, Copilot, and others)\n" +
933
+ "\n" +
934
+ " Both paths are added to .gitignore — skills are a per-developer " +
935
+ "cache, not committed.\n\n",
936
+ );
937
+ }
938
+
939
+ let selectedKeys;
940
+ if (yes) {
941
+ // Auto-select: pre-checked rows become the selection.
942
+ selectedKeys = items
943
+ .filter((it) => it.preChecked)
944
+ .map((it) => it.key);
945
+ } else {
946
+ selectedKeys = await promptMultiSelect(
947
+ { question: "Set up which targets?", items },
948
+ { stdout },
949
+ );
950
+ }
951
+
952
+ return translatePickerSelection(selectedKeys);
953
+ }
954
+
955
+ /**
956
+ * Map the picker's selected keys to the canonical vendor list.
957
+ * Exported for test surface area would be ideal but the function is
958
+ * an internal helper to `runDetectionPicker` — translation is
959
+ * exercised end-to-end via `init.test.mjs`.
960
+ *
961
+ * @param {string[]} selected
962
+ * @returns {string[]}
963
+ */
964
+ function translatePickerSelection(selected) {
965
+ const hasNone = selected.includes("none");
966
+ const hasOther = selected.some((k) => k !== "none");
967
+ if (hasNone && hasOther) {
968
+ throw validationError(
969
+ "Cannot mix 'None' with other targets in the picker.",
970
+ {
971
+ hint: "Pick either 'None' alone (skip placement) or one or both of the configured target rows.",
972
+ },
973
+ );
974
+ }
975
+ if (hasNone) return [];
976
+ const out = [];
977
+ if (selected.includes("claude")) out.push("claudeCode");
978
+ if (selected.includes("agents")) out.push(...AGENTS_COHORT_KEYS);
979
+ return out;
980
+ }
@@ -14,7 +14,7 @@
14
14
  * --json Pipe-friendly JSON output
15
15
  * --key/--url Override credentials
16
16
  *
17
- * No --global / --ide flags — `list` is a library-state inspector,
17
+ * No --global / --agent flags — `list` is a library-state inspector,
18
18
  * not a writer.
19
19
  */
20
20