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
|
@@ -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
|
+
}
|