skillrepo 2.0.0 → 3.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 (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -1,190 +1,636 @@
1
1
  /**
2
- * `skillrepo init` — main command orchestrator.
2
+ * `skillrepo init` (#673) PR3b rewrite, v3.1.0 session-sync (#884).
3
3
  *
4
- * Flow:
5
- * 1. Detect IDEs
6
- * 2. Prompt for access key
7
- * 3. Validate key + fetch skill count
8
- * 4. Write configs (global config, MCP, .gitignore)
9
- * 5. Print summary
4
+ * First-run command. Replaces the v2.0.0 init that consumed the
5
+ * deprecated `/api/v1/setup` endpoint and wrote hook-delivery
6
+ * artifacts. The new flow:
7
+ *
8
+ * 1. Collect credentials (--key | env var | interactive prompt)
9
+ * 2. Validate the key via POST /api/v1/auth/validate
10
+ * 3. Write ~/.claude/skillrepo/config.json via config.mjs
11
+ * 4. Detect installed IDEs (.claude/, .cursor/, .vscode/, ~/.codeium/windsurf/)
12
+ * 5. Run MCP auto-merge for detected IDEs (user-confirmed unless --yes)
13
+ * 6. Install Claude Code SessionStart hook (v3.1.0 #884)
14
+ * 7. Run the first library sync via sync.mjs
15
+ * 8. Print summary
16
+ *
17
+ * Key differences from v2.0.0:
18
+ *
19
+ * - Uses /api/v1/auth/validate (not /api/v1/setup)
20
+ * - Writes config.mjs format with schemaVersion
21
+ * - MCP merge is interactive with per-vendor prompts
22
+ * - NO rules-delivery files (.claude/rules/, .claude/skillrepo*.md) —
23
+ * those died with the hooks in #835
24
+ * - NO silent vendor fallback — if nothing is detected, refuse with
25
+ * a clear --ide hint
26
+ * - Idempotent: re-running with a valid existing config re-runs
27
+ * detection + MCP merge prompts + sync, but does NOT re-prompt
28
+ * for a key. A stale (401) key triggers automatic re-prompt.
29
+ * - --force re-prompts unconditionally for a new key
30
+ *
31
+ * Exit codes: inherited from errors.mjs via the underlying calls.
32
+ *
33
+ * Flags:
34
+ * --key/-k <key> Access key (overrides config + env)
35
+ * --url/-u <url> SkillRepo server URL (overrides config + env)
36
+ * --yes/-y Non-interactive: skip all prompts (MCP merge + IDE confirm)
37
+ * --force Re-prompt for a new key even if config exists and is valid
38
+ * --global Run first sync in global mode (~/.claude/skills/)
39
+ * --ide <list> Override detected IDEs (comma-separated)
40
+ * --json JSON summary output
10
41
  */
11
42
 
12
- import { detectIdes, formatDetectedIdes, getDetectedIdeKeys } from "../lib/detect-ides.mjs";
13
- import { fetchSetupPayload, AuthError, SuspendedError, NetworkError } from "../lib/http.mjs";
14
- import { writeAllConfigs } from "../lib/write-configs.mjs";
43
+ import { validateAccessKey } from "../lib/http.mjs";
44
+ import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
45
+ import { readConfig, writeConfig } from "../lib/config.mjs";
46
+ import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
47
+ import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
48
+ import { runSync } from "../lib/sync.mjs";
49
+ import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
50
+ import { mergeGitignore } from "../lib/mergers/gitignore.mjs";
51
+ import { mergeSessionHook } from "../lib/mergers/session-hook.mjs";
52
+ import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
15
53
  import {
16
- printHeader,
17
- printStep,
18
- printSuccess,
19
- printWarning,
20
- printError,
21
- printResult,
22
- printBlank,
23
54
  promptSecret,
24
55
  confirm,
25
56
  } from "../lib/prompt.mjs";
26
-
27
- import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
28
-
29
- const DEFAULT_URL = "https://skillrepo.dev";
57
+ import {
58
+ CliError,
59
+ authError,
60
+ validationError,
61
+ EXIT_AUTH,
62
+ } from "../lib/errors.mjs";
63
+
64
+ // Local print helpers that use the INJECTED stdout/stderr streams.
65
+ // The prompt.mjs `print*` helpers write to process.stdout directly,
66
+ // which collides with the stream-injection pattern used by every
67
+ // other command. The init command is the only one that needs the
68
+ // step-progress UI, so these helpers live here rather than being
69
+ // added to prompt.mjs.
70
+ //
71
+ // In --json mode, every human-readable helper is a no-op — stdout
72
+ // is reserved for the final JSON blob so the output is pipe-friendly.
73
+
74
+ const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
75
+ const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`;
76
+ const RED = (s) => `\x1b[31m${s}\x1b[0m`;
77
+ const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
78
+ const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
30
79
 
31
80
  /**
32
- * Parse CLI flags from argv.
33
- * @param {string[]} argv
81
+ * Build a set of print helpers tied to a specific output stream.
82
+ * When `silent` is true, every helper is a no-op except for
83
+ * `write()` which still writes to stdout (used for the final JSON).
34
84
  */
35
- function parseFlags(argv) {
36
- const flags = {
37
- key: resolveKeyFromEnvFiles(),
38
- url: process.env.SKILLREPO_URL || DEFAULT_URL,
39
- yes: false,
85
+ function makePrinter(stdout, stderr, silent) {
86
+ const useColor = !silent && !process.env.NO_COLOR && stdout.isTTY;
87
+ const paint = (color) => (s) => (useColor ? color(s) : s);
88
+ const green = paint(GREEN);
89
+ const yellow = paint(YELLOW);
90
+ const red = paint(RED);
91
+ const dim = paint(DIM);
92
+ const bold = paint(BOLD);
93
+
94
+ const noop = () => {};
95
+
96
+ return {
97
+ header: silent ? noop : (title) => {
98
+ stdout.write(`\n ${bold(title)}\n\n`);
99
+ },
100
+ step: silent ? noop : (n, total, msg) => {
101
+ stdout.write(` ${dim(`Step ${n}/${total}:`)} ${msg}\n`);
102
+ },
103
+ success: silent ? noop : (msg) => {
104
+ stdout.write(` ${green("✓")} ${msg}\n`);
105
+ },
106
+ warning: silent ? noop : (msg) => {
107
+ stdout.write(` ${yellow("⚠")} ${msg}\n`);
108
+ },
109
+ error: silent ? noop : (msg) => {
110
+ stderr.write(` ${red("✗")} ${msg}\n`);
111
+ },
112
+ blank: silent ? noop : () => {
113
+ stdout.write("\n");
114
+ },
115
+ /** Always writes regardless of silent mode — used for the final JSON. */
116
+ write: (text) => stdout.write(text),
40
117
  };
41
-
42
- for (let i = 0; i < argv.length; i++) {
43
- const arg = argv[i];
44
- if ((arg === "--key" || arg === "-k") && argv[i + 1]) {
45
- flags.key = argv[++i];
46
- } else if ((arg === "--url" || arg === "-u") && argv[i + 1]) {
47
- flags.url = argv[++i];
48
- } else if (arg === "--yes" || arg === "-y") {
49
- flags.yes = true;
50
- }
51
- }
52
-
53
- // Normalize URL
54
- flags.url = flags.url.replace(/\/+$/, "");
55
-
56
- return flags;
57
118
  }
58
119
 
120
+ const DEFAULT_URL = "https://skillrepo.dev";
121
+
59
122
  /**
60
- * Run the setup command.
61
- * @param {string[]} argv - CLI arguments after the "setup" subcommand
123
+ * A minimal no-op writable-stream shape. Used in --json mode to
124
+ * suppress sub-module writes that would otherwise pollute the
125
+ * captured stdout with non-JSON text. Any command/helper that
126
+ * follows the `{ stdout, stderr }` injection pattern can be
127
+ * silenced by passing this as `stdout`.
62
128
  */
63
- export async function runInit(argv) {
64
- const flags = parseFlags(argv);
65
-
66
- printHeader("SkillRepo Setup");
129
+ const BLACK_HOLE_STREAM = {
130
+ write: () => true,
131
+ isTTY: false,
132
+ };
67
133
 
68
- // ── Step 1: Detect IDEs ───────────────────────────────────────────────
69
- printStep(1, 4, "Detecting IDEs...");
70
-
71
- const detected = detectIdes();
72
- const ideList = formatDetectedIdes(detected);
73
- const detectedKeys = getDetectedIdeKeys(detected);
74
-
75
- for (const ide of ideList) {
76
- if (ide.detected) {
77
- printSuccess(`${ide.name}`);
78
- }
134
+ /**
135
+ * Run `init`. Throws CliError on failure.
136
+ *
137
+ * @param {string[]} argv
138
+ * @param {object} [io]
139
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
140
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
141
+ */
142
+ export async function runInit(argv, io = {}) {
143
+ const stdout = io.stdout ?? process.stdout;
144
+ const stderr = io.stderr ?? process.stderr;
145
+
146
+ const { flags, yes, force, noSessionSync } = parseInitFlags(argv);
147
+
148
+ // In --json mode, suppress all step-progress output so stdout
149
+ // carries only the final JSON blob.
150
+ const p = makePrinter(stdout, stderr, flags.json);
151
+
152
+ p.header("SkillRepo Init");
153
+
154
+ // ── Step 1: Collect credentials ───────────────────────────────
155
+ p.step(1, 7, "Credentials");
156
+
157
+ // Try sources in priority: --key flag > --url flag > global
158
+ // config > env vars > interactive prompt.
159
+ const existingConfig = readConfig();
160
+ let apiKey = flags.apiKey;
161
+ let serverUrl = flags.serverUrl;
162
+
163
+ // `--force` re-prompts for a new key AND clears the stored
164
+ // serverUrl so a user pointing at a different SkillRepo instance
165
+ // (e.g., switching orgs) starts fresh. Without the URL clear,
166
+ // --force was surprising: the key was re-prompted but the URL
167
+ // silently inherited from the old config.
168
+ if (!apiKey && !force && existingConfig) {
169
+ apiKey = existingConfig.apiKey;
79
170
  }
80
-
81
- if (detectedKeys.length === 0 || !Object.values(detected).some(Boolean)) {
82
- printWarning("No IDEs detected — defaulting to Claude Code + Cursor");
171
+ if (!serverUrl && !force && existingConfig) {
172
+ serverUrl = existingConfig.serverUrl;
83
173
  }
84
-
85
- // Confirm IDE selection
86
- if (!flags.yes) {
87
- const names = detectedKeys.map((k) => ideList.find((i) => i.key === k)?.name).filter(Boolean);
88
- const ok = await confirm(`Configure for: ${names.join(", ")}?`);
89
- if (!ok) {
90
- console.log(" Setup cancelled.");
91
- process.exit(0);
174
+ if (!serverUrl) {
175
+ serverUrl = process.env.SKILLREPO_URL || DEFAULT_URL;
176
+ }
177
+ if (!apiKey && !force) {
178
+ apiKey = resolveKeyFromEnvFiles();
179
+ }
180
+ // Interactive prompt if still missing (and not non-interactive)
181
+ if (!apiKey) {
182
+ if (yes) {
183
+ throw authError("No access key provided.", {
184
+ hint: "Pass --key sk_live_... or set SKILLREPO_ACCESS_KEY.",
185
+ });
92
186
  }
187
+ apiKey = await promptSecret("Enter your access key (sk_live_...)");
93
188
  }
94
189
 
95
- printBlank();
96
-
97
- // ── Step 2: Access Key ────────────────────────────────────────────────
98
- printStep(2, 4, "Access Key");
99
-
100
- let apiKey = flags.key;
101
- if (!apiKey) {
102
- apiKey = await promptSecret("Enter your SkillRepo access key (sk_live_...)");
190
+ // Trim whitespace from the key before validating. Pasting an API
191
+ // key from a browser or email client frequently adds a leading or
192
+ // trailing space or newline, which the server-side check would
193
+ // reject as invalid with no context. Trim early so the user gets
194
+ // a clean "works" or "wrong format" result.
195
+ if (typeof apiKey === "string") {
196
+ apiKey = apiKey.trim();
103
197
  }
104
198
 
105
- if (!apiKey || !apiKey.startsWith("sk_live_")) {
106
- printError("Invalid key format. Keys start with sk_live_");
107
- printError(`Get your key at ${flags.url}/app/integrations`);
108
- process.exit(1);
199
+ // Basic shape check (the full check is server-side via validate)
200
+ if (!apiKey || typeof apiKey !== "string" || !apiKey.startsWith("sk_live_")) {
201
+ throw validationError("Invalid access key format (must start with sk_live_).");
109
202
  }
110
203
 
111
- printBlank();
204
+ p.blank();
112
205
 
113
- // ── Step 3: Validate + Fetch ──────────────────────────────────────────
114
- printStep(3, 4, "Validating key...");
206
+ // ── Step 2: Validate against the server ──────────────────────
207
+ p.step(2, 7, "Validating key");
208
+ let accountCtx;
209
+ try {
210
+ accountCtx = await validateAccessKey(serverUrl, apiKey);
211
+ } catch (err) {
212
+ // If the existing config had a stale/revoked key, fall back to
213
+ // prompt (unless --yes, which is meant to be non-interactive).
214
+ // Use the EXIT_AUTH constant rather than a magic number so a
215
+ // future refactor of errors.mjs can't silently break this branch.
216
+ if (
217
+ err instanceof CliError &&
218
+ err.exitCode === EXIT_AUTH &&
219
+ existingConfig &&
220
+ !force &&
221
+ !yes
222
+ ) {
223
+ p.warning("Existing config has an invalid key. Re-prompting for a new one.");
224
+ apiKey = (await promptSecret("Enter your access key (sk_live_...)")).trim();
225
+ if (!apiKey || !apiKey.startsWith("sk_live_")) {
226
+ throw validationError("Invalid access key format.");
227
+ }
228
+ accountCtx = await validateAccessKey(serverUrl, apiKey);
229
+ } else {
230
+ throw err;
231
+ }
232
+ }
233
+ p.success(`Valid. Account: ${accountCtx.accountSlug} (${accountCtx.tier})`);
234
+ p.blank();
235
+
236
+ // ── Step 3: Write global config ──────────────────────────────
237
+ p.step(3, 7, "Writing config");
238
+ const configAction = writeConfig({
239
+ apiKey,
240
+ serverUrl,
241
+ accountSlug: accountCtx.accountSlug,
242
+ accountId: accountCtx.accountId,
243
+ userId: accountCtx.userId,
244
+ });
245
+ p.success(`~/.claude/skillrepo/config.json ${configAction}`);
246
+
247
+ // Also write to .env.local so agents that read the env var
248
+ // continue to work. The mergeEnvLocal helper is unchanged from
249
+ // v2.0.0 and writes SKILLREPO_ACCESS_KEY=... in the project root.
250
+ try {
251
+ mergeEnvLocal(apiKey);
252
+ } catch (err) {
253
+ // Defensive: `err?.message ?? String(err)` handles the edge
254
+ // case where a non-Error value is thrown (plain string, number,
255
+ // etc.) and `err.message` would be undefined, producing a
256
+ // garbled warning.
257
+ p.warning(
258
+ `Could not write .env.local: ${err?.message ?? String(err)}. ` +
259
+ `Config is still saved at ~/.claude/skillrepo/config.json.`,
260
+ );
261
+ }
115
262
 
116
- let payload;
263
+ // Ensure .env.local, .claude/skills/, and
264
+ // .claude/settings.local.json are in .gitignore. The access key
265
+ // in .env.local is the security-critical entry — without this
266
+ // step a user running `init` in a fresh project could commit
267
+ // their key with the next `git add .`. PR4 round-3 review caught
268
+ // that this gitignore management was documented but never
269
+ // implemented; this call closes that gap.
270
+ //
271
+ // Failure is non-fatal: if .gitignore is read-only or the user
272
+ // isn't in a git repo, we print a warning pointing them at the
273
+ // three entries they should add manually. The config is already
274
+ // saved at this point, so we don't abort init.
117
275
  try {
118
- payload = await fetchSetupPayload(apiKey, flags.url);
276
+ const gitignoreResult = mergeGitignore();
277
+ if (gitignoreResult.action === "created") {
278
+ p.success(`.gitignore created with ${gitignoreResult.added.length} SkillRepo entries`);
279
+ } else if (gitignoreResult.action === "updated") {
280
+ p.success(
281
+ `.gitignore updated (added: ${gitignoreResult.added.join(", ")})`,
282
+ );
283
+ }
284
+ // "skipped" → all entries already present, no output needed
119
285
  } catch (err) {
120
- if (err instanceof AuthError) {
121
- printError(`Invalid access key. Get your key at ${flags.url}/app/integrations`);
122
- process.exit(1);
286
+ p.warning(
287
+ `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
288
+ `Add these entries manually: .env.local, .claude/skills/, ` +
289
+ `.claude/settings.local.json`,
290
+ );
291
+ }
292
+ p.blank();
293
+
294
+ // ── Step 4: Detect IDEs ──────────────────────────────────────
295
+ p.step(4, 7, "Detecting IDEs");
296
+
297
+ // The v2.0.0 CLI had a silent fallback to [claudeCode, cursor]
298
+ // when nothing was detected. The v3.0.0 CLI removes that — the
299
+ // user must opt in via --ide. This catches headless CI scenarios
300
+ // early rather than silently installing configs the user didn't
301
+ // ask for.
302
+ let vendors;
303
+ if (flags.vendors) {
304
+ vendors = flags.vendors;
305
+ p.success(`Using explicit --ide list: ${vendors.join(", ")}`);
306
+ } else {
307
+ const detected = detectIdes();
308
+ const detectedKeys = Object.entries(detected)
309
+ .filter(([, v]) => v)
310
+ .map(([k]) => k);
311
+ const ideList = formatDetectedIdes(detected);
312
+ for (const ide of ideList) {
313
+ if (ide.detected) p.success(ide.name);
123
314
  }
124
- if (err instanceof SuspendedError) {
125
- printError(`Account suspended: ${err.reason || "Contact your admin"}`);
126
- process.exit(1);
315
+ if (detectedKeys.length === 0) {
316
+ // Print a copy-pasteable MCP config blob so users whose IDEs
317
+ // aren't supported for auto-detection can still wire up MCP
318
+ // manually. Then refuse so headless CI scenarios fail loudly
319
+ // unless the user opts in with --ide. This call makes
320
+ // printManualMcpInstructions live code — it was exported
321
+ // but unused after the initial PR3b draft.
322
+ const mcpUrl = `${serverUrl}/api/mcp`;
323
+ printManualMcpInstructions(mcpUrl, { stdout });
324
+
325
+ throw validationError("No IDEs detected in this directory.", {
326
+ hint:
327
+ "The JSON above is a copy-pasteable MCP config for any IDE. " +
328
+ "Or run init from inside a project with .claude/, .cursor/, .vscode/, " +
329
+ "or with --ide <vendor> (claude, cursor, windsurf, vscode).",
330
+ });
127
331
  }
128
- if (err instanceof NetworkError) {
129
- printError(`Cannot reach ${flags.url}. Check your network and the --url flag.`);
130
- process.exit(1);
332
+ // Confirm the detected list unless --yes
333
+ if (!yes) {
334
+ const names = ideList
335
+ .filter((i) => i.detected)
336
+ .map((i) => i.name)
337
+ .join(", ");
338
+ const ok = await confirm(`Configure for: ${names}?`, true);
339
+ if (!ok) {
340
+ throw validationError("Cancelled. Pass --ide <vendor> to target specific IDEs.");
341
+ }
131
342
  }
132
- throw err;
343
+ vendors = detectedKeys;
133
344
  }
134
-
135
- printSuccess(`Valid. ${payload.skillCount} skills in your library.`);
136
-
137
- if (payload.skillCount === 0) {
138
- printWarning("No skills in your library yet. Config will be written — skills will activate when you add them.");
345
+ p.blank();
346
+
347
+ // ── Step 5: MCP auto-merge ───────────────────────────────────
348
+ p.step(5, 7, "Configuring MCP");
349
+ const mcpUrl = `${serverUrl}/api/mcp`;
350
+ // In --json mode, pass a black-hole stdout to mergeMcpForVendors
351
+ // so its per-vendor preview lines don't pollute the JSON output.
352
+ // Preserve stderr for warnings (which go to the user regardless).
353
+ const mergeIo = flags.json
354
+ ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
355
+ : io;
356
+ const mcpResults = await mergeMcpForVendors({
357
+ vendors,
358
+ mcpUrl,
359
+ yes,
360
+ io: mergeIo,
361
+ });
362
+ // Summarize
363
+ const merged = mcpResults.filter((r) => r.outcome === "merged");
364
+ const skipped = mcpResults.filter((r) => r.outcome === "skipped");
365
+ const failed = mcpResults.filter((r) => r.outcome === "failed");
366
+ if (merged.length > 0) {
367
+ p.success(`MCP configured for ${merged.length} vendor${merged.length === 1 ? "" : "s"}`);
139
368
  }
369
+ if (skipped.length > 0) {
370
+ p.warning(`MCP skipped for ${skipped.length} vendor${skipped.length === 1 ? "" : "s"}`);
371
+ }
372
+ if (failed.length > 0) {
373
+ p.warning(`MCP failed for ${failed.length} vendor${failed.length === 1 ? "" : "s"}`);
374
+ for (const r of failed) {
375
+ p.error(` ${r.path}: ${r.reason}`);
376
+ }
377
+ }
378
+ p.blank();
379
+
380
+ // ── Step 6: Session sync (#884) ──────────────────────────────
381
+ //
382
+ // Install the Claude Code SessionStart hook so the user's library
383
+ // auto-syncs on every session start. Per the architect design in
384
+ // issue #884:
385
+ // - Opt-in by default. Interactive mode prompts before installing.
386
+ // `--yes` skips the prompt and installs. `--no-session-sync`
387
+ // is the explicit opt-out for BOTH modes — CI bootstraps
388
+ // without session sync by passing it.
389
+ // - If `which skillrepo` fails (e.g. npx user without a global
390
+ // install), we SKIP with a warning. Init continues — do not
391
+ // abort for this.
392
+ // - A failure writing the settings file is non-fatal: the
393
+ // config, MCP, and first sync still run. Users can re-run
394
+ // `skillrepo session-sync enable` later.
395
+ // - **Skip entirely when Claude Code is not the target.** The
396
+ // SessionStart hook is Claude Code-specific: it lives at
397
+ // `.claude/settings.local.json` and is only read by Claude
398
+ // Code's session-start machinery. A Cursor-only or
399
+ // Windsurf-only user doesn't benefit from it and shouldn't
400
+ // get a prompt for it. Cross-PR review (v3.1.0) flagged this
401
+ // as a silent-useless-state bug: without the guard, the hook
402
+ // file was written even for non-Claude projects. The only
403
+ // "Claude Code is the target" signals are:
404
+ // • `vendors` includes "claudeCode" (either explicit
405
+ // `--ide claude` or detected `.claude/` directory), OR
406
+ // • `--global` is passed (writes to
407
+ // `~/.claude/settings.local.json`, which is explicitly
408
+ // Claude Code's user-wide path).
409
+ // Everything else → skip with a clear message.
410
+ //
411
+ // This step is INSERTED between MCP merge (step 5) and the first
412
+ // sync (step 7). Order matters — we need the config written
413
+ // (step 3) so the hook's `skillrepo update` calls find creds.
414
+ p.step(6, 7, "Session sync");
415
+ let sessionSyncAction = "skipped";
416
+ let sessionSyncPath = null;
417
+ const claudeTargeted =
418
+ Boolean(flags.global) ||
419
+ (Array.isArray(vendors) && vendors.includes("claudeCode"));
420
+ if (noSessionSync) {
421
+ p.warning("Session sync skipped (--no-session-sync).");
422
+ sessionSyncAction = "opted-out";
423
+ } else if (!claudeTargeted) {
424
+ // Non-Claude-Code target (e.g. `--ide cursor`) — the hook would
425
+ // never fire, so skip the prompt AND the install. This is the
426
+ // v3.1.0 cross-PR review fix.
427
+ p.warning(
428
+ "Session sync skipped: the SessionStart hook is Claude Code-specific " +
429
+ "and no Claude Code target was configured.",
430
+ );
431
+ sessionSyncAction = "not-applicable";
432
+ } else {
433
+ let proceed = true;
434
+ if (!yes) {
435
+ proceed = await confirm(
436
+ "Install Claude Code SessionStart hook so your library auto-syncs on every session start?",
437
+ true,
438
+ );
439
+ }
440
+ if (!proceed) {
441
+ p.warning(
442
+ "Session sync skipped. Run `skillrepo session-sync enable` to install it later.",
443
+ );
444
+ sessionSyncAction = "declined";
445
+ } else {
446
+ try {
447
+ const result = mergeSessionHook({ global: flags.global });
448
+ sessionSyncAction = result.action;
449
+ sessionSyncPath = result.path;
450
+ if (result.action === "installed") {
451
+ p.success(`SessionStart hook installed (${result.path})`);
452
+ } else if (result.action === "updated") {
453
+ p.success(`SessionStart hook updated (${result.path})`);
454
+ } else if (result.action === "unchanged") {
455
+ p.success(`SessionStart hook already installed (${result.path})`);
456
+ } else if (result.action === "skipped") {
457
+ // Binary not resolvable — the installer returned a reason.
458
+ p.warning(result.reason ?? "Session sync skipped.");
459
+ }
460
+ } catch (err) {
461
+ // Disk error (corrupt settings file, permissions). The
462
+ // other init steps already ran, so the config and MCP are
463
+ // still in place. Surface the error and continue to the
464
+ // first sync step — do NOT abort.
465
+ p.warning(
466
+ `Session sync failed: ${err?.message ?? String(err)}. ` +
467
+ `Run \`skillrepo session-sync enable\` after fixing the issue.`,
468
+ );
469
+ sessionSyncAction = "failed";
470
+ }
471
+ }
472
+ }
473
+ p.blank();
140
474
 
141
- printBlank();
142
-
143
- // ── Step 4: Write configs ─────────────────────────────────────────────
144
- printStep(4, 4, "Writing configuration...");
145
- printBlank();
146
-
147
- const mcpUrl = `${flags.url}/api/mcp`;
148
-
149
- let results;
475
+ // ── Step 7: First sync ───────────────────────────────────────
476
+ p.step(7, 7, "Pulling library");
477
+ let syncSummary;
478
+ let syncFailedReason = null;
150
479
  try {
151
- results = writeAllConfigs({
152
- ides: detectedKeys,
153
- mcpUrl,
480
+ // In --json mode, suppress runSync's warning prints to stdout
481
+ // by passing a black-hole stream. (Its warnings go to stderr
482
+ // by default, which stays visible.)
483
+ const syncIo = flags.json
484
+ ? { stdout: BLACK_HOLE_STREAM, stderr: stderr }
485
+ : io;
486
+ syncSummary = await runSync({
487
+ serverUrl,
154
488
  apiKey,
155
- serverUrl: flags.url,
156
- userId: payload.userId,
489
+ vendors: effectiveVendors({ vendors, global: flags.global }),
490
+ global: flags.global,
491
+ io: syncIo,
157
492
  });
158
493
  } catch (err) {
159
- printError(err.message);
160
- process.exit(2);
494
+ // A sync failure after the rest of init succeeded is a
495
+ // partial-state concern: config IS saved, MCP IS configured,
496
+ // and the access key IS validated — only the skill files
497
+ // haven't been fetched yet. The right recovery is `skillrepo
498
+ // update`, not a re-init. Surface the warning and exit 0 so
499
+ // the user sees the "run update later" message as actionable
500
+ // guidance rather than as a failed-command contradiction.
501
+ //
502
+ // Round-2 review caught the prior behavior (warn + unconditional
503
+ // rethrow) as an inconsistent contract: the warning told users
504
+ // to retry with `update`, but the non-zero exit code made it
505
+ // look like the whole init had failed.
506
+ p.warning(
507
+ `Config saved but first sync failed: ${err.message}. ` +
508
+ `Run \`skillrepo update\` later to retry.`,
509
+ );
510
+ syncFailedReason = err.message;
511
+ // Synthesize a zero-delta summary matching the SyncSummary
512
+ // typedef in sync.mjs (added, updated, removed, notModified,
513
+ // syncedAt). No `unchanged` or `failed` field — those were
514
+ // phantom fields the cross-review flagged; failure is signaled
515
+ // via `syncFailedReason` locally and via `sync.failureReason`
516
+ // in the --json output below.
517
+ syncSummary = {
518
+ added: 0,
519
+ updated: 0,
520
+ removed: 0,
521
+ notModified: false,
522
+ syncedAt: new Date().toISOString(),
523
+ };
161
524
  }
162
525
 
163
- for (const r of results) {
164
- printResult(r.path, r.action);
526
+ if (syncFailedReason) {
527
+ // The warning already printed; the step-summary success line
528
+ // would be misleading, so we skip it. Any helpful "next steps"
529
+ // is in the final `SkillRepo is ready` block.
530
+ } else if (syncSummary.notModified || syncSummary.added + syncSummary.updated + syncSummary.removed === 0) {
531
+ p.success("No skills in library yet (add some with `skillrepo add @owner/name`)");
532
+ } else {
533
+ p.success(
534
+ `${syncSummary.added} added, ${syncSummary.updated} updated, ${syncSummary.removed} removed`,
535
+ );
165
536
  }
166
-
167
- printBlank();
168
-
169
- // ── Summary ───────────────────────────────────────────────────────────
170
- printBlank();
171
- printSuccess("SkillRepo is ready.");
172
- printBlank();
173
- console.log(" Next steps:");
174
- console.log(" • Commit the generated config files to git");
175
- console.log(" • Each team member runs: npx skillrepo init");
176
- console.log(" • SKILLREPO_ACCESS_KEY is in .env.local (gitignored)");
177
-
178
- if (detectedKeys.includes("claudeCode")) {
179
- printBlank();
180
- console.log(" Claude Code: Skills are delivered via .claude/rules/ files.");
181
- console.log(" They refresh automatically on each session start.");
537
+ p.blank();
538
+
539
+ // ── Summary ──────────────────────────────────────────────────
540
+ if (flags.json) {
541
+ stdout.write(
542
+ JSON.stringify(
543
+ {
544
+ action: "initialized",
545
+ account: {
546
+ slug: accountCtx.accountSlug,
547
+ id: accountCtx.accountId,
548
+ tier: accountCtx.tier,
549
+ },
550
+ config: { action: configAction },
551
+ vendors,
552
+ mcp: {
553
+ merged: merged.map((r) => r.path),
554
+ skipped: skipped.map((r) => r.path),
555
+ failed: failed.map((r) => ({ path: r.path, reason: r.reason })),
556
+ },
557
+ // Session-sync block — action values:
558
+ // "installed" | "updated" | "unchanged" (success states)
559
+ // "opted-out" (--no-session-sync)
560
+ // "declined" (user said no at the prompt)
561
+ // "not-applicable" (no Claude Code target — e.g. `--ide cursor`)
562
+ // "skipped" (binary not resolvable — reason in non-json path)
563
+ // "failed" (disk error during install)
564
+ sessionSync: {
565
+ action: sessionSyncAction,
566
+ path: sessionSyncPath,
567
+ },
568
+ // Sync block always shows the counts (zeroed on failure)
569
+ // and adds `failureReason` when the first sync blew up —
570
+ // downstream scripts can `.failureReason != null` to
571
+ // detect a recoverable partial init.
572
+ sync: syncFailedReason
573
+ ? { ...syncSummary, failureReason: syncFailedReason }
574
+ : syncSummary,
575
+ },
576
+ null,
577
+ 2,
578
+ ) + "\n",
579
+ );
580
+ return;
182
581
  }
183
582
 
184
- if (detectedKeys.includes("vscode")) {
185
- printBlank();
186
- console.log(" Note: VS Code will prompt for your access key on first use.");
187
- }
583
+ stdout.write("\n ✓ SkillRepo is ready.\n\n");
584
+ stdout.write(" Next steps:\n");
585
+ stdout.write(" skillrepo list — see what's in your library\n");
586
+ stdout.write(" • skillrepo search <query> — find skills\n");
587
+ stdout.write(" • skillrepo add @owner/name — add a skill\n\n");
588
+ }
188
589
 
189
- printBlank();
590
+ /**
591
+ * Parse init-specific flags. Uses resolveFlags for the common ones
592
+ * (--key/--url/--global/--ide/--json) and pulls out --yes/-y + --force.
593
+ */
594
+ function parseInitFlags(argv) {
595
+ // resolveFlags handles --key/--url/--global/--ide/--json and
596
+ // rejects unknown flags via its acceptPositional callback. We
597
+ // intercept --yes, --force, and --no-session-sync as "positional-
598
+ // shaped" flags via the callback so we don't need to pre-filter
599
+ // argv.
600
+ let yes = false;
601
+ let force = false;
602
+ let noSessionSync = false;
603
+
604
+ const flags = resolveFlags(argv, {
605
+ requireAuth: false, // init MAY prompt for a key, so don't hard-fail
606
+ // Don't let resolveFlags read from the global config file. init
607
+ // owns the credential lifecycle — it reads the config itself
608
+ // (via readConfig) so --force and the stale-key re-prompt path
609
+ // can decide whether to consume the cached credentials. If we
610
+ // let resolveFlags inject them silently, --force becomes a
611
+ // no-op: flags.apiKey would already be the cached key by the
612
+ // time init's --force branch runs.
613
+ skipConfig: true,
614
+ acceptPositional(arg) {
615
+ if (arg === "--yes" || arg === "-y") {
616
+ yes = true;
617
+ return 1;
618
+ }
619
+ if (arg === "--force") {
620
+ force = true;
621
+ return 1;
622
+ }
623
+ if (arg === "--no-session-sync") {
624
+ // #884: explicit opt-out for BOTH interactive and --yes
625
+ // modes. CI scripts that bootstrap a project without ever
626
+ // starting a Claude Code session pass this to skip the hook
627
+ // installation entirely.
628
+ noSessionSync = true;
629
+ return 1;
630
+ }
631
+ return false; // anything else is unknown
632
+ },
633
+ });
634
+
635
+ return { flags, yes, force, noSessionSync };
190
636
  }