skillrepo 3.2.0 → 4.1.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 (53) hide show
  1. package/README.md +137 -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-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. 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,41 @@
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";
84
+ import { installCohortHooks } from "./init-cohort-hooks.mjs";
56
85
  import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
57
86
  import {
58
- promptSecret,
59
- promptWithBrowserOpen,
60
- confirm,
61
- } from "../lib/prompt.mjs";
87
+ claudeSkillsProjectRoot,
88
+ agentsSkillsProjectRoot,
89
+ } from "../lib/paths.mjs";
90
+ import { promptWithBrowserOpen } from "../lib/prompt.mjs";
91
+ import { promptMultiSelect } from "../lib/prompt-multiselect.mjs";
62
92
  import {
63
93
  CliError,
64
94
  authError,
@@ -67,6 +97,26 @@ import {
67
97
  } from "../lib/errors.mjs";
68
98
  import { cliAuthUrl } from "../lib/constants.mjs";
69
99
 
100
+ /**
101
+ * Format the user-visible "configured X, Y" line for the init step-4
102
+ * success message. Resolves the vendor list to its placement targets,
103
+ * deduplicates via placementTargetsFor, and labels each with a
104
+ * readable path. Falls back to vendor display names if path resolution
105
+ * throws (e.g., a vendor was passed without a corresponding target —
106
+ * shouldn't happen post-validation, but the fallback prevents a
107
+ * step-4 success line from blowing up the rest of init).
108
+ */
109
+ function describeVendors(vendors, global) {
110
+ try {
111
+ const targets = placementTargetsFor({ vendors, global: !!global });
112
+ return targets.map(describePlacementTarget).join(", ");
113
+ } catch {
114
+ return vendors
115
+ .map((v) => AGENT_REGISTRY.find((e) => e.key === v)?.displayName ?? v)
116
+ .join(", ");
117
+ }
118
+ }
119
+
70
120
  // Local print helpers that use the INJECTED stdout/stderr streams.
71
121
  // The prompt.mjs `print*` helpers write to process.stdout directly,
72
122
  // which collides with the stream-injection pattern used by every
@@ -155,6 +205,16 @@ export async function runInit(argv, io = {}, deps = {}) {
155
205
 
156
206
  const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
157
207
 
208
+ // Capture detection state EARLY — before step 3 writes
209
+ // `~/.claude/skillrepo/config.json`, which would itself create
210
+ // `~/.claude/` and self-fire the Claude Code home-trace signal.
211
+ // Detection has to reflect what's on disk before init touches
212
+ // anything. The result feeds the step 4 picker; it's only used
213
+ // when the user did NOT pass `--agent` (otherwise the picker is
214
+ // skipped). Capturing unconditionally is cheap (a handful of
215
+ // `existsSync` probes) and keeps the code simple.
216
+ const earlyDetections = detectAgents();
217
+
158
218
  // `--json` is for non-interactive consumers (CI scripts, programmatic
159
219
  // callers). Without `--yes`, init would hang at step 5 (MCP merge
160
220
  // per-vendor confirm prompt) waiting for stdin that no one is going
@@ -303,7 +363,10 @@ export async function runInit(argv, io = {}, deps = {}) {
303
363
  // three entries they should add manually. The config is already
304
364
  // saved at this point, so we don't abort init.
305
365
  try {
306
- const gitignoreResult = mergeGitignore();
366
+ const gitignoreResult = mergeGitignore({
367
+ vendors: flags.vendors ?? undefined,
368
+ global: flags.global,
369
+ });
307
370
  if (gitignoreResult.action === "created") {
308
371
  p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
309
372
  } else if (gitignoreResult.action === "updated") {
@@ -316,61 +379,58 @@ export async function runInit(argv, io = {}, deps = {}) {
316
379
  p.warning(
317
380
  `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
318
381
  `Add these entries manually: .env.local, .claude/skills/, ` +
319
- `.claude/settings.local.json`,
382
+ `.agents/skills/, .claude/settings.local.json`,
320
383
  );
321
384
  }
322
385
  p.blank();
323
386
 
324
- // ── Step 4: Detect IDEs ──────────────────────────────────────
325
- p.step(4, 7, "Detecting IDEs");
387
+ // ── Step 4: Detection + two-row picker (#1236) ──────────────
388
+ p.step(4, 7, "Configuring targets");
326
389
 
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.
390
+ // `flags.vendors === []` is the `--agent none` sentinel from
391
+ // parseAgentList. The user explicitly opted out of placement, so
392
+ // skip the picker entirely. Steps 5-7 downstream check
393
+ // `vendors.length === 0` to skip MCP merge, session-sync, and the
394
+ // first library sync.
395
+ //
396
+ // Any other non-null `flags.vendors` means the user passed an
397
+ // explicit `--agent` list — also skip the picker.
332
398
  let vendors;
333
- if (flags.vendors) {
399
+ if (Array.isArray(flags.vendors) && flags.vendors.length === 0) {
334
400
  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
- });
401
+ p.success("Skipping placement (--agent none)");
402
+ } else if (flags.vendors) {
403
+ vendors = flags.vendors;
404
+ if (vendors.length === 0) {
405
+ p.success("Skipping placement (--agent none).");
406
+ } else {
407
+ p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
361
408
  }
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
- }
409
+ } else {
410
+ vendors = await runDetectionPicker({
411
+ yes,
412
+ json: flags.json,
413
+ stdout,
414
+ detections: earlyDetections,
415
+ });
416
+ if (vendors.length === 0) {
417
+ p.success("Skipping placement (None selected).");
418
+ } else {
419
+ p.success(`Configured: ${describeVendors(vendors, flags.global)}`);
372
420
  }
373
- vendors = detectedKeys;
421
+ }
422
+
423
+ // Validate the (vendor, --global) combination upfront. Catches cases
424
+ // like `--global --agent copilot` (Copilot has no personal scope)
425
+ // before any side-effectful work runs. Without this check, the
426
+ // validation error would fire from inside runSync and be swallowed
427
+ // by the partial-state try/catch below — exiting 0 with a warning
428
+ // when the user's invocation was structurally invalid.
429
+ if (Array.isArray(vendors) && vendors.length > 0) {
430
+ placementTargetsFor({
431
+ vendors: effectiveVendors({ vendors, global: flags.global }),
432
+ global: flags.global,
433
+ });
374
434
  }
375
435
  p.blank();
376
436
 
@@ -407,17 +467,41 @@ export async function runInit(argv, io = {}, deps = {}) {
407
467
  }
408
468
  p.blank();
409
469
 
410
- // ── Step 6: Session sync (#884, v3.1.2 auto-install #894) ─────
470
+ // ── Step 6: Session sync (#884 Claude path + #1240 cohort path)
471
+ //
472
+ // Two sibling installers run here:
411
473
  //
412
- // The full decision tree (six branches) lives in
413
- // `init-session-sync.mjs`. This step is inserted between MCP
414
- // merge (step 5) and the first sync (step 7) — order matters
415
- // because the hook's `skillrepo update` calls expect the config
416
- // to already be written (step 3).
474
+ // - Claude Code's SessionStart hook (#884) `installSessionSyncHook`
475
+ // in `init-session-sync.mjs`. Has its own six-branch decision
476
+ // tree because Claude Code's hook needs an absolute-path command
477
+ // (Claude doesn't load PATH at hook time), which under `npx
478
+ // skillrepo init` requires offering to install skillrepo
479
+ // globally first.
480
+ // - Cohort SessionStart hooks (#1240) — `installCohortHooks` in
481
+ // `init-cohort-hooks.mjs`. Writes hooks for every selected vendor
482
+ // whose agent-registry entry has a non-null `agentHook` spec
483
+ // (Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). Uses
484
+ // `npx --yes skillrepo update --silent` as the universal hook
485
+ // command — no global install needed.
486
+ //
487
+ // The two flows are deliberately separate. Folding them into a single
488
+ // function would create irreducible branching by Phase 4 (#1241-1244)
489
+ // because Claude's binaryPath resolution has no analogue in the
490
+ // cohort path.
491
+ //
492
+ // `--no-session-sync` skips BOTH paths (semantics widened in #1240
493
+ // from "skip Claude" to "skip all auto-refresh hooks").
417
494
  p.step(6, 7, "Session sync");
495
+
496
+ // Install the Claude Code SessionStart hook only when Claude Code
497
+ // is actually a target. Pre-#1249 this condition also included
498
+ // `Boolean(flags.global)` because bare `--global` historically
499
+ // routed to Claude; after #1249's effectiveVendors fix, `--global
500
+ // --agent windsurf` legitimately targets Windsurf only, and we
501
+ // must NOT silently add a Claude hook for users who never picked
502
+ // Claude. Manual E2E sweep caught this regression.
418
503
  const claudeTargeted =
419
- Boolean(flags.global) ||
420
- (Array.isArray(vendors) && vendors.includes("claudeCode"));
504
+ Array.isArray(vendors) && vendors.includes("claudeCode");
421
505
  const sessionSync = await installSessionSyncHook({
422
506
  noSessionSync,
423
507
  claudeTargeted,
@@ -430,68 +514,103 @@ export async function runInit(argv, io = {}, deps = {}) {
430
514
  const sessionSyncAction = sessionSync.action;
431
515
  const sessionSyncPath = sessionSync.path;
432
516
  const globalInstallActive = sessionSync.globalInstallActive;
517
+
518
+ // Cohort SessionStart hooks for every non-Claude selected vendor
519
+ // with an `agentHook` spec. Skipped under `--no-session-sync` AND
520
+ // when no eligible vendors are selected (e.g. `--agent claude` only,
521
+ // or `--agent none`). Per-vendor failures do not abort init —
522
+ // each result entry carries its own `action: "failed"` + `reason`.
523
+ const cohortHooksResult =
524
+ Array.isArray(vendors) && vendors.length > 0
525
+ ? installCohortHooks({ noSessionSync, vendors, p })
526
+ : { hooks: [] };
433
527
  p.blank();
434
528
 
435
529
  // ── Step 7: First sync ───────────────────────────────────────
436
530
  p.step(7, 7, "Pulling library");
437
531
  let syncSummary;
438
532
  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.
533
+ // `--agent none` short-circuits the first sync: the user opted
534
+ // out of placement, and the credential write + gitignore + MCP
535
+ // bookkeeping that init also does are still complete. Calling
536
+ // runSync with an empty vendor list would throw inside
537
+ // placementTargetsFor (no vendors specified), and even if it
538
+ // didn't, fetching skill files we have nowhere to write is
539
+ // pure waste.
540
+ const skipFirstSync =
541
+ Array.isArray(vendors) && vendors.length === 0 && !flags.global;
542
+ if (skipFirstSync) {
543
+ p.success("Skipped first sync (--agent none).");
477
544
  syncSummary = {
478
545
  added: 0,
479
546
  updated: 0,
480
547
  removed: 0,
481
548
  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.
549
+ // Same null-fullSync rationale as the failure-recovery branch:
550
+ // the network call never ran, so reporting `false` would be a
551
+ // legitimate-looking "delta returned zero" lie. `null` makes
552
+ // the unknown-state explicit.
492
553
  fullSync: null,
493
554
  syncedAt: new Date().toISOString(),
494
555
  };
556
+ } else {
557
+ try {
558
+ // In --json mode, suppress runSync's warning prints to stdout
559
+ // by passing a black-hole stream. (Its warnings go to stderr
560
+ // by default, which stays visible.)
561
+ const syncIo = flags.json
562
+ ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
563
+ : io;
564
+ syncSummary = await runSync({
565
+ serverUrl,
566
+ apiKey,
567
+ vendors: effectiveVendors({ vendors, global: flags.global }),
568
+ global: flags.global,
569
+ io: syncIo,
570
+ });
571
+ } catch (err) {
572
+ // A sync failure after the rest of init succeeded is a
573
+ // partial-state concern: config IS saved, MCP IS configured,
574
+ // and the access key IS validated — only the skill files
575
+ // haven't been fetched yet. The right recovery is `skillrepo
576
+ // update`, not a re-init. Surface the warning and exit 0 so
577
+ // the user sees the "run update later" message as actionable
578
+ // guidance rather than as a failed-command contradiction.
579
+ //
580
+ // Round-2 review caught the prior behavior (warn + unconditional
581
+ // rethrow) as an inconsistent contract: the warning told users
582
+ // to retry with `update`, but the non-zero exit code made it
583
+ // look like the whole init had failed.
584
+ p.warning(
585
+ `Config saved but first sync failed: ${err.message}. ` +
586
+ `Run \`skillrepo update\` later to retry.`,
587
+ );
588
+ syncFailedReason = err.message;
589
+ // Synthesize a zero-delta summary matching the SyncSummary
590
+ // typedef in sync.mjs (added, updated, removed, notModified,
591
+ // syncedAt). No `unchanged` or `failed` field — those were
592
+ // phantom fields the cross-review flagged; failure is signaled
593
+ // via `syncFailedReason` locally and via `sync.failureReason`
594
+ // in the --json output below.
595
+ syncSummary = {
596
+ added: 0,
597
+ updated: 0,
598
+ removed: 0,
599
+ notModified: false,
600
+ // On a synthesized failure summary we genuinely don't know
601
+ // whether the sync WOULD have been full or delta — the network
602
+ // call never completed. Architect review (v3.1.1) flagged that
603
+ // emitting `fullSync: false` here is misleading for --json
604
+ // consumers: it looks like a legitimate "delta sync returned
605
+ // zero" signal. Using `null` makes the unknown-state
606
+ // explicit — any typed consumer must handle it separately
607
+ // from true/false. The always-present `sync.failureReason`
608
+ // field is still the authoritative "did the sync fail"
609
+ // indicator; fullSync is just additional context.
610
+ fullSync: null,
611
+ syncedAt: new Date().toISOString(),
612
+ };
613
+ }
495
614
  }
496
615
 
497
616
  const zeroDeltas =
@@ -547,9 +666,19 @@ export async function runInit(argv, io = {}, deps = {}) {
547
666
  // Session-sync block. `action` is one of the
548
667
  // `SessionSyncAction` enum values defined in
549
668
  // `./session-sync-actions.mjs` (single source of truth).
669
+ // `cohortHooks` (added in #1240) reports per-vendor cohort
670
+ // SessionStart hook outcomes — empty array when no cohort
671
+ // vendor was selected or when --no-session-sync was passed.
550
672
  sessionSync: {
551
673
  action: sessionSyncAction,
552
674
  path: sessionSyncPath,
675
+ cohortHooks: cohortHooksResult.hooks.map((h) => ({
676
+ vendorKey: h.vendorKey,
677
+ displayName: h.displayName,
678
+ path: h.path,
679
+ action: h.action,
680
+ ...(h.reason ? { reason: h.reason } : {}),
681
+ })),
553
682
  },
554
683
  // Sync block always shows the counts (zeroed on failure)
555
684
  // and adds `failureReason` when the first sync blew up —
@@ -609,10 +738,10 @@ export async function runInit(argv, io = {}, deps = {}) {
609
738
 
610
739
  /**
611
740
  * Parse init-specific flags. Uses resolveFlags for the common ones
612
- * (--key/--url/--global/--ide/--json) and pulls out --yes/-y + --force.
741
+ * (--key/--url/--global/--agent/--json) and pulls out --yes/-y + --force.
613
742
  */
614
743
  function parseInitFlags(argv) {
615
- // resolveFlags handles --key/--url/--global/--ide/--json and
744
+ // resolveFlags handles --key/--url/--global/--agent/--json and
616
745
  // rejects unknown flags via its acceptPositional callback. We
617
746
  // intercept --yes, --force, and --no-session-sync as "positional-
618
747
  // shaped" flags via the callback so we don't need to pre-filter
@@ -654,3 +783,237 @@ function parseInitFlags(argv) {
654
783
 
655
784
  return { flags, yes, force, noSessionSync };
656
785
  }
786
+
787
+ /**
788
+ * Build the picker rows from a detection result.
789
+ *
790
+ * Both Claude Code and the cohort row are pre-checked when ANY signal
791
+ * fires for that target. On a fresh clone with no detection, both
792
+ * rows are still pre-checked — the product's reason for existing is
793
+ * to set up skills, and defaulting to "do nothing" because env vars
794
+ * didn't fire is the bug we are fixing.
795
+ *
796
+ * @param {ReturnType<typeof detectAgents>} detections
797
+ * @returns {{ items: import("../lib/prompt-multiselect.mjs").MultiSelectItem[], claudeReason: string|null, cohortReasons: string[] }}
798
+ */
799
+ function buildPickerItems(detections) {
800
+ const claudeDetection = detections.find((d) => d.key === "claudeCode");
801
+ const cohortDetections = detections.filter((d) =>
802
+ AGENTS_COHORT_KEYS.includes(d.key),
803
+ );
804
+ const cohortDetectedNames = cohortDetections
805
+ .filter((d) => d.detected)
806
+ .map((d) => d.displayName);
807
+
808
+ const claudeDetected = Boolean(claudeDetection?.detected);
809
+ const cohortDetected = cohortDetectedNames.length > 0;
810
+
811
+ // Idempotent re-run hint: if the target dir already exists with
812
+ // content, annotate the row.
813
+ const claudeAlreadyConfigured = directoryHasContent(claudeSkillsProjectRoot());
814
+ const cohortAlreadyConfigured = directoryHasContent(agentsSkillsProjectRoot());
815
+
816
+ const claudeHint = formatHint({
817
+ detectionReason: claudeDetected ? claudeDetection.reason : null,
818
+ pathLabel: ".claude/skills/",
819
+ alreadyConfigured: claudeAlreadyConfigured,
820
+ });
821
+ const cohortHint = formatHint({
822
+ detectionReason: cohortDetected ? formatCohortBrands(cohortDetectedNames) : null,
823
+ pathLabel: ".agents/skills/",
824
+ alreadyConfigured: cohortAlreadyConfigured,
825
+ });
826
+
827
+ // Three-state pre-check policy:
828
+ // 1. Detection fires for both targets → both rows pre-checked.
829
+ // 2. Detection fires for one target only → only that row pre-checked
830
+ // (the other stays empty so the picker nudges the user toward
831
+ // what we actually found).
832
+ // 3. No detection anywhere (fresh clone) → both rows pre-checked
833
+ // (the product's job is to set things up; defaulting to "do
834
+ // nothing" is the bug we are fixing).
835
+ const anySignal = claudeDetected || cohortDetected;
836
+ const claudePreChecked = anySignal ? claudeDetected : true;
837
+ const cohortPreChecked = anySignal ? cohortDetected : true;
838
+
839
+ return {
840
+ claudeReason: claudeDetected ? claudeDetection.reason : null,
841
+ cohortReasons: cohortDetectedNames,
842
+ items: [
843
+ {
844
+ key: "claude",
845
+ label: "Claude Code",
846
+ hint: claudeHint,
847
+ preChecked: claudePreChecked,
848
+ },
849
+ {
850
+ key: "agents",
851
+ label: "Other agents",
852
+ hint: cohortHint,
853
+ preChecked: cohortPreChecked,
854
+ },
855
+ {
856
+ key: "none",
857
+ label: "None — I'll configure manually",
858
+ preChecked: false,
859
+ },
860
+ ],
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Format the cohort detection reason. Up to 3 brand names listed,
866
+ * then "+N more" — keeps the picker hint readable when most cohort
867
+ * vendors fire at once.
868
+ *
869
+ * @param {string[]} names
870
+ */
871
+ export function formatCohortBrands(names) {
872
+ if (names.length <= 3) return names.join(", ");
873
+ return `${names.slice(0, 3).join(", ")} +${names.length - 3} more`;
874
+ }
875
+
876
+ /**
877
+ * Build the per-row hint string. Combines the path label, the
878
+ * detection reason (or the "no signal" fallback), and the
879
+ * already-configured annotation when applicable.
880
+ *
881
+ * Exported for unit-test surface area (#1252 / QA round): the
882
+ * `(already configured — re-checking will refresh)` annotation has
883
+ * historically been only end-to-end-tested via the picker, which
884
+ * locks the wording to its rendering layer rather than to the data
885
+ * shape callers expect. Direct testing of this helper means a future
886
+ * refactor that splits picker rendering from row construction can't
887
+ * silently drop the annotation without breaking the targeted unit
888
+ * test.
889
+ *
890
+ * @param {object} args
891
+ * @param {string|null} args.detectionReason
892
+ * @param {string} args.pathLabel
893
+ * @param {boolean} args.alreadyConfigured
894
+ */
895
+ export function formatHint({ detectionReason, pathLabel, alreadyConfigured }) {
896
+ const detectionPart = detectionReason
897
+ ? `(detected: ${detectionReason})`
898
+ : "(not detected — leave checked if you use one)";
899
+ const configuredPart = alreadyConfigured
900
+ ? " (already configured — re-checking will refresh)"
901
+ : "";
902
+ return `${pathLabel} ${detectionPart}${configuredPart}`;
903
+ }
904
+
905
+ /**
906
+ * `true` when `dir` exists and contains at least one entry. Used to
907
+ * annotate the picker rows with the idempotent "already configured"
908
+ * hint. We don't validate skill layout here — any content under the
909
+ * target dir means a prior run wrote there.
910
+ *
911
+ * Exported alongside `formatHint` for direct unit-test coverage of
912
+ * the idempotent re-run annotation path. See `formatHint` for the
913
+ * rationale.
914
+ *
915
+ * @param {string} dir
916
+ */
917
+ export function directoryHasContent(dir) {
918
+ if (!existsSync(dir)) return false;
919
+ try {
920
+ const entries = readdirSync(dir);
921
+ return entries.length > 0;
922
+ } catch {
923
+ // Permission denied, race, etc. — treat as "no content" so the
924
+ // row is annotated as fresh. Annotation is informational; a
925
+ // false negative just shows the row without the "already
926
+ // configured" hint, which is harmless.
927
+ return false;
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Run the two-row picker and translate the user's selection into a
933
+ * vendors list. The output matches what `--agent` produces:
934
+ *
935
+ * - "claude" selected → ["claudeCode"]
936
+ * - "agents" selected → cohort keys
937
+ * - "claude" + "agents" selected → ["claudeCode", ...cohort]
938
+ * - "none" selected (alone) → []
939
+ * - "none" + anything else → rejected (validation error)
940
+ *
941
+ * Under `--yes`, the picker render is skipped: the pre-checked rows
942
+ * become the selection. With no detection, that means both default
943
+ * rows pre-checked → both targets configured. Spec rationale: writing
944
+ * a few KB the user didn't strictly need is trivial; CI running
945
+ * `init --yes` on a fresh clone and writing nothing is broken
946
+ * automation.
947
+ *
948
+ * @param {object} args
949
+ * @param {boolean} args.yes
950
+ * @param {boolean} args.json - True under --json: suppress the human
951
+ * explanatory header so stdout stays parseable.
952
+ * @param {NodeJS.WritableStream} args.stdout
953
+ * @param {ReturnType<typeof detectAgents>} args.detections - Captured
954
+ * BEFORE step 3 writes `~/.claude/skillrepo/config.json`
955
+ * (which would itself create `~/.claude/` and self-fire the
956
+ * Claude Code home-trace signal).
957
+ * @returns {Promise<string[]>}
958
+ */
959
+ async function runDetectionPicker({ yes, json, stdout, detections }) {
960
+ const { items } = buildPickerItems(detections);
961
+
962
+ // Print the explanatory header before the picker (or before the
963
+ // pre-checked auto-select under --yes). Keeps the user grounded
964
+ // in WHAT will be written WHERE. Suppressed under --json so the
965
+ // final JSON blob is the only thing on stdout.
966
+ if (!json) {
967
+ stdout.write(
968
+ "\n This will write skills to your project under:\n" +
969
+ " .claude/skills/ (for Claude Code)\n" +
970
+ " .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
971
+ "Cline, Copilot, and others)\n" +
972
+ "\n" +
973
+ " Both paths are added to .gitignore — skills are a per-developer " +
974
+ "cache, not committed.\n\n",
975
+ );
976
+ }
977
+
978
+ let selectedKeys;
979
+ if (yes) {
980
+ // Auto-select: pre-checked rows become the selection.
981
+ selectedKeys = items
982
+ .filter((it) => it.preChecked)
983
+ .map((it) => it.key);
984
+ } else {
985
+ selectedKeys = await promptMultiSelect(
986
+ { question: "Set up which targets?", items },
987
+ { stdout },
988
+ );
989
+ }
990
+
991
+ return translatePickerSelection(selectedKeys);
992
+ }
993
+
994
+ /**
995
+ * Map the picker's selected keys to the canonical vendor list.
996
+ * Exported for test surface area would be ideal but the function is
997
+ * an internal helper to `runDetectionPicker` — translation is
998
+ * exercised end-to-end via `init.test.mjs`.
999
+ *
1000
+ * @param {string[]} selected
1001
+ * @returns {string[]}
1002
+ */
1003
+ function translatePickerSelection(selected) {
1004
+ const hasNone = selected.includes("none");
1005
+ const hasOther = selected.some((k) => k !== "none");
1006
+ if (hasNone && hasOther) {
1007
+ throw validationError(
1008
+ "Cannot mix 'None' with other targets in the picker.",
1009
+ {
1010
+ hint: "Pick either 'None' alone (skip placement) or one or both of the configured target rows.",
1011
+ },
1012
+ );
1013
+ }
1014
+ if (hasNone) return [];
1015
+ const out = [];
1016
+ if (selected.includes("claude")) out.push("claudeCode");
1017
+ if (selected.includes("agents")) out.push(...AGENTS_COHORT_KEYS);
1018
+ return out;
1019
+ }