skillrepo 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/get.mjs (PR2 of #646).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
import { runGet } from "../../commands/get.mjs";
|
|
12
|
+
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
13
|
+
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
14
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
|
+
|
|
17
|
+
let sandbox;
|
|
18
|
+
let server;
|
|
19
|
+
let serverUrl;
|
|
20
|
+
let originalCwd;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let stdout;
|
|
23
|
+
const VALID_KEY = "sk_live_test";
|
|
24
|
+
|
|
25
|
+
function makeSkill(owner, name) {
|
|
26
|
+
return {
|
|
27
|
+
owner,
|
|
28
|
+
name,
|
|
29
|
+
version: "1.0.0",
|
|
30
|
+
description: `${name} description`,
|
|
31
|
+
files: [
|
|
32
|
+
{
|
|
33
|
+
path: "SKILL.md",
|
|
34
|
+
content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
|
|
35
|
+
sha256: "x",
|
|
36
|
+
size: 50,
|
|
37
|
+
contentType: "text/markdown",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: "scripts/run.py",
|
|
41
|
+
content: "print('hi')\n",
|
|
42
|
+
sha256: "y",
|
|
43
|
+
size: 12,
|
|
44
|
+
contentType: "text/x-python",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function setup() {
|
|
52
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-get-"));
|
|
53
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
54
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
55
|
+
originalCwd = process.cwd();
|
|
56
|
+
originalHome = process.env.HOME;
|
|
57
|
+
process.chdir(join(sandbox, "project"));
|
|
58
|
+
process.env.HOME = join(sandbox, "home");
|
|
59
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
60
|
+
delete process.env.SKILLREPO_URL;
|
|
61
|
+
|
|
62
|
+
server = createMockServer({});
|
|
63
|
+
const port = await server.start();
|
|
64
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
65
|
+
|
|
66
|
+
stdout = createCaptureStream();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function teardown() {
|
|
70
|
+
if (server) await server.stop();
|
|
71
|
+
process.chdir(originalCwd);
|
|
72
|
+
process.env.HOME = originalHome;
|
|
73
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
74
|
+
server = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("runGet — happy path", () => {
|
|
78
|
+
beforeEach(setup);
|
|
79
|
+
afterEach(teardown);
|
|
80
|
+
|
|
81
|
+
it("fetches and writes a single skill", async () => {
|
|
82
|
+
const skill = makeSkill("alice", "pdf-helper");
|
|
83
|
+
server.setSkillResponse("alice", "pdf-helper", skill);
|
|
84
|
+
|
|
85
|
+
await runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
|
|
86
|
+
|
|
87
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
88
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
89
|
+
assert.ok(existsSync(join(dir, "scripts/run.py")));
|
|
90
|
+
assert.match(stdout.text(), /Fetched/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("accepts identifier without leading @", async () => {
|
|
94
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
95
|
+
await runGet(["--key", VALID_KEY, "--url", serverUrl, "alice/pdf-helper"], { stdout });
|
|
96
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "pdf-helper")));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("--global writes to home dir", async () => {
|
|
100
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
101
|
+
await runGet(["--key", VALID_KEY, "--url", serverUrl, "--global", "@alice/pdf-helper"], { stdout });
|
|
102
|
+
const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
|
|
103
|
+
assert.ok(existsSync(dir));
|
|
104
|
+
assert.ok(dir.startsWith(process.env.HOME));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("--json outputs structured result", async () => {
|
|
108
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
|
|
109
|
+
await runGet(["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/pdf-helper"], { stdout });
|
|
110
|
+
const json = JSON.parse(stdout.text());
|
|
111
|
+
assert.equal(json.action, "fetched");
|
|
112
|
+
assert.equal(json.owner, "alice");
|
|
113
|
+
assert.equal(json.name, "pdf-helper");
|
|
114
|
+
assert.equal(json.filesWritten, 2);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("runGet — error paths", () => {
|
|
119
|
+
beforeEach(setup);
|
|
120
|
+
afterEach(teardown);
|
|
121
|
+
|
|
122
|
+
it("rejects missing identifier", async () => {
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl]),
|
|
125
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /Missing/i.test(err.message),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects malformed identifier", async () => {
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "not-an-identifier"]),
|
|
132
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects extra positional after identifier", async () => {
|
|
137
|
+
await assert.rejects(
|
|
138
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@a/b", "@c/d"]),
|
|
139
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("404 on the skill returns a clean validation error (not a network error)", async () => {
|
|
144
|
+
// Server has no registered skill — getSkill returns null
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/missing"]),
|
|
147
|
+
(err) =>
|
|
148
|
+
err instanceof CliError &&
|
|
149
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
150
|
+
/not found/i.test(err.message),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects skill with mismatched owner/name from server", async () => {
|
|
155
|
+
// Defense in depth: server says it's @alice/pdf-helper but returns
|
|
156
|
+
// @bob/wrong. The command should refuse the response.
|
|
157
|
+
server.setSkillResponse("alice", "pdf-helper", makeSkill("bob", "wrong"));
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"]),
|
|
160
|
+
(err) =>
|
|
161
|
+
err instanceof CliError &&
|
|
162
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
163
|
+
/wrong skill/i.test(err.message),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects filesIncomplete skill", async () => {
|
|
168
|
+
const incomplete = makeSkill("alice", "incomplete");
|
|
169
|
+
incomplete.filesIncomplete = true;
|
|
170
|
+
server.setSkillResponse("alice", "incomplete", incomplete);
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
() => runGet(["--key", VALID_KEY, "--url", serverUrl, "@alice/incomplete"]),
|
|
173
|
+
(err) => err instanceof CliError && /incomplete/i.test(err.message),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/init.mjs (PR3b rewrite, #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
|
+
existsSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import { runInit } from "../../commands/init.mjs";
|
|
19
|
+
import { readConfig } from "../../lib/config.mjs";
|
|
20
|
+
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
21
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
22
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
23
|
+
|
|
24
|
+
let sandbox;
|
|
25
|
+
let server;
|
|
26
|
+
let serverUrl;
|
|
27
|
+
let originalCwd;
|
|
28
|
+
let originalHome;
|
|
29
|
+
let stdout;
|
|
30
|
+
let stderr;
|
|
31
|
+
const VALID_KEY = "sk_live_init_test";
|
|
32
|
+
|
|
33
|
+
async function setup() {
|
|
34
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-init-"));
|
|
35
|
+
// init defaults to detecting IDEs in cwd. Create a `.claude/`
|
|
36
|
+
// marker so detection finds claudeCode and the command doesn't
|
|
37
|
+
// refuse for "no IDEs detected".
|
|
38
|
+
mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
|
|
39
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
40
|
+
originalCwd = process.cwd();
|
|
41
|
+
originalHome = process.env.HOME;
|
|
42
|
+
process.chdir(join(sandbox, "project"));
|
|
43
|
+
process.env.HOME = join(sandbox, "home");
|
|
44
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
|
+
delete process.env.SKILLREPO_URL;
|
|
46
|
+
|
|
47
|
+
server = createMockServer({});
|
|
48
|
+
const port = await server.start();
|
|
49
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
50
|
+
|
|
51
|
+
stdout = createCaptureStream();
|
|
52
|
+
stderr = createCaptureStream();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function teardown() {
|
|
56
|
+
if (server) await server.stop();
|
|
57
|
+
process.chdir(originalCwd);
|
|
58
|
+
process.env.HOME = originalHome;
|
|
59
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
60
|
+
server = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Happy path ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("runInit — happy path", () => {
|
|
66
|
+
beforeEach(setup);
|
|
67
|
+
afterEach(teardown);
|
|
68
|
+
|
|
69
|
+
it("writes config + MCP + runs first sync with --yes", async () => {
|
|
70
|
+
await runInit(
|
|
71
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
72
|
+
{ stdout, stderr },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Config file persisted
|
|
76
|
+
const cfg = readConfig();
|
|
77
|
+
assert.ok(cfg, "config should be readable after init");
|
|
78
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
79
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
80
|
+
assert.equal(cfg.accountSlug, "mock");
|
|
81
|
+
|
|
82
|
+
// MCP config created in project
|
|
83
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
84
|
+
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
85
|
+
assert.ok(mcp.mcpServers?.skillrepo);
|
|
86
|
+
|
|
87
|
+
// .env.local written for agent env var consumers
|
|
88
|
+
assert.ok(existsSync(join(process.cwd(), ".env.local")));
|
|
89
|
+
const envContent = readFileSync(join(process.cwd(), ".env.local"), "utf-8");
|
|
90
|
+
assert.match(envContent, new RegExp(`SKILLREPO_ACCESS_KEY=${VALID_KEY}`));
|
|
91
|
+
|
|
92
|
+
// .gitignore has the three init-required entries.
|
|
93
|
+
// Round-3 architect + code-reviewer caught that this gitignore
|
|
94
|
+
// management was documented in the README but never actually
|
|
95
|
+
// implemented — this assertion locks the fix so a future
|
|
96
|
+
// regression that removes the mergeGitignore call from init
|
|
97
|
+
// fails loudly.
|
|
98
|
+
assert.ok(existsSync(join(process.cwd(), ".gitignore")));
|
|
99
|
+
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
100
|
+
assert.match(gi, /^\.env\.local$/m, ".env.local must be gitignored (contains access key)");
|
|
101
|
+
assert.match(gi, /^\.claude\/skills\/$/m, ".claude/skills/ must be gitignored");
|
|
102
|
+
assert.match(gi, /^\.claude\/settings\.local\.json$/m, ".claude/settings.local.json must be gitignored");
|
|
103
|
+
|
|
104
|
+
// Human summary
|
|
105
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("--json outputs structured summary", async () => {
|
|
109
|
+
await runInit(
|
|
110
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
111
|
+
{ stdout, stderr },
|
|
112
|
+
);
|
|
113
|
+
const json = JSON.parse(stdout.text());
|
|
114
|
+
assert.equal(json.action, "initialized");
|
|
115
|
+
assert.equal(json.account.slug, "mock");
|
|
116
|
+
assert.ok(Array.isArray(json.vendors));
|
|
117
|
+
assert.ok(Array.isArray(json.mcp.merged));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("respects --ide flag to override detection", async () => {
|
|
121
|
+
await runInit(
|
|
122
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
|
|
123
|
+
{ stdout, stderr },
|
|
124
|
+
);
|
|
125
|
+
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
126
|
+
assert.ok(mcp.mcpServers?.skillrepo);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detects multiple IDEs when both .claude/ and .cursor/ exist", async () => {
|
|
130
|
+
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
131
|
+
await runInit(
|
|
132
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
133
|
+
{ stdout, stderr },
|
|
134
|
+
);
|
|
135
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
136
|
+
assert.ok(existsSync(join(process.cwd(), ".cursor", "mcp.json")));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Credential resolution ─────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("runInit — credential resolution", () => {
|
|
143
|
+
beforeEach(setup);
|
|
144
|
+
afterEach(teardown);
|
|
145
|
+
|
|
146
|
+
it("reads existing config when no --key provided and server validates OK", async () => {
|
|
147
|
+
// Pre-seed the config file
|
|
148
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
149
|
+
writeFileSync(
|
|
150
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
apiKey: VALID_KEY,
|
|
154
|
+
serverUrl,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
// No --key passed; init should pick up the config and succeed
|
|
158
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
159
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reads SKILLREPO_ACCESS_KEY env var when no flag or config", async () => {
|
|
163
|
+
process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
|
|
164
|
+
process.env.SKILLREPO_URL = serverUrl;
|
|
165
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
166
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("refuses to run under --yes when no key is configured anywhere", async () => {
|
|
170
|
+
// No --key, no config, no env var, non-interactive → hard fail
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
() => runInit(["--yes"], { stdout, stderr }),
|
|
173
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Error paths ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("runInit — error paths", () => {
|
|
181
|
+
beforeEach(setup);
|
|
182
|
+
afterEach(teardown);
|
|
183
|
+
|
|
184
|
+
it("rejects invalid key format (not sk_live_ prefix)", async () => {
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
() => runInit(
|
|
187
|
+
["--key", "not_a_valid_key", "--url", serverUrl, "--yes"],
|
|
188
|
+
{ stdout, stderr },
|
|
189
|
+
),
|
|
190
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("401 from validate surfaces as authError", async () => {
|
|
195
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
() => runInit(
|
|
198
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
199
|
+
{ stdout, stderr },
|
|
200
|
+
),
|
|
201
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refuses with clear error when no IDE detected and no --ide flag", async () => {
|
|
206
|
+
// Remove the .claude marker that setup() created
|
|
207
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
() => runInit(
|
|
210
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
211
|
+
{ stdout, stderr },
|
|
212
|
+
),
|
|
213
|
+
(err) =>
|
|
214
|
+
err instanceof CliError &&
|
|
215
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
216
|
+
/No IDEs detected/.test(err.message),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("headless CI scenario: explicit --ide claude works in empty project", async () => {
|
|
221
|
+
// Remove the .claude marker — empty dir
|
|
222
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
223
|
+
// With --ide claude, init should proceed even in an empty dir
|
|
224
|
+
await runInit(
|
|
225
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
|
|
226
|
+
{ stdout, stderr },
|
|
227
|
+
);
|
|
228
|
+
// And write the MCP config
|
|
229
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── Idempotency ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
describe("runInit — idempotency", () => {
|
|
236
|
+
beforeEach(setup);
|
|
237
|
+
afterEach(teardown);
|
|
238
|
+
|
|
239
|
+
it("running init twice with valid existing config is a no-op refresh", async () => {
|
|
240
|
+
// First init
|
|
241
|
+
await runInit(
|
|
242
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
243
|
+
{ stdout, stderr },
|
|
244
|
+
);
|
|
245
|
+
const firstConfig = readConfig();
|
|
246
|
+
|
|
247
|
+
// Reset captures for the second run
|
|
248
|
+
stdout = createCaptureStream();
|
|
249
|
+
stderr = createCaptureStream();
|
|
250
|
+
|
|
251
|
+
// Second init WITHOUT --key — should pick up from config
|
|
252
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
253
|
+
const secondConfig = readConfig();
|
|
254
|
+
assert.equal(secondConfig.apiKey, firstConfig.apiKey);
|
|
255
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── --force flag (round-1 review fix) ──────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("runInit — --force flag", () => {
|
|
262
|
+
beforeEach(setup);
|
|
263
|
+
afterEach(teardown);
|
|
264
|
+
|
|
265
|
+
it("--force ignores existing config key (requires explicit new key)", async () => {
|
|
266
|
+
// Pre-seed a valid config
|
|
267
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
268
|
+
writeFileSync(
|
|
269
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
apiKey: VALID_KEY,
|
|
273
|
+
serverUrl,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// --force WITHOUT --key AND WITHOUT env var under --yes should
|
|
278
|
+
// hard-fail with EXIT_AUTH. This proves --force actually
|
|
279
|
+
// invalidates the cached credential rather than silently
|
|
280
|
+
// inheriting from the config. Before the round-1 fix, --force
|
|
281
|
+
// only cleared the key but still inherited serverUrl — this
|
|
282
|
+
// test also locks that the cached URL is ignored (the --url
|
|
283
|
+
// flag is required alongside --force for a full reset).
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() =>
|
|
286
|
+
runInit(["--force", "--yes"], { stdout, stderr }),
|
|
287
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("--force + --key + --url re-runs validation against new credentials", async () => {
|
|
292
|
+
// Pre-seed with one server URL
|
|
293
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
294
|
+
writeFileSync(
|
|
295
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
apiKey: "sk_live_old_key",
|
|
299
|
+
serverUrl: "https://old.example",
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// --force + explicit new credentials should succeed and OVERWRITE
|
|
304
|
+
// the config with the new ones (not merge).
|
|
305
|
+
await runInit(
|
|
306
|
+
["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
307
|
+
{ stdout, stderr },
|
|
308
|
+
);
|
|
309
|
+
const cfg = readConfig();
|
|
310
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
311
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("--force + SKILLREPO_ACCESS_KEY env var (no --key flag) uses the env var", async () => {
|
|
315
|
+
// Cross-review coverage gap: the README explicitly documents
|
|
316
|
+
// this scenario ("init --force with SKILLREPO_ACCESS_KEY set:
|
|
317
|
+
// uses the env var (step 2). --force only clears the cached
|
|
318
|
+
// credentials, not the runtime env."), but no existing test
|
|
319
|
+
// locked it. Reviewers (correctly) pointed out that missing
|
|
320
|
+
// coverage on this path would let a regression silently change
|
|
321
|
+
// the priority order between env vars and the cached config.
|
|
322
|
+
//
|
|
323
|
+
// Pre-seed a cached config with a DIFFERENT key than the env
|
|
324
|
+
// var so we can tell which one init actually used.
|
|
325
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
326
|
+
writeFileSync(
|
|
327
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
schemaVersion: 1,
|
|
330
|
+
apiKey: "sk_live_CONFIG_KEY",
|
|
331
|
+
serverUrl: "https://old.example",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
|
|
336
|
+
process.env.SKILLREPO_URL = serverUrl;
|
|
337
|
+
|
|
338
|
+
await runInit(["--force", "--yes"], { stdout, stderr });
|
|
339
|
+
|
|
340
|
+
// The config file should now contain the env var's key, NOT
|
|
341
|
+
// the seeded config key. --force cleared the cache; the env
|
|
342
|
+
// var won over the interactive-prompt fallback (which would
|
|
343
|
+
// also have fired since no --key was given).
|
|
344
|
+
const cfg = readConfig();
|
|
345
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
346
|
+
assert.notEqual(cfg.apiKey, "sk_live_CONFIG_KEY");
|
|
347
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ── Non-fatal sync failure (round-2 review fix) ──────────────────────
|
|
352
|
+
|
|
353
|
+
describe("runInit — non-fatal sync failure", () => {
|
|
354
|
+
beforeEach(setup);
|
|
355
|
+
afterEach(teardown);
|
|
356
|
+
|
|
357
|
+
it("sync failure during init does NOT abort the command", async () => {
|
|
358
|
+
// Round-2 review caught the prior behavior: sync failure warned
|
|
359
|
+
// "run skillrepo update later to retry" but then rethrew the
|
|
360
|
+
// error, so the init command exited non-zero — contradicting
|
|
361
|
+
// its own message. The fix: swallow the sync failure, print
|
|
362
|
+
// the warning, and exit 0 with a synthesized zero-delta summary.
|
|
363
|
+
// The user's config and MCP setup are already persisted; only
|
|
364
|
+
// the skill fetch failed, and `skillrepo update` is the
|
|
365
|
+
// documented recovery path.
|
|
366
|
+
//
|
|
367
|
+
// We simulate the failure by forcing the mock server to return
|
|
368
|
+
// 500 on the library sync endpoint AFTER validate succeeds.
|
|
369
|
+
// setForcedStatus fires once then clears, so the POST /validate
|
|
370
|
+
// in step 2 succeeds with a 200, and the subsequent GET /library
|
|
371
|
+
// hits normal routing — which we break by setting the status
|
|
372
|
+
// mid-run. The simplest approach: set the forced status to a
|
|
373
|
+
// non-200 BEFORE calling init and let it fire on the first
|
|
374
|
+
// non-auth request (the library GET comes after validate).
|
|
375
|
+
//
|
|
376
|
+
// But that would hit validate first. Instead we use a
|
|
377
|
+
// purpose-built "always 500 on /library" by overriding the
|
|
378
|
+
// library-sync response via the mock's setLibraryResponse slot.
|
|
379
|
+
server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
|
|
380
|
+
|
|
381
|
+
// Init should complete without throwing
|
|
382
|
+
await runInit(
|
|
383
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
384
|
+
{ stdout, stderr },
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Config IS persisted despite the sync failure
|
|
388
|
+
const cfg = readConfig();
|
|
389
|
+
assert.ok(cfg, "config must be written even when sync fails");
|
|
390
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
391
|
+
|
|
392
|
+
// MCP IS configured despite the sync failure
|
|
393
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
394
|
+
|
|
395
|
+
// Warning is surfaced
|
|
396
|
+
assert.match(stdout.text(), /first sync failed/i);
|
|
397
|
+
assert.match(stdout.text(), /skillrepo update/);
|
|
398
|
+
|
|
399
|
+
// Final "ready" line STILL prints — the init completed
|
|
400
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("--json sync failure includes failureReason in the JSON payload", async () => {
|
|
404
|
+
server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
|
|
405
|
+
await runInit(
|
|
406
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
407
|
+
{ stdout, stderr },
|
|
408
|
+
);
|
|
409
|
+
const json = JSON.parse(stdout.text());
|
|
410
|
+
assert.equal(json.action, "initialized");
|
|
411
|
+
// `failureReason` is the sentinel — downstream scripts detect a
|
|
412
|
+
// partial init by `sync.failureReason != null`. The synthesized
|
|
413
|
+
// SyncSummary no longer carries a `failed` field because that
|
|
414
|
+
// wasn't part of the documented SyncSummary typedef; the
|
|
415
|
+
// failure is signalled exclusively via `failureReason`.
|
|
416
|
+
assert.ok(json.sync.failureReason, "sync.failureReason should be present on failure");
|
|
417
|
+
assert.equal(json.sync.added, 0);
|
|
418
|
+
assert.equal(json.sync.updated, 0);
|
|
419
|
+
assert.equal(json.sync.removed, 0);
|
|
420
|
+
assert.equal(json.sync.notModified, false);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── Stale-key handling (round-1 review fix) ───────────────────────────
|
|
425
|
+
|
|
426
|
+
describe("runInit — stale-key handling", () => {
|
|
427
|
+
beforeEach(setup);
|
|
428
|
+
afterEach(teardown);
|
|
429
|
+
|
|
430
|
+
it("existing config + 401 from validate + --yes → hard failure (no re-prompt)", async () => {
|
|
431
|
+
// Pre-seed an existing config
|
|
432
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
433
|
+
writeFileSync(
|
|
434
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
apiKey: VALID_KEY,
|
|
438
|
+
serverUrl,
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
// Force the server to reject the stale key
|
|
442
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
443
|
+
|
|
444
|
+
// Under --yes (non-interactive), init MUST NOT fall back to
|
|
445
|
+
// promptSecret because there's no interactive stdin. It must
|
|
446
|
+
// surface the auth error directly. This test locks the guard
|
|
447
|
+
// in init.mjs's catch block: the re-prompt path is gated on
|
|
448
|
+
// `!yes` specifically so non-interactive callers (CI, scripts)
|
|
449
|
+
// fail loudly instead of hanging on stdin.
|
|
450
|
+
await assert.rejects(
|
|
451
|
+
() =>
|
|
452
|
+
runInit(
|
|
453
|
+
["--url", serverUrl, "--yes"],
|
|
454
|
+
{ stdout, stderr },
|
|
455
|
+
),
|
|
456
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("existing config + 401 + --force + --yes still hard-fails (force + yes both gate)", async () => {
|
|
461
|
+
// Pre-seed config
|
|
462
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
463
|
+
writeFileSync(
|
|
464
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
465
|
+
JSON.stringify({
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
apiKey: VALID_KEY,
|
|
468
|
+
serverUrl,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
472
|
+
|
|
473
|
+
// With --force AND --yes, the re-prompt path is gated twice:
|
|
474
|
+
// once by `!force` (the intent of --force is "use exactly what I
|
|
475
|
+
// passed, no fallbacks") and once by `!yes` (non-interactive).
|
|
476
|
+
// Either alone would block re-prompt; this test locks both.
|
|
477
|
+
await assert.rejects(
|
|
478
|
+
() =>
|
|
479
|
+
runInit(
|
|
480
|
+
["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
481
|
+
{ stdout, stderr },
|
|
482
|
+
),
|
|
483
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
});
|