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.
- package/README.md +276 -145
- package/bin/skillrepo.mjs +224 -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 +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -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/fs-utils.mjs +83 -1
- 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/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- 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 +697 -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/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -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/artifact-registry.test.mjs +268 -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/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- 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,636 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skillrepo init` —
|
|
2
|
+
* `skillrepo init` (#673) — PR3b rewrite, v3.1.0 session-sync (#884).
|
|
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. 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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
129
|
+
const BLACK_HOLE_STREAM = {
|
|
130
|
+
write: () => true,
|
|
131
|
+
isTTY: false,
|
|
132
|
+
};
|
|
67
133
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
printWarning("No IDEs detected — defaulting to Claude Code + Cursor");
|
|
171
|
+
if (!serverUrl && !force && existingConfig) {
|
|
172
|
+
serverUrl = existingConfig.serverUrl;
|
|
83
173
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
204
|
+
p.blank();
|
|
112
205
|
|
|
113
|
-
// ── Step
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
343
|
+
vendors = detectedKeys;
|
|
133
344
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
489
|
+
vendors: effectiveVendors({ vendors, global: flags.global }),
|
|
490
|
+
global: flags.global,
|
|
491
|
+
io: syncIo,
|
|
157
492
|
});
|
|
158
493
|
} catch (err) {
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
}
|