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,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/cli-config.mjs (PR2 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* • resolveFlags — every flag, the priority order (CLI flag > config
|
|
6
|
+
* file > env var > default), error paths
|
|
7
|
+
* • effectiveVendors — defaults, --global override, explicit list
|
|
8
|
+
* • parseVendorList edge cases (alias, all, empty, unknown)
|
|
9
|
+
*
|
|
10
|
+
* The cli-config helper is shared by all four PR2 commands and PR3a's
|
|
11
|
+
* write commands, so coverage here is load-bearing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import { resolveFlags, effectiveVendors } from "../../lib/cli-config.mjs";
|
|
21
|
+
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
22
|
+
|
|
23
|
+
let sandbox;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let originalEnv;
|
|
26
|
+
|
|
27
|
+
function setupSandbox() {
|
|
28
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-config-"));
|
|
29
|
+
mkdirSync(join(sandbox, "home", ".claude", "skillrepo"), { recursive: true });
|
|
30
|
+
originalHome = process.env.HOME;
|
|
31
|
+
// Snapshot the env vars we mutate so tests don't bleed into each other
|
|
32
|
+
originalEnv = {
|
|
33
|
+
SKILLREPO_ACCESS_KEY: process.env.SKILLREPO_ACCESS_KEY,
|
|
34
|
+
SKILLREPO_URL: process.env.SKILLREPO_URL,
|
|
35
|
+
};
|
|
36
|
+
process.env.HOME = join(sandbox, "home");
|
|
37
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
38
|
+
delete process.env.SKILLREPO_URL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function teardownSandbox() {
|
|
42
|
+
process.env.HOME = originalHome;
|
|
43
|
+
if (originalEnv.SKILLREPO_ACCESS_KEY === undefined) {
|
|
44
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
|
+
} else {
|
|
46
|
+
process.env.SKILLREPO_ACCESS_KEY = originalEnv.SKILLREPO_ACCESS_KEY;
|
|
47
|
+
}
|
|
48
|
+
if (originalEnv.SKILLREPO_URL === undefined) {
|
|
49
|
+
delete process.env.SKILLREPO_URL;
|
|
50
|
+
} else {
|
|
51
|
+
process.env.SKILLREPO_URL = originalEnv.SKILLREPO_URL;
|
|
52
|
+
}
|
|
53
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeConfig(obj) {
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(sandbox, "home", ".claude", "skillrepo", "config.json"),
|
|
59
|
+
JSON.stringify(obj),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── resolveFlags — priority order ──────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("resolveFlags — credential priority", () => {
|
|
66
|
+
beforeEach(setupSandbox);
|
|
67
|
+
afterEach(teardownSandbox);
|
|
68
|
+
|
|
69
|
+
it("uses --key and --url when provided (highest priority)", () => {
|
|
70
|
+
writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
|
|
71
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
72
|
+
process.env.SKILLREPO_URL = "https://env.example";
|
|
73
|
+
|
|
74
|
+
const flags = resolveFlags(["--key", "flag_key", "--url", "https://flag.example"]);
|
|
75
|
+
assert.equal(flags.apiKey, "flag_key");
|
|
76
|
+
assert.equal(flags.serverUrl, "https://flag.example");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to config file when CLI flags absent", () => {
|
|
80
|
+
writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
|
|
81
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
82
|
+
process.env.SKILLREPO_URL = "https://env.example";
|
|
83
|
+
|
|
84
|
+
const flags = resolveFlags([]);
|
|
85
|
+
assert.equal(flags.apiKey, "config_key");
|
|
86
|
+
assert.equal(flags.serverUrl, "https://config.example");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to env vars when no config file", () => {
|
|
90
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
91
|
+
process.env.SKILLREPO_URL = "https://env.example";
|
|
92
|
+
|
|
93
|
+
const flags = resolveFlags([]);
|
|
94
|
+
assert.equal(flags.apiKey, "env_key");
|
|
95
|
+
assert.equal(flags.serverUrl, "https://env.example");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to DEFAULT_URL when nothing provides a URL", () => {
|
|
99
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
100
|
+
const flags = resolveFlags([]);
|
|
101
|
+
assert.equal(flags.serverUrl, "https://skillrepo.dev");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("throws authError when no key is configured anywhere", () => {
|
|
105
|
+
assert.throws(
|
|
106
|
+
() => resolveFlags([]),
|
|
107
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH && /No access key/.test(err.message),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("requireAuth: false skips the no-key error", () => {
|
|
112
|
+
const flags = resolveFlags([], { requireAuth: false });
|
|
113
|
+
assert.equal(flags.apiKey, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ignores corrupt config file gracefully", () => {
|
|
117
|
+
writeFileSync(
|
|
118
|
+
join(sandbox, "home", ".claude", "skillrepo", "config.json"),
|
|
119
|
+
"this is not json {{{",
|
|
120
|
+
);
|
|
121
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
122
|
+
const flags = resolveFlags([]);
|
|
123
|
+
assert.equal(flags.apiKey, "env_key");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("merges flag override with config-file fallback (mixed)", () => {
|
|
127
|
+
writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
|
|
128
|
+
// Override only the URL via flag; key still comes from config
|
|
129
|
+
const flags = resolveFlags(["--url", "https://flag.example"]);
|
|
130
|
+
assert.equal(flags.apiKey, "config_key");
|
|
131
|
+
assert.equal(flags.serverUrl, "https://flag.example");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── resolveFlags — skipConfig (round-1 review fix) ────────────────────
|
|
136
|
+
|
|
137
|
+
describe("resolveFlags — skipConfig", () => {
|
|
138
|
+
beforeEach(setupSandbox);
|
|
139
|
+
afterEach(teardownSandbox);
|
|
140
|
+
|
|
141
|
+
// This option was introduced in the round-1 PR3b fix because `init`
|
|
142
|
+
// needs to own its credential lifecycle end-to-end. Before this
|
|
143
|
+
// option, `resolveFlags` would silently inject the cached config key
|
|
144
|
+
// AND eagerly default `serverUrl` to the production URL — making
|
|
145
|
+
// init's `--force` and stale-key branches dead code. These tests
|
|
146
|
+
// lock the skipConfig contract: (1) config file is ignored,
|
|
147
|
+
// (2) production-URL default does NOT apply (caller must supply its
|
|
148
|
+
// own), (3) env vars still apply because they're explicit runtime
|
|
149
|
+
// state, not a cached credential.
|
|
150
|
+
|
|
151
|
+
it("skipConfig: true ignores the config file entirely", () => {
|
|
152
|
+
writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
|
|
153
|
+
const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
|
|
154
|
+
assert.equal(
|
|
155
|
+
flags.apiKey,
|
|
156
|
+
null,
|
|
157
|
+
"config-file key must NOT leak through when skipConfig is set",
|
|
158
|
+
);
|
|
159
|
+
assert.equal(
|
|
160
|
+
flags.serverUrl,
|
|
161
|
+
null,
|
|
162
|
+
"config-file URL must NOT leak through when skipConfig is set",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("skipConfig: true does NOT apply the https://skillrepo.dev default", () => {
|
|
167
|
+
// Without skipConfig, an empty argv would return the production
|
|
168
|
+
// URL. With skipConfig, serverUrl stays null so the caller can
|
|
169
|
+
// decide what to do. This is the specific bug that made init's
|
|
170
|
+
// `!serverUrl && existingConfig` branch dead code in round 0.
|
|
171
|
+
const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
|
|
172
|
+
assert.equal(flags.serverUrl, null);
|
|
173
|
+
assert.notEqual(flags.serverUrl, "https://skillrepo.dev");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("skipConfig: true still consumes SKILLREPO_URL / SKILLREPO_ACCESS_KEY env vars", () => {
|
|
177
|
+
// Env vars are explicit runtime state, not cached credentials —
|
|
178
|
+
// they should still resolve under skipConfig. This matches the
|
|
179
|
+
// init.mjs flow: `init` reads the env directly in its own
|
|
180
|
+
// credential collection step too, so the behavior is consistent
|
|
181
|
+
// whether init is called via `resolveFlags` or not.
|
|
182
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
183
|
+
process.env.SKILLREPO_URL = "https://env.example";
|
|
184
|
+
const flags = resolveFlags([], { requireAuth: false, skipConfig: true });
|
|
185
|
+
assert.equal(flags.apiKey, "env_key");
|
|
186
|
+
assert.equal(flags.serverUrl, "https://env.example");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("skipConfig: true still honors --key and --url flags (highest priority)", () => {
|
|
190
|
+
writeConfig({ apiKey: "config_key", serverUrl: "https://config.example" });
|
|
191
|
+
process.env.SKILLREPO_ACCESS_KEY = "env_key";
|
|
192
|
+
const flags = resolveFlags(
|
|
193
|
+
["--key", "flag_key", "--url", "https://flag.example"],
|
|
194
|
+
{ requireAuth: false, skipConfig: true },
|
|
195
|
+
);
|
|
196
|
+
// Flags still win — the option only suppresses the config-file
|
|
197
|
+
// fallback, it doesn't disable flag parsing.
|
|
198
|
+
assert.equal(flags.apiKey, "flag_key");
|
|
199
|
+
assert.equal(flags.serverUrl, "https://flag.example");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── resolveFlags — flag parsing ────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("resolveFlags — flag parsing", () => {
|
|
206
|
+
beforeEach(setupSandbox);
|
|
207
|
+
afterEach(teardownSandbox);
|
|
208
|
+
|
|
209
|
+
it("parses --global", () => {
|
|
210
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
211
|
+
const flags = resolveFlags(["--global"]);
|
|
212
|
+
assert.equal(flags.global, true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("parses --json", () => {
|
|
216
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
217
|
+
const flags = resolveFlags(["--json"]);
|
|
218
|
+
assert.equal(flags.json, true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("parses -k as alias for --key", () => {
|
|
222
|
+
const flags = resolveFlags(["-k", "k1"]);
|
|
223
|
+
assert.equal(flags.apiKey, "k1");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("parses -u as alias for --url", () => {
|
|
227
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
228
|
+
const flags = resolveFlags(["-u", "https://x"]);
|
|
229
|
+
assert.equal(flags.serverUrl, "https://x");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("parses --ide claudeCode", () => {
|
|
233
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
234
|
+
const flags = resolveFlags(["--ide", "claudeCode"]);
|
|
235
|
+
assert.deepEqual(flags.vendors, ["claudeCode"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("parses --ide alias 'claude' as claudeCode", () => {
|
|
239
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
240
|
+
const flags = resolveFlags(["--ide", "claude"]);
|
|
241
|
+
assert.deepEqual(flags.vendors, ["claudeCode"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("parses --ide multi-vendor", () => {
|
|
245
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
246
|
+
const flags = resolveFlags(["--ide", "claude,cursor,windsurf"]);
|
|
247
|
+
assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("parses --ide all as the full vendor list", () => {
|
|
251
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
252
|
+
const flags = resolveFlags(["--ide", "all"]);
|
|
253
|
+
assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("rejects --ide cursor,all (mixing all with explicit vendors is ambiguous)", () => {
|
|
257
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
258
|
+
assert.throws(
|
|
259
|
+
() => resolveFlags(["--ide", "cursor,all"]),
|
|
260
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /cannot mix/.test(err.message),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("rejects --ide all,cursor in the other order too", () => {
|
|
265
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
266
|
+
assert.throws(
|
|
267
|
+
() => resolveFlags(["--ide", "all,cursor"]),
|
|
268
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("accepts --ide all,all (degenerate case dedupes to the full set)", () => {
|
|
273
|
+
// The `all` token is hard-coded to mean the full vendor list. A
|
|
274
|
+
// user passing it twice is a degenerate input but unambiguous —
|
|
275
|
+
// both `all`s expand to the same set, so we accept it. This test
|
|
276
|
+
// locks the behavior so a future tightening of parseVendorList
|
|
277
|
+
// doesn't accidentally start rejecting it.
|
|
278
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
279
|
+
const flags = resolveFlags(["--ide", "all,all"]);
|
|
280
|
+
assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("rejects unknown --ide vendor", () => {
|
|
284
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
285
|
+
assert.throws(
|
|
286
|
+
() => resolveFlags(["--ide", "jetbrains"]),
|
|
287
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("rejects empty --ide list", () => {
|
|
292
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
293
|
+
assert.throws(
|
|
294
|
+
() => resolveFlags(["--ide", ","]),
|
|
295
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("rejects unknown flag without acceptPositional", () => {
|
|
300
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
301
|
+
assert.throws(
|
|
302
|
+
() => resolveFlags(["--bogus"]),
|
|
303
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── resolveFlags — positional handling ─────────────────────────────────
|
|
309
|
+
|
|
310
|
+
describe("resolveFlags — positional callback", () => {
|
|
311
|
+
beforeEach(setupSandbox);
|
|
312
|
+
afterEach(teardownSandbox);
|
|
313
|
+
|
|
314
|
+
it("invokes acceptPositional for non-flag args", () => {
|
|
315
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
316
|
+
const captured = [];
|
|
317
|
+
resolveFlags(["@alice/skill"], {
|
|
318
|
+
acceptPositional(arg) {
|
|
319
|
+
captured.push(arg);
|
|
320
|
+
return 1;
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
assert.deepEqual(captured, ["@alice/skill"]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("respects the consume count from acceptPositional", () => {
|
|
327
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
328
|
+
const captured = [];
|
|
329
|
+
resolveFlags(["--limit", "50", "@alice/skill"], {
|
|
330
|
+
acceptPositional(arg, i, all) {
|
|
331
|
+
if (arg === "--limit") {
|
|
332
|
+
captured.push(["limit", all[i + 1]]);
|
|
333
|
+
return 2;
|
|
334
|
+
}
|
|
335
|
+
captured.push(["query", arg]);
|
|
336
|
+
return 1;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
assert.deepEqual(captured, [["limit", "50"], ["query", "@alice/skill"]]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("treats positional as unknown when acceptPositional returns false", () => {
|
|
343
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
344
|
+
assert.throws(
|
|
345
|
+
() => resolveFlags(["random"], { acceptPositional: () => false }),
|
|
346
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("rejects positional when acceptPositional returns 0 (would loop forever)", () => {
|
|
351
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
352
|
+
assert.throws(
|
|
353
|
+
() => resolveFlags(["random"], { acceptPositional: () => 0 }),
|
|
354
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("rejects positional when acceptPositional returns a negative number", () => {
|
|
359
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
360
|
+
assert.throws(
|
|
361
|
+
() => resolveFlags(["random"], { acceptPositional: () => -1 }),
|
|
362
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("rejects positional when acceptPositional returns a non-integer", () => {
|
|
367
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
368
|
+
assert.throws(
|
|
369
|
+
() => resolveFlags(["random"], { acceptPositional: () => 1.5 }),
|
|
370
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("treats positional as unknown when no callback provided", () => {
|
|
375
|
+
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
376
|
+
assert.throws(
|
|
377
|
+
() => resolveFlags(["random"]),
|
|
378
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── effectiveVendors ───────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe("effectiveVendors", () => {
|
|
386
|
+
it("returns ['claudeCode'] by default", () => {
|
|
387
|
+
assert.deepEqual(effectiveVendors({ vendors: null, global: false }), ["claudeCode"]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns undefined for global mode", () => {
|
|
391
|
+
assert.equal(effectiveVendors({ vendors: null, global: true }), undefined);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("global overrides explicit vendors", () => {
|
|
395
|
+
assert.equal(
|
|
396
|
+
effectiveVendors({ vendors: ["cursor"], global: true }),
|
|
397
|
+
undefined,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("returns explicit vendors when set", () => {
|
|
402
|
+
assert.deepEqual(
|
|
403
|
+
effectiveVendors({ vendors: ["claudeCode", "cursor"], global: false }),
|
|
404
|
+
["claudeCode", "cursor"],
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/config.mjs (PR3b of #646).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
statSync,
|
|
15
|
+
chmodSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
readConfig,
|
|
22
|
+
writeConfig,
|
|
23
|
+
clearConfig,
|
|
24
|
+
CONFIG_SCHEMA_VERSION,
|
|
25
|
+
} from "../../lib/config.mjs";
|
|
26
|
+
import { globalConfigPath } from "../../lib/paths.mjs";
|
|
27
|
+
import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
|
|
28
|
+
|
|
29
|
+
let sandbox;
|
|
30
|
+
let originalHome;
|
|
31
|
+
|
|
32
|
+
function setupSandbox() {
|
|
33
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-config-mjs-"));
|
|
34
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
35
|
+
originalHome = process.env.HOME;
|
|
36
|
+
process.env.HOME = join(sandbox, "home");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function teardownSandbox() {
|
|
40
|
+
process.env.HOME = originalHome;
|
|
41
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── readConfig ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("readConfig", () => {
|
|
47
|
+
beforeEach(setupSandbox);
|
|
48
|
+
afterEach(teardownSandbox);
|
|
49
|
+
|
|
50
|
+
it("returns null when the file does not exist", () => {
|
|
51
|
+
assert.equal(readConfig(), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads a valid config file", () => {
|
|
55
|
+
const path = globalConfigPath();
|
|
56
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
57
|
+
writeFileSync(
|
|
58
|
+
path,
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
61
|
+
apiKey: "sk_live_abc",
|
|
62
|
+
serverUrl: "https://example.com",
|
|
63
|
+
accountSlug: "alice",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const result = readConfig();
|
|
67
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
68
|
+
assert.equal(result.serverUrl, "https://example.com");
|
|
69
|
+
assert.equal(result.accountSlug, "alice");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("accepts a legacy v2.0.0 config file with no schemaVersion", () => {
|
|
73
|
+
const path = globalConfigPath();
|
|
74
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
75
|
+
writeFileSync(
|
|
76
|
+
path,
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
apiKey: "sk_live_abc",
|
|
79
|
+
serverUrl: "https://example.com",
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const result = readConfig();
|
|
83
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
84
|
+
assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null on corrupt JSON", () => {
|
|
88
|
+
const path = globalConfigPath();
|
|
89
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
90
|
+
writeFileSync(path, "not json {{{");
|
|
91
|
+
assert.equal(readConfig(), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null on unknown future schema version", () => {
|
|
95
|
+
const path = globalConfigPath();
|
|
96
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
97
|
+
writeFileSync(
|
|
98
|
+
path,
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
schemaVersion: 999,
|
|
101
|
+
apiKey: "sk_live_abc",
|
|
102
|
+
serverUrl: "https://example.com",
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
assert.equal(readConfig(), null);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when apiKey is missing", () => {
|
|
109
|
+
const path = globalConfigPath();
|
|
110
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
111
|
+
writeFileSync(path, JSON.stringify({ serverUrl: "https://example.com" }));
|
|
112
|
+
assert.equal(readConfig(), null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns null when serverUrl is missing", () => {
|
|
116
|
+
const path = globalConfigPath();
|
|
117
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
118
|
+
writeFileSync(path, JSON.stringify({ apiKey: "sk_live_abc" }));
|
|
119
|
+
assert.equal(readConfig(), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns null when file is an empty object", () => {
|
|
123
|
+
const path = globalConfigPath();
|
|
124
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
125
|
+
writeFileSync(path, "{}");
|
|
126
|
+
assert.equal(readConfig(), null);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns null when file is a JSON array (not object)", () => {
|
|
130
|
+
const path = globalConfigPath();
|
|
131
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
132
|
+
writeFileSync(path, "[]");
|
|
133
|
+
assert.equal(readConfig(), null);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── writeConfig ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("writeConfig", () => {
|
|
140
|
+
beforeEach(setupSandbox);
|
|
141
|
+
afterEach(teardownSandbox);
|
|
142
|
+
|
|
143
|
+
it("creates a new config file", () => {
|
|
144
|
+
const action = writeConfig({
|
|
145
|
+
apiKey: "sk_live_abc",
|
|
146
|
+
serverUrl: "https://example.com",
|
|
147
|
+
accountSlug: "alice",
|
|
148
|
+
});
|
|
149
|
+
assert.equal(action, "created");
|
|
150
|
+
const result = readConfig();
|
|
151
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
152
|
+
assert.equal(result.accountSlug, "alice");
|
|
153
|
+
assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
|
|
154
|
+
assert.ok(result.writtenAt); // timestamp present
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("updates an existing config file", () => {
|
|
158
|
+
writeConfig({ apiKey: "sk_live_old", serverUrl: "https://old.com" });
|
|
159
|
+
const action = writeConfig({
|
|
160
|
+
apiKey: "sk_live_new",
|
|
161
|
+
serverUrl: "https://new.com",
|
|
162
|
+
});
|
|
163
|
+
assert.equal(action, "updated");
|
|
164
|
+
const result = readConfig();
|
|
165
|
+
assert.equal(result.apiKey, "sk_live_new");
|
|
166
|
+
assert.equal(result.serverUrl, "https://new.com");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("creates parent directory if missing", () => {
|
|
170
|
+
// sandbox HOME has no .claude/skillrepo/ yet
|
|
171
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
172
|
+
assert.ok(existsSync(globalConfigPath()));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves unknown fields from an existing config", () => {
|
|
176
|
+
const path = globalConfigPath();
|
|
177
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
178
|
+
writeFileSync(
|
|
179
|
+
path,
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
182
|
+
apiKey: "sk_live_old",
|
|
183
|
+
serverUrl: "https://old.com",
|
|
184
|
+
futureField: "preserve me",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
writeConfig({
|
|
188
|
+
apiKey: "sk_live_new",
|
|
189
|
+
serverUrl: "https://new.com",
|
|
190
|
+
});
|
|
191
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
192
|
+
assert.equal(raw.futureField, "preserve me");
|
|
193
|
+
assert.equal(raw.apiKey, "sk_live_new");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("sets 0600 permissions on POSIX", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
197
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
198
|
+
const path = globalConfigPath();
|
|
199
|
+
const mode = statSync(path).mode & 0o777;
|
|
200
|
+
assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("throws validationError on missing apiKey", () => {
|
|
204
|
+
assert.throws(
|
|
205
|
+
() => writeConfig({ serverUrl: "https://example.com" }),
|
|
206
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("throws validationError on missing serverUrl", () => {
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => writeConfig({ apiKey: "sk_live_abc" }),
|
|
213
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws validationError on null config", () => {
|
|
218
|
+
assert.throws(
|
|
219
|
+
() => writeConfig(null),
|
|
220
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("throws diskError when parent dir is read-only", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
225
|
+
// Create the grandparent dir and make the parent path non-writable
|
|
226
|
+
const grandParent = join(process.env.HOME, ".claude");
|
|
227
|
+
mkdirSync(grandParent, { recursive: true });
|
|
228
|
+
chmodSync(grandParent, 0o555);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
assert.throws(
|
|
232
|
+
() => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
|
|
233
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
234
|
+
);
|
|
235
|
+
} finally {
|
|
236
|
+
chmodSync(grandParent, 0o755);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── clearConfig ────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("clearConfig", () => {
|
|
244
|
+
beforeEach(setupSandbox);
|
|
245
|
+
afterEach(teardownSandbox);
|
|
246
|
+
|
|
247
|
+
it("returns false when no config exists", () => {
|
|
248
|
+
assert.equal(clearConfig(), false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("removes an existing config file", () => {
|
|
252
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
253
|
+
assert.ok(existsSync(globalConfigPath()));
|
|
254
|
+
assert.equal(clearConfig(), true);
|
|
255
|
+
assert.ok(!existsSync(globalConfigPath()));
|
|
256
|
+
});
|
|
257
|
+
});
|