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.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
package/src/commands/init.mjs
CHANGED
|
@@ -1,190 +1,518 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skillrepo init` —
|
|
2
|
+
* `skillrepo init` (#673) — PR3b rewrite.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
127
|
+
const BLACK_HOLE_STREAM = {
|
|
128
|
+
write: () => true,
|
|
129
|
+
isTTY: false,
|
|
130
|
+
};
|
|
65
131
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
printWarning("No IDEs detected — defaulting to Claude Code + Cursor");
|
|
169
|
+
if (!serverUrl && !force && existingConfig) {
|
|
170
|
+
serverUrl = existingConfig.serverUrl;
|
|
83
171
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
202
|
+
p.blank();
|
|
112
203
|
|
|
113
|
-
// ── Step
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
341
|
+
vendors = detectedKeys;
|
|
133
342
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
392
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
393
|
+
global: flags.global,
|
|
394
|
+
io: syncIo,
|
|
157
395
|
});
|
|
158
396
|
} catch (err) {
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
}
|