skillrepo 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Single source of truth for every file and in-file entry `skillrepo init`
3
+ * writes to the user's filesystem (#885).
4
+ *
5
+ * The goal is drift prevention: any future command that writes a new
6
+ * artifact (a new MCP vendor, a new project-local config file, etc.)
7
+ * MUST add its descriptor to this registry in the same PR. The CI
8
+ * enforcement test at `src/test/lib/artifact-registry.test.mjs` walks
9
+ * the mergers/ and removers/ directories on the filesystem and fails
10
+ * loudly if a merger has no matching remover, or if the registry is
11
+ * missing a descriptor for an id that the test's expected-set covers.
12
+ *
13
+ * This file is DATA ONLY. No filesystem access, no dynamic behavior,
14
+ * no side effects — if you find yourself importing `fs` here you are
15
+ * writing in the wrong module. Actual removal logic lives in
16
+ * `src/lib/removers/*.mjs`; this module only catalogs WHAT gets
17
+ * removed, not HOW.
18
+ *
19
+ * Out of scope for v3.1.0 (see #885 section 3 Non-Goals):
20
+ * - v2.0.0 artifacts (.claude/rules/skillrepo-*.md,
21
+ * .claude/hooks/skillrepo-*). v3.0.0 is the minimum supported
22
+ * version; users of earlier versions clean up manually.
23
+ * - The <cwd>/skills/ fallback directory written for non-Claude-Code
24
+ * vendors. Tracked in #876 until those IDEs publish their own
25
+ * on-disk skill discovery conventions.
26
+ *
27
+ * The `settings-session-hook` descriptor is a forward-declaration: it
28
+ * names the SessionStart hook entry that #884 installs. The fingerprint
29
+ * string is exported below so #884's installer can import it and both
30
+ * modules stay in lockstep without a circular dependency.
31
+ */
32
+
33
+ import {
34
+ claudeMcpJson,
35
+ cursorMcpJson,
36
+ vscodeMcpJson,
37
+ windsurfMcpJson,
38
+ envLocal,
39
+ gitignorePath,
40
+ claudeSkillsProjectRoot,
41
+ claudeSkillsGlobalRoot,
42
+ } from "./paths.mjs";
43
+ import { join } from "node:path";
44
+ import { homedir } from "node:os";
45
+
46
+ // ── Fingerprint constants exported for cross-module consumption ───────
47
+
48
+ /**
49
+ * `.gitignore` section header. Lines directly under this header, up to
50
+ * the next blank line or the end of file, are considered SkillRepo-owned
51
+ * and are removed by the gitignore remover. User-authored lines outside
52
+ * the section are never touched — this is the attribution mechanism.
53
+ *
54
+ * MUST stay in lockstep with `src/lib/mergers/gitignore.mjs`
55
+ * SECTION_HEADER. The artifact-registry CI test asserts both modules
56
+ * resolve the same header text.
57
+ */
58
+ export const GITIGNORE_SECTION_HEADER =
59
+ "# SkillRepo CLI (added by `skillrepo init`)";
60
+
61
+ /**
62
+ * Entries written under the gitignore section by init. The remover
63
+ * does not match individual entries — it removes every line between the
64
+ * header and the next blank line — but the expected set is exported so
65
+ * tests can verify the two lists stay in sync.
66
+ */
67
+ export const GITIGNORE_REQUIRED_ENTRIES = Object.freeze([
68
+ ".env.local",
69
+ ".claude/skills/",
70
+ ".claude/settings.local.json",
71
+ ]);
72
+
73
+ /**
74
+ * Environment variable name owned by the CLI in `.env.local`. Any line
75
+ * starting with `${ENV_LOCAL_KEY_NAME}=` is considered SkillRepo-owned.
76
+ * Exact prefix match (not substring) so a user-authored
77
+ * `# SKILLREPO_ACCESS_KEY is set via ...` comment line is never
78
+ * stripped.
79
+ */
80
+ export const ENV_LOCAL_KEY_NAME = "SKILLREPO_ACCESS_KEY";
81
+
82
+ /**
83
+ * VS Code input `id` owned by the CLI. Lives in the `inputs` array of
84
+ * `.vscode/mcp.json` alongside the server entry.
85
+ */
86
+ export const VSCODE_INPUT_ID = "skillrepo-api-key";
87
+
88
+ /**
89
+ * Substring that identifies a SessionStart hook command entry as
90
+ * SkillRepo-owned. The #884 installer writes a hook whose `command`
91
+ * field contains `skillrepo update --session-hook`; any entry whose
92
+ * command contains this substring is removed by the uninstall path.
93
+ *
94
+ * Exported so #884's installer can import and use the same constant —
95
+ * this is the module boundary that makes #884 depend on #885 rather
96
+ * than the other way around. The architect design (issue #884 section
97
+ * 5.3) notes the bidirectional-fingerprint requirement; centralizing
98
+ * it here enforces it at the language level.
99
+ */
100
+ export const SESSION_HOOK_FINGERPRINT = "skillrepo update --session-hook";
101
+
102
+ // ── Artifact descriptors ────────────────────────────────────────────
103
+
104
+ /**
105
+ * @typedef {Object} ArtifactDescriptor
106
+ * @property {string} id - Stable identifier. Shows up in --json output.
107
+ * @property {"project" | "global"} scope - Which teardown pass owns this.
108
+ * @property {"json-key" | "json-input" | "line" | "section" | "directory"} kind
109
+ * How the remover deletes this artifact.
110
+ * @property {() => string} pathFn - Resolves the on-disk path (absolute).
111
+ * @property {string} displayPath - Human-readable path for output (e.g.
112
+ * `~/.codeium/windsurf/mcp_config.json`). Never an absolute
113
+ * path — those leak the user's home directory.
114
+ * @property {string[]} [jsonPath] - For json-key: property path to delete.
115
+ * @property {string} [inputId] - For json-input: the id field to match.
116
+ * @property {string} [linePrefix] - For line: lines starting with this
117
+ * string are owned and removed.
118
+ * @property {string} [sectionHeader] - For section: the header line;
119
+ * every line under it up to a blank-line sentinel is owned.
120
+ * @property {string} [commandFingerprint] - For the SessionStart hook
121
+ * entry: `command` field contains this substring.
122
+ */
123
+
124
+ /**
125
+ * The complete v3.1.0 artifact list. Frozen so callers can't mutate
126
+ * the catalog at runtime — if a future feature needs to conditionally
127
+ * include/exclude entries, do the filter at consumption time, not by
128
+ * editing the module.
129
+ *
130
+ * Order matters for display: the uninstall command's pre-removal
131
+ * summary renders in this order, so keep "scope grouped and cheapest
132
+ * operation first" within each scope for a predictable UX.
133
+ */
134
+ export const ARTIFACT_REGISTRY = Object.freeze([
135
+ // ── Project scope ────────────────────────────────────────────────
136
+ Object.freeze({
137
+ id: "claude-mcp-entry",
138
+ scope: "project",
139
+ kind: "json-key",
140
+ pathFn: claudeMcpJson,
141
+ displayPath: ".mcp.json",
142
+ jsonPath: ["mcpServers", "skillrepo"],
143
+ }),
144
+ Object.freeze({
145
+ id: "cursor-mcp-entry",
146
+ scope: "project",
147
+ kind: "json-key",
148
+ pathFn: cursorMcpJson,
149
+ displayPath: ".cursor/mcp.json",
150
+ jsonPath: ["mcpServers", "skillrepo"],
151
+ }),
152
+ Object.freeze({
153
+ id: "vscode-mcp-entry",
154
+ scope: "project",
155
+ kind: "json-key",
156
+ pathFn: vscodeMcpJson,
157
+ displayPath: ".vscode/mcp.json",
158
+ jsonPath: ["servers", "skillrepo"],
159
+ }),
160
+ Object.freeze({
161
+ id: "vscode-mcp-input",
162
+ scope: "project",
163
+ kind: "json-input",
164
+ pathFn: vscodeMcpJson,
165
+ displayPath: ".vscode/mcp.json",
166
+ inputId: VSCODE_INPUT_ID,
167
+ }),
168
+ Object.freeze({
169
+ id: "env-local-key",
170
+ scope: "project",
171
+ kind: "line",
172
+ pathFn: envLocal,
173
+ displayPath: ".env.local",
174
+ linePrefix: `${ENV_LOCAL_KEY_NAME}=`,
175
+ }),
176
+ Object.freeze({
177
+ id: "gitignore-entries",
178
+ scope: "project",
179
+ kind: "section",
180
+ pathFn: gitignorePath,
181
+ displayPath: ".gitignore",
182
+ sectionHeader: GITIGNORE_SECTION_HEADER,
183
+ }),
184
+ Object.freeze({
185
+ id: "settings-session-hook",
186
+ scope: "project",
187
+ kind: "json-key",
188
+ pathFn: () => join(process.cwd(), ".claude", "settings.local.json"),
189
+ displayPath: ".claude/settings.local.json",
190
+ // Hook entries live in `hooks.SessionStart` as an array of
191
+ // objects. The remover filters the array by a predicate on the
192
+ // `command` field rather than by index, because array order is
193
+ // not load-bearing and user-authored hooks may precede or follow
194
+ // SkillRepo's entry.
195
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
196
+ }),
197
+ Object.freeze({
198
+ id: "skills-dir-project",
199
+ scope: "project",
200
+ kind: "directory",
201
+ pathFn: claudeSkillsProjectRoot,
202
+ displayPath: ".claude/skills/",
203
+ }),
204
+
205
+ // ── Global scope ─────────────────────────────────────────────────
206
+ Object.freeze({
207
+ id: "windsurf-mcp-entry",
208
+ scope: "global",
209
+ kind: "json-key",
210
+ pathFn: windsurfMcpJson,
211
+ displayPath: "~/.codeium/windsurf/mcp_config.json",
212
+ jsonPath: ["mcpServers", "skillrepo"],
213
+ }),
214
+ Object.freeze({
215
+ id: "skills-dir-global",
216
+ scope: "global",
217
+ kind: "directory",
218
+ pathFn: claudeSkillsGlobalRoot,
219
+ displayPath: "~/.claude/skills/",
220
+ }),
221
+ Object.freeze({
222
+ id: "global-config-dir",
223
+ scope: "global",
224
+ kind: "directory",
225
+ // `~/.claude/skillrepo/` — the parent dir of both config.json and
226
+ // .last-sync. Whole-directory removal is correct because both
227
+ // children are CLI-owned and there's no room for user content.
228
+ pathFn: () => join(homedir(), ".claude", "skillrepo"),
229
+ displayPath: "~/.claude/skillrepo/",
230
+ }),
231
+ Object.freeze({
232
+ // Global-scope counterpart to `settings-session-hook` above. Added
233
+ // in #884 alongside the session-sync feature: `skillrepo init
234
+ // --global` and `skillrepo session-sync enable --global` write the
235
+ // hook to the user-wide settings file, not the project-local one.
236
+ // Without this descriptor, `skillrepo uninstall --global` would
237
+ // miss the global hook — a real gap that `session-sync disable
238
+ // --global` could clean up but the project-scope uninstall could
239
+ // not. Mirrors the skills-dir-project / skills-dir-global pair.
240
+ id: "settings-session-hook-global",
241
+ scope: "global",
242
+ kind: "json-key",
243
+ pathFn: () => join(homedir(), ".claude", "settings.local.json"),
244
+ displayPath: "~/.claude/settings.local.json",
245
+ commandFingerprint: SESSION_HOOK_FINGERPRINT,
246
+ }),
247
+ ]);
248
+
249
+ /**
250
+ * Convenience filter by scope. Used by the uninstall command to
251
+ * partition the registry for the "project only" vs "project + global"
252
+ * passes.
253
+ */
254
+ export function artifactsByScope(scope) {
255
+ return ARTIFACT_REGISTRY.filter((a) => a.scope === scope);
256
+ }
257
+
258
+ /**
259
+ * Lookup by id. Returns `undefined` for unknown ids — callers should
260
+ * treat that as a programming error (registry lookup for an id that
261
+ * was never declared), not a user-visible failure.
262
+ */
263
+ export function artifactById(id) {
264
+ return ARTIFACT_REGISTRY.find((a) => a.id === id);
265
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Shared credential + flag resolution for command modules.
3
+ *
4
+ * Every command needs to:
5
+ * 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
6
+ * 2. Fall back to ~/.claude/skillrepo/config.json
7
+ * 3. Fall back to SKILLREPO_ACCESS_KEY / SKILLREPO_URL env vars
8
+ * 4. Hard-error with an actionable hint pointing at `init` if no
9
+ * key is configured
10
+ *
11
+ * Centralizing this here keeps the four command modules thin and
12
+ * means a future change to credential resolution (e.g., adding a
13
+ * keychain backend) is a single edit instead of four.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+
18
+ import { globalConfigPath } from "./paths.mjs";
19
+ import { authError, validationError } from "./errors.mjs";
20
+
21
+ const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
22
+ const VENDOR_ALIASES = { claude: "claudeCode" };
23
+
24
+ /**
25
+ * @typedef {Object} ResolvedFlags
26
+ * @property {string} serverUrl
27
+ * @property {string} apiKey
28
+ * @property {boolean} global
29
+ * @property {string[]|null} vendors - null = use the default
30
+ * @property {boolean} json
31
+ */
32
+
33
+ /**
34
+ * Parse common flags from an argv slice. Returns a typed object with
35
+ * `serverUrl` and `apiKey` resolved per the priority order documented
36
+ * at the top of this file.
37
+ *
38
+ * Throws `validationError` for unknown flags (caller can intercept
39
+ * extra positional args before calling this).
40
+ *
41
+ * @param {string[]} argv - The argv slice the dispatcher passed to the command
42
+ * @param {object} [opts]
43
+ * @param {boolean} [opts.requireAuth=true] - If false, skip the no-key error
44
+ * (used by commands that may run without credentials, but PR2 has none)
45
+ * @param {boolean} [opts.skipConfig=false] - If true, DO NOT read from
46
+ * ~/.claude/skillrepo/config.json as a fallback. Only flag values
47
+ * and env vars are considered. Used by `init` so that its own
48
+ * --force / stale-key flow can decide whether to consume cached
49
+ * credentials — without this, resolveFlags would silently inject
50
+ * the cached key before init's decision logic runs, making
51
+ * --force a no-op.
52
+ * @param {(arg: string, i: number, argv: string[]) => boolean | number} [opts.acceptPositional]
53
+ * Optional callback to consume positional args. Return:
54
+ * - `false` (or any falsy non-zero) → arg rejected as unknown
55
+ * - a positive integer N → consume N args (the current arg
56
+ * plus the next N-1 if any)
57
+ * - 0 is INVALID and treated the same as `false` — a
58
+ * callback that "handles but consumes nothing" is a
59
+ * contract violation and would loop forever
60
+ * - any non-finite or negative number → invalid, treated as `false`
61
+ * @returns {ResolvedFlags}
62
+ */
63
+ export function resolveFlags(argv, opts = {}) {
64
+ const flagState = {
65
+ global: false,
66
+ vendors: null,
67
+ json: false,
68
+ key: null,
69
+ serverUrl: null,
70
+ };
71
+
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i];
74
+
75
+ if (arg === "--global") {
76
+ flagState.global = true;
77
+ } else if (arg === "--ide" && argv[i + 1] !== undefined) {
78
+ flagState.vendors = parseVendorList(argv[++i]);
79
+ } else if (arg === "--json") {
80
+ flagState.json = true;
81
+ } else if ((arg === "--key" || arg === "-k") && argv[i + 1] !== undefined) {
82
+ flagState.key = argv[++i];
83
+ } else if ((arg === "--url" || arg === "-u") && argv[i + 1] !== undefined) {
84
+ flagState.serverUrl = argv[++i];
85
+ } else if (arg === "--help" || arg === "-h") {
86
+ // Dispatcher should have intercepted this. Defensive no-op.
87
+ continue;
88
+ } else {
89
+ // Allow the caller to consume a positional arg before we treat
90
+ // it as unknown. This is how `get @owner/name` and
91
+ // `search <query>` slot in their main argument.
92
+ //
93
+ // Contract: callback returns a positive integer N (consume N
94
+ // args), `false`, or any falsy/zero/negative/non-integer value
95
+ // (reject the arg). Returning 0 is INVALID — it would mean
96
+ // "handled but consumed nothing", which would infinite-loop on
97
+ // the same arg. We reject zero defensively rather than trust
98
+ // every caller to read the JSDoc.
99
+ const consumed = opts.acceptPositional?.(arg, i, argv);
100
+ if (
101
+ typeof consumed === "number" &&
102
+ Number.isInteger(consumed) &&
103
+ consumed > 0
104
+ ) {
105
+ i += consumed - 1; // -1 because the for loop also increments
106
+ } else {
107
+ throw validationError(`Unknown argument: ${arg}`, {
108
+ hint: "Run the command with --help for valid options.",
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ // Resolve credentials in priority order
115
+ let serverUrl = flagState.serverUrl;
116
+ let apiKey = flagState.key;
117
+
118
+ // `skipConfig` suppresses the config-file fallback AND the eager
119
+ // default for serverUrl. `init` needs this because it owns the
120
+ // credential lifecycle: it reads the config file itself so
121
+ // `--force` and the stale-key re-prompt path can decide whether
122
+ // to use the cached credentials, and it needs to see `null`
123
+ // serverUrl (not the baked-in production URL) so its own
124
+ // "existingConfig.serverUrl → env → DEFAULT_URL" fallback chain
125
+ // takes effect. Without this, `init`'s `!serverUrl` branch was
126
+ // dead because resolveFlags would already have set it to
127
+ // https://skillrepo.dev.
128
+ if (opts.skipConfig) {
129
+ // Env var still applies — it's explicit config, not a cached
130
+ // credential. But the production URL default does NOT apply;
131
+ // the caller is expected to provide its own default.
132
+ if (!serverUrl) serverUrl = process.env.SKILLREPO_URL || null;
133
+ if (!apiKey) apiKey = process.env.SKILLREPO_ACCESS_KEY || null;
134
+ } else {
135
+ if (!serverUrl || !apiKey) {
136
+ const config = readGlobalConfig();
137
+ if (config) {
138
+ serverUrl = serverUrl || config.serverUrl;
139
+ apiKey = apiKey || config.apiKey;
140
+ }
141
+ }
142
+ if (!serverUrl) serverUrl = process.env.SKILLREPO_URL || "https://skillrepo.dev";
143
+ if (!apiKey) apiKey = process.env.SKILLREPO_ACCESS_KEY || null;
144
+ }
145
+
146
+ if (!apiKey && opts.requireAuth !== false) {
147
+ throw authError("No access key configured.", {
148
+ hint: "Run `skillrepo init` first, or pass --key sk_live_...",
149
+ });
150
+ }
151
+
152
+ return {
153
+ serverUrl,
154
+ apiKey,
155
+ global: flagState.global,
156
+ vendors: flagState.vendors,
157
+ json: flagState.json,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Compute the vendor list to pass to file-write / sync. Defaults to
163
+ * `["claudeCode"]` when neither `--ide` nor `--global` is provided —
164
+ * the v3.0.0 CLI deliberately does NOT silently fall back to
165
+ * `[claudeCode, cursor]` like v2.0.0 did. The user opts in.
166
+ */
167
+ export function effectiveVendors(flags) {
168
+ if (flags.global) return undefined; // global mode ignores vendors
169
+ if (flags.vendors) return flags.vendors;
170
+ return ["claudeCode"];
171
+ }
172
+
173
+ // ── Internals ──────────────────────────────────────────────────────────
174
+
175
+ function parseVendorList(raw) {
176
+ if (typeof raw !== "string") {
177
+ throw validationError(`--ide expects a comma-separated list, got: ${raw}`);
178
+ }
179
+
180
+ // First pass: collect tokens, normalize aliases, detect `all`.
181
+ const pieces = raw.split(",").map((p) => p.trim()).filter(Boolean);
182
+ const normalized = pieces.map((v) => VENDOR_ALIASES[v] ?? v);
183
+ const hasAll = normalized.includes("all");
184
+ const otherVendors = normalized.filter((v) => v !== "all");
185
+
186
+ // Defense against confusing input: `--ide cursor,all` is ambiguous
187
+ // — the user might think it means "cursor first, then everything"
188
+ // or "all minus duplicates". Both interpretations are wrong because
189
+ // `all` is the full set. Reject the combination so the user is
190
+ // forced to pick one.
191
+ if (hasAll && otherVendors.length > 0) {
192
+ throw validationError(
193
+ `--ide cannot mix "all" with other vendors: "${raw}"`,
194
+ { hint: "Use --ide all OR --ide claude,cursor,... — not both." },
195
+ );
196
+ }
197
+
198
+ if (hasAll) {
199
+ return ["claudeCode", "cursor", "windsurf", "vscode"];
200
+ }
201
+
202
+ if (normalized.length === 0) {
203
+ throw validationError("--ide list is empty.");
204
+ }
205
+
206
+ // Validate each vendor token
207
+ for (const resolved of normalized) {
208
+ if (!VALID_VENDORS.has(resolved)) {
209
+ // Find the original (un-aliased) form for the error message
210
+ const original = pieces[normalized.indexOf(resolved)];
211
+ throw validationError(`Unknown --ide vendor: "${original}"`, {
212
+ hint: "Use claudeCode (or 'claude'), cursor, windsurf, vscode, or 'all'.",
213
+ });
214
+ }
215
+ }
216
+
217
+ return normalized;
218
+ }
219
+
220
+ function readGlobalConfig() {
221
+ const path = globalConfigPath();
222
+ if (!existsSync(path)) return null;
223
+ try {
224
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
225
+ if (parsed && typeof parsed === "object") return parsed;
226
+ return null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }