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