skillrepo 3.1.5 → 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 (47) hide show
  1. package/README.md +108 -33
  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 +446 -112
  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/browser-open.mjs +136 -0
  14. package/src/lib/cli-config.mjs +146 -44
  15. package/src/lib/constants.mjs +69 -0
  16. package/src/lib/detect-agents.mjs +112 -0
  17. package/src/lib/file-write.mjs +162 -77
  18. package/src/lib/http.mjs +17 -3
  19. package/src/lib/mcp-merge.mjs +17 -36
  20. package/src/lib/mergers/gitignore.mjs +55 -28
  21. package/src/lib/paths.mjs +27 -25
  22. package/src/lib/prompt-multiselect.mjs +324 -0
  23. package/src/lib/prompt.mjs +81 -5
  24. package/src/lib/sync.mjs +18 -19
  25. package/src/test/commands/add.test.mjs +18 -3
  26. package/src/test/commands/init-picker.test.mjs +144 -0
  27. package/src/test/commands/init.test.mjs +228 -42
  28. package/src/test/commands/remove.test.mjs +4 -1
  29. package/src/test/commands/update.test.mjs +13 -3
  30. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  31. package/src/test/e2e/cli-commands.test.mjs +39 -13
  32. package/src/test/integration/file-write.integration.test.mjs +31 -10
  33. package/src/test/lib/agent-registry.test.mjs +215 -0
  34. package/src/test/lib/browser-open.test.mjs +187 -0
  35. package/src/test/lib/cli-config.test.mjs +222 -38
  36. package/src/test/lib/constants.test.mjs +93 -0
  37. package/src/test/lib/detect-agents.test.mjs +336 -0
  38. package/src/test/lib/file-write-placement.test.mjs +264 -0
  39. package/src/test/lib/file-write.test.mjs +231 -30
  40. package/src/test/lib/http.test.mjs +63 -0
  41. package/src/test/lib/mcp-merge.test.mjs +23 -15
  42. package/src/test/lib/paths.test.mjs +53 -17
  43. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  44. package/src/test/lib/prompt.test.mjs +154 -0
  45. package/src/test/lib/sync.test.mjs +157 -0
  46. package/src/lib/detect-ides.mjs +0 -44
  47. 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,37 +54,67 @@
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
- confirm,
60
- } 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";
61
91
  import {
62
92
  CliError,
63
93
  authError,
64
94
  validationError,
65
95
  EXIT_AUTH,
66
96
  } from "../lib/errors.mjs";
97
+ import { cliAuthUrl } from "../lib/constants.mjs";
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
+ }
67
118
 
68
119
  // Local print helpers that use the INJECTED stdout/stderr streams.
69
120
  // The prompt.mjs `print*` helpers write to process.stdout directly,
@@ -153,6 +204,16 @@ export async function runInit(argv, io = {}, deps = {}) {
153
204
 
154
205
  const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
155
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
+
156
217
  // `--json` is for non-interactive consumers (CI scripts, programmatic
157
218
  // callers). Without `--yes`, init would hang at step 5 (MCP merge
158
219
  // per-vendor confirm prompt) waiting for stdin that no one is going
@@ -204,7 +265,10 @@ export async function runInit(argv, io = {}, deps = {}) {
204
265
  hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
205
266
  });
206
267
  }
207
- apiKey = await promptSecret("Enter your access key (sk_live_...)");
268
+ apiKey = await promptWithBrowserOpen(
269
+ cliAuthUrl(serverUrl),
270
+ "Enter your access key (sk_live_...)",
271
+ );
208
272
  }
209
273
 
210
274
  // Trim whitespace from the key before validating. Pasting an API
@@ -241,7 +305,12 @@ export async function runInit(argv, io = {}, deps = {}) {
241
305
  !yes
242
306
  ) {
243
307
  p.warning("Existing config has an invalid key. Re-prompting for a new one.");
244
- apiKey = (await promptSecret("Enter your access key (sk_live_...)")).trim();
308
+ apiKey = (
309
+ await promptWithBrowserOpen(
310
+ cliAuthUrl(serverUrl),
311
+ "Enter your access key (sk_live_...)",
312
+ )
313
+ ).trim();
245
314
  if (!apiKey || !apiKey.startsWith("sk_live_")) {
246
315
  throw validationError("Invalid access key format.");
247
316
  }
@@ -293,7 +362,10 @@ export async function runInit(argv, io = {}, deps = {}) {
293
362
  // three entries they should add manually. The config is already
294
363
  // saved at this point, so we don't abort init.
295
364
  try {
296
- const gitignoreResult = mergeGitignore();
365
+ const gitignoreResult = mergeGitignore({
366
+ vendors: flags.vendors ?? undefined,
367
+ global: flags.global,
368
+ });
297
369
  if (gitignoreResult.action === "created") {
298
370
  p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
299
371
  } else if (gitignoreResult.action === "updated") {
@@ -306,61 +378,58 @@ export async function runInit(argv, io = {}, deps = {}) {
306
378
  p.warning(
307
379
  `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
308
380
  `Add these entries manually: .env.local, .claude/skills/, ` +
309
- `.claude/settings.local.json`,
381
+ `.agents/skills/, .claude/settings.local.json`,
310
382
  );
311
383
  }
312
384
  p.blank();
313
385
 
314
- // ── Step 4: Detect IDEs ──────────────────────────────────────
315
- p.step(4, 7, "Detecting IDEs");
386
+ // ── Step 4: Detection + two-row picker (#1236) ──────────────
387
+ p.step(4, 7, "Configuring targets");
316
388
 
317
- // The v2.0.0 CLI had a silent fallback to [claudeCode, cursor]
318
- // when nothing was detected. The v3.0.0 CLI removes that the
319
- // user must opt in via --ide. This catches headless CI scenarios
320
- // early rather than silently installing configs the user didn't
321
- // 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.
322
397
  let vendors;
323
- if (flags.vendors) {
398
+ if (Array.isArray(flags.vendors) && flags.vendors.length === 0) {
324
399
  vendors = flags.vendors;
325
- p.success(`Using explicit --ide list: ${vendors.join(", ")}`);
326
- } else {
327
- const detected = detectIdes();
328
- const detectedKeys = Object.entries(detected)
329
- .filter(([, v]) => v)
330
- .map(([k]) => k);
331
- const ideList = formatDetectedIdes(detected);
332
- for (const ide of ideList) {
333
- if (ide.detected) p.success(ide.name);
334
- }
335
- if (detectedKeys.length === 0) {
336
- // Print a copy-pasteable MCP config blob so users whose IDEs
337
- // aren't supported for auto-detection can still wire up MCP
338
- // manually. Then refuse so headless CI scenarios fail loudly
339
- // unless the user opts in with --ide. This call makes
340
- // printManualMcpInstructions live code — it was exported
341
- // but unused after the initial PR3b draft.
342
- const mcpUrl = `${serverUrl}/api/mcp`;
343
- printManualMcpInstructions(mcpUrl, { stdout });
344
-
345
- throw validationError("No IDEs detected in this directory.", {
346
- hint:
347
- "The JSON above is a copy-pasteable MCP config for any IDE. " +
348
- "Or run init from inside a project with .claude/, .cursor/, .vscode/, " +
349
- "or with --ide <vendor> (claude, cursor, windsurf, vscode).",
350
- });
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)}`);
351
407
  }
352
- // Confirm the detected list unless --yes
353
- if (!yes) {
354
- const names = ideList
355
- .filter((i) => i.detected)
356
- .map((i) => i.name)
357
- .join(", ");
358
- const ok = await confirm(`Configure for: ${names}?`, true);
359
- if (!ok) {
360
- throw validationError("Cancelled. Pass --ide <vendor> to target specific IDEs.");
361
- }
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)}`);
362
419
  }
363
- 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
+ });
364
433
  }
365
434
  p.blank();
366
435
 
@@ -405,9 +474,15 @@ export async function runInit(argv, io = {}, deps = {}) {
405
474
  // because the hook's `skillrepo update` calls expect the config
406
475
  // to already be written (step 3).
407
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.
408
484
  const claudeTargeted =
409
- Boolean(flags.global) ||
410
- (Array.isArray(vendors) && vendors.includes("claudeCode"));
485
+ Array.isArray(vendors) && vendors.includes("claudeCode");
411
486
  const sessionSync = await installSessionSyncHook({
412
487
  noSessionSync,
413
488
  claudeTargeted,
@@ -426,62 +501,87 @@ export async function runInit(argv, io = {}, deps = {}) {
426
501
  p.step(7, 7, "Pulling library");
427
502
  let syncSummary;
428
503
  let syncFailedReason = null;
429
- try {
430
- // In --json mode, suppress runSync's warning prints to stdout
431
- // by passing a black-hole stream. (Its warnings go to stderr
432
- // by default, which stays visible.)
433
- const syncIo = flags.json
434
- ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
435
- : io;
436
- syncSummary = await runSync({
437
- serverUrl,
438
- apiKey,
439
- vendors: effectiveVendors({ vendors, global: flags.global }),
440
- global: flags.global,
441
- io: syncIo,
442
- });
443
- } catch (err) {
444
- // A sync failure after the rest of init succeeded is a
445
- // partial-state concern: config IS saved, MCP IS configured,
446
- // and the access key IS validated — only the skill files
447
- // haven't been fetched yet. The right recovery is `skillrepo
448
- // update`, not a re-init. Surface the warning and exit 0 so
449
- // the user sees the "run update later" message as actionable
450
- // guidance rather than as a failed-command contradiction.
451
- //
452
- // Round-2 review caught the prior behavior (warn + unconditional
453
- // rethrow) as an inconsistent contract: the warning told users
454
- // to retry with `update`, but the non-zero exit code made it
455
- // look like the whole init had failed.
456
- p.warning(
457
- `Config saved but first sync failed: ${err.message}. ` +
458
- `Run \`skillrepo update\` later to retry.`,
459
- );
460
- syncFailedReason = err.message;
461
- // Synthesize a zero-delta summary matching the SyncSummary
462
- // typedef in sync.mjs (added, updated, removed, notModified,
463
- // syncedAt). No `unchanged` or `failed` field — those were
464
- // phantom fields the cross-review flagged; failure is signaled
465
- // via `syncFailedReason` locally and via `sync.failureReason`
466
- // 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).");
467
515
  syncSummary = {
468
516
  added: 0,
469
517
  updated: 0,
470
518
  removed: 0,
471
519
  notModified: false,
472
- // On a synthesized failure summary we genuinely don't know
473
- // whether the sync WOULD have been full or delta the network
474
- // call never completed. Architect review (v3.1.1) flagged that
475
- // emitting `fullSync: false` here is misleading for --json
476
- // consumers: it looks like a legitimate "delta sync returned
477
- // zero" signal. Using `null` makes the unknown-state
478
- // explicit — any typed consumer must handle it separately
479
- // from true/false. The always-present `sync.failureReason`
480
- // field is still the authoritative "did the sync fail"
481
- // 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.
482
524
  fullSync: null,
483
525
  syncedAt: new Date().toISOString(),
484
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
+ }
485
585
  }
486
586
 
487
587
  const zeroDeltas =
@@ -599,10 +699,10 @@ export async function runInit(argv, io = {}, deps = {}) {
599
699
 
600
700
  /**
601
701
  * Parse init-specific flags. Uses resolveFlags for the common ones
602
- * (--key/--url/--global/--ide/--json) and pulls out --yes/-y + --force.
702
+ * (--key/--url/--global/--agent/--json) and pulls out --yes/-y + --force.
603
703
  */
604
704
  function parseInitFlags(argv) {
605
- // resolveFlags handles --key/--url/--global/--ide/--json and
705
+ // resolveFlags handles --key/--url/--global/--agent/--json and
606
706
  // rejects unknown flags via its acceptPositional callback. We
607
707
  // intercept --yes, --force, and --no-session-sync as "positional-
608
708
  // shaped" flags via the callback so we don't need to pre-filter
@@ -644,3 +744,237 @@ function parseInitFlags(argv) {
644
744
 
645
745
  return { flags, yes, force, noSessionSync };
646
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
+ }