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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/list.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 } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
import { runList } from "../../commands/list.mjs";
|
|
12
|
+
import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
13
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
|
+
|
|
16
|
+
let sandbox;
|
|
17
|
+
let server;
|
|
18
|
+
let serverUrl;
|
|
19
|
+
let originalCwd;
|
|
20
|
+
let originalHome;
|
|
21
|
+
let stdout;
|
|
22
|
+
const VALID_KEY = "sk_live_test";
|
|
23
|
+
|
|
24
|
+
function makeSkill(owner, name, version = "1.0.0", description = `${name} description`) {
|
|
25
|
+
return {
|
|
26
|
+
owner,
|
|
27
|
+
name,
|
|
28
|
+
version,
|
|
29
|
+
description,
|
|
30
|
+
files: [], // list ignores files
|
|
31
|
+
updatedAt: "2025-01-01T12:00:00Z",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function setup() {
|
|
36
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-list-"));
|
|
37
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
38
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
39
|
+
originalCwd = process.cwd();
|
|
40
|
+
originalHome = process.env.HOME;
|
|
41
|
+
process.chdir(join(sandbox, "project"));
|
|
42
|
+
process.env.HOME = join(sandbox, "home");
|
|
43
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
44
|
+
|
|
45
|
+
server = createMockServer({});
|
|
46
|
+
const port = await server.start();
|
|
47
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
48
|
+
|
|
49
|
+
stdout = createCaptureStream();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function teardown() {
|
|
53
|
+
if (server) await server.stop();
|
|
54
|
+
process.chdir(originalCwd);
|
|
55
|
+
process.env.HOME = originalHome;
|
|
56
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
57
|
+
server = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("runList — happy path", () => {
|
|
61
|
+
beforeEach(setup);
|
|
62
|
+
afterEach(teardown);
|
|
63
|
+
|
|
64
|
+
it("renders a table when the library has skills", async () => {
|
|
65
|
+
server.setLibraryResponse({
|
|
66
|
+
skills: [
|
|
67
|
+
makeSkill("alice", "pdf-helper"),
|
|
68
|
+
makeSkill("bob", "code-review"),
|
|
69
|
+
],
|
|
70
|
+
removals: [],
|
|
71
|
+
syncedAt: "x",
|
|
72
|
+
});
|
|
73
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
74
|
+
const out = stdout.text();
|
|
75
|
+
assert.match(out, /pdf-helper/);
|
|
76
|
+
assert.match(out, /code-review/);
|
|
77
|
+
assert.match(out, /2 skills in your library/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("sorts alphabetically by owner then name", async () => {
|
|
81
|
+
server.setLibraryResponse({
|
|
82
|
+
skills: [
|
|
83
|
+
makeSkill("zeta", "later"),
|
|
84
|
+
makeSkill("alice", "second"),
|
|
85
|
+
makeSkill("alice", "first"),
|
|
86
|
+
],
|
|
87
|
+
removals: [],
|
|
88
|
+
syncedAt: "x",
|
|
89
|
+
});
|
|
90
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
91
|
+
const out = stdout.text();
|
|
92
|
+
// alice/first before alice/second before zeta/later
|
|
93
|
+
const firstIdx = out.indexOf("first");
|
|
94
|
+
const secondIdx = out.indexOf("second");
|
|
95
|
+
const laterIdx = out.indexOf("later");
|
|
96
|
+
assert.ok(firstIdx >= 0 && secondIdx >= 0 && laterIdx >= 0);
|
|
97
|
+
assert.ok(firstIdx < secondIdx);
|
|
98
|
+
assert.ok(secondIdx < laterIdx);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("--json outputs structured array", async () => {
|
|
102
|
+
server.setLibraryResponse({
|
|
103
|
+
skills: [makeSkill("alice", "pdf-helper", "2.1.0")],
|
|
104
|
+
removals: [],
|
|
105
|
+
syncedAt: "x",
|
|
106
|
+
});
|
|
107
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
108
|
+
const json = JSON.parse(stdout.text());
|
|
109
|
+
assert.equal(json.length, 1);
|
|
110
|
+
assert.equal(json[0].owner, "alice");
|
|
111
|
+
assert.equal(json[0].name, "pdf-helper");
|
|
112
|
+
assert.equal(json[0].version, "2.1.0");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("--json output is sorted by owner+name", async () => {
|
|
116
|
+
server.setLibraryResponse({
|
|
117
|
+
skills: [
|
|
118
|
+
makeSkill("zeta", "x"),
|
|
119
|
+
makeSkill("alice", "z"),
|
|
120
|
+
makeSkill("alice", "a"),
|
|
121
|
+
],
|
|
122
|
+
removals: [],
|
|
123
|
+
syncedAt: "x",
|
|
124
|
+
});
|
|
125
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
126
|
+
const json = JSON.parse(stdout.text());
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
json.map((s) => `${s.owner}/${s.name}`),
|
|
129
|
+
["alice/a", "alice/z", "zeta/x"],
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders empty state with helpful hint", async () => {
|
|
134
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
135
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
136
|
+
const out = stdout.text();
|
|
137
|
+
assert.match(out, /library is empty/);
|
|
138
|
+
assert.match(out, /skillrepo add/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("--json on empty library outputs []", async () => {
|
|
142
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
143
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
144
|
+
const json = JSON.parse(stdout.text());
|
|
145
|
+
assert.deepEqual(json, []);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("truncates long descriptions in the table", async () => {
|
|
149
|
+
const longDesc = "a".repeat(200);
|
|
150
|
+
server.setLibraryResponse({
|
|
151
|
+
skills: [makeSkill("alice", "x", "1.0", longDesc)],
|
|
152
|
+
removals: [],
|
|
153
|
+
syncedAt: "x",
|
|
154
|
+
});
|
|
155
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
156
|
+
const out = stdout.text();
|
|
157
|
+
// Should contain the ellipsis marker
|
|
158
|
+
assert.match(out, /…/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("runList — error paths", () => {
|
|
163
|
+
beforeEach(setup);
|
|
164
|
+
afterEach(teardown);
|
|
165
|
+
|
|
166
|
+
it("throws authError when no credentials", async () => {
|
|
167
|
+
await assert.rejects(
|
|
168
|
+
() => runList([]),
|
|
169
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/remove.mjs (PR3a 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, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
import { runRemove } from "../../commands/remove.mjs";
|
|
12
|
+
import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
13
|
+
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } 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
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function setup() {
|
|
45
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-remove-"));
|
|
46
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
47
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
48
|
+
originalCwd = process.cwd();
|
|
49
|
+
originalHome = process.env.HOME;
|
|
50
|
+
process.chdir(join(sandbox, "project"));
|
|
51
|
+
process.env.HOME = join(sandbox, "home");
|
|
52
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
53
|
+
|
|
54
|
+
server = createMockServer({});
|
|
55
|
+
const port = await server.start();
|
|
56
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
57
|
+
|
|
58
|
+
stdout = createCaptureStream();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function teardown() {
|
|
62
|
+
if (server) await server.stop();
|
|
63
|
+
process.chdir(originalCwd);
|
|
64
|
+
process.env.HOME = originalHome;
|
|
65
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
|
+
server = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("runRemove — happy path", () => {
|
|
70
|
+
beforeEach(setup);
|
|
71
|
+
afterEach(teardown);
|
|
72
|
+
|
|
73
|
+
it("removes from library AND deletes local files when both exist", async () => {
|
|
74
|
+
// Pre-write a local skill
|
|
75
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
|
|
76
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
77
|
+
assert.ok(existsSync(dir));
|
|
78
|
+
|
|
79
|
+
// Default server response is 200 removed
|
|
80
|
+
await runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
|
|
81
|
+
|
|
82
|
+
// Local files gone
|
|
83
|
+
assert.ok(!existsSync(dir));
|
|
84
|
+
// Summary reports both actions
|
|
85
|
+
assert.match(stdout.text(), /Removed @alice\/pdf-helper from your library and deleted 1 local/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("library updated but no local files (summary reflects)", async () => {
|
|
89
|
+
// No pre-existing local skill, default 200 removed
|
|
90
|
+
await runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"], { stdout });
|
|
91
|
+
assert.match(stdout.text(), /no local files found/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("404 not-in-library + local files exist → orphan cleanup", async () => {
|
|
95
|
+
// Pre-write a local skill
|
|
96
|
+
writeSkillDir(makeSkill("alice", "orphan"), { vendors: ["claudeCode"] });
|
|
97
|
+
|
|
98
|
+
// Server says skill isn't in library
|
|
99
|
+
server.setRemoveResponse("alice", "orphan", {
|
|
100
|
+
status: 404,
|
|
101
|
+
body: { error: "Skill is not in your library", code: "not_in_library" },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/orphan"], { stdout });
|
|
105
|
+
|
|
106
|
+
const dir = resolvePlacementDir("claudeProject", "orphan");
|
|
107
|
+
assert.ok(!existsSync(dir));
|
|
108
|
+
// Round-1 review: message was "orphaned local" which implies
|
|
109
|
+
// CLI-managed state. Now the message is more honest about the
|
|
110
|
+
// fact that we don't know whether the user or a prior CLI run
|
|
111
|
+
// placed those files.
|
|
112
|
+
assert.match(stdout.text(), /wasn't in your library — deleted 1 local director/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("404 not-in-library + no local files → nothing to do", async () => {
|
|
116
|
+
server.setRemoveResponse("alice", "ghost", {
|
|
117
|
+
status: 404,
|
|
118
|
+
body: { code: "not_in_library" },
|
|
119
|
+
});
|
|
120
|
+
await runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/ghost"], { stdout });
|
|
121
|
+
assert.match(stdout.text(), /nothing to do/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("--json outputs removed status with accurate counts", async () => {
|
|
125
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
|
|
126
|
+
|
|
127
|
+
await runRemove(
|
|
128
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/pdf-helper"],
|
|
129
|
+
{ stdout },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const json = JSON.parse(stdout.text());
|
|
133
|
+
assert.equal(json.action, "removed");
|
|
134
|
+
assert.equal(json.libraryUpdated, true);
|
|
135
|
+
assert.equal(json.localDirsRemoved, 1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("--json outputs not-in-library status on 404", async () => {
|
|
139
|
+
server.setRemoveResponse("alice", "ghost", {
|
|
140
|
+
status: 404,
|
|
141
|
+
body: { code: "not_in_library" },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await runRemove(
|
|
145
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json", "@alice/ghost"],
|
|
146
|
+
{ stdout },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const json = JSON.parse(stdout.text());
|
|
150
|
+
assert.equal(json.action, "not-in-library");
|
|
151
|
+
assert.equal(json.libraryUpdated, false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("cleans .old/ orphans from prior crashes as a pre-flight step", async () => {
|
|
155
|
+
// Round-1 review: `remove` should call cleanupOrphans for
|
|
156
|
+
// consistency with get/add/update. Verify that a stale .old/
|
|
157
|
+
// from a crashed prior write is cleaned when running remove.
|
|
158
|
+
|
|
159
|
+
// Set up a live skill + a stale .old/ sibling
|
|
160
|
+
writeSkillDir(makeSkill("alice", "existing"), { vendors: ["claudeCode"] });
|
|
161
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
162
|
+
mkdirSync(join(root, "ghost.old"));
|
|
163
|
+
writeFileSync(join(root, "ghost.old", "stale.txt"), "leftover");
|
|
164
|
+
// Also need a live ghost sibling so the .old is considered cleanable
|
|
165
|
+
mkdirSync(join(root, "ghost"));
|
|
166
|
+
|
|
167
|
+
// Run remove on an unrelated skill
|
|
168
|
+
await runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/existing"], { stdout });
|
|
169
|
+
|
|
170
|
+
// The .old/ orphan should be gone (cleanupOrphans ran)
|
|
171
|
+
assert.ok(!existsSync(join(root, "ghost.old")), "stale .old should be cleaned");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("--global removes from home dir", async () => {
|
|
175
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { global: true });
|
|
176
|
+
const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
|
|
177
|
+
assert.ok(existsSync(dir));
|
|
178
|
+
|
|
179
|
+
await runRemove(
|
|
180
|
+
["--key", VALID_KEY, "--url", serverUrl, "--global", "@alice/pdf-helper"],
|
|
181
|
+
{ stdout },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
assert.ok(!existsSync(dir));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("runRemove — error paths", () => {
|
|
189
|
+
beforeEach(setup);
|
|
190
|
+
afterEach(teardown);
|
|
191
|
+
|
|
192
|
+
it("rejects missing identifier", async () => {
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() => runRemove(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
|
|
195
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects malformed identifier", async () => {
|
|
200
|
+
await assert.rejects(
|
|
201
|
+
() => runRemove(["--key", VALID_KEY, "--url", serverUrl, "not-an-identifier"], { stdout }),
|
|
202
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("rejects extra positional after identifier", async () => {
|
|
207
|
+
await assert.rejects(
|
|
208
|
+
() => runRemove(["--key", VALID_KEY, "--url", serverUrl, "@a/b", "@c/d"], { stdout }),
|
|
209
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("403 scope-required exits 4", async () => {
|
|
214
|
+
server.setRemoveResponse("alice", "scoped", {
|
|
215
|
+
status: 403,
|
|
216
|
+
body: { error: "Insufficient scope", code: "scope_required" },
|
|
217
|
+
});
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
() => runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/scoped"], { stdout }),
|
|
220
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("401 auth exits 2", async () => {
|
|
225
|
+
server.setRemoveResponse("alice", "auth-test", {
|
|
226
|
+
status: 401,
|
|
227
|
+
body: { error: "Invalid access key" },
|
|
228
|
+
});
|
|
229
|
+
await assert.rejects(
|
|
230
|
+
() => runRemove(["--key", VALID_KEY, "--url", serverUrl, "@alice/auth-test"], { stdout }),
|
|
231
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/search.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 } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
import { runSearch } from "../../commands/search.mjs";
|
|
12
|
+
import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
13
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
|
+
|
|
16
|
+
let sandbox;
|
|
17
|
+
let server;
|
|
18
|
+
let serverUrl;
|
|
19
|
+
let originalCwd;
|
|
20
|
+
let originalHome;
|
|
21
|
+
let stdout;
|
|
22
|
+
let stderr;
|
|
23
|
+
const VALID_KEY = "sk_live_test";
|
|
24
|
+
|
|
25
|
+
function makeResult(owner, name, installs = 100) {
|
|
26
|
+
return {
|
|
27
|
+
owner,
|
|
28
|
+
name,
|
|
29
|
+
description: `${name} description`,
|
|
30
|
+
version: "1.0.0",
|
|
31
|
+
license: "MIT",
|
|
32
|
+
compatibility: null,
|
|
33
|
+
installs,
|
|
34
|
+
avgRating: null,
|
|
35
|
+
safetyGrade: null,
|
|
36
|
+
publishedAt: "2025-01-01T00:00:00Z",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function setup() {
|
|
41
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-search-"));
|
|
42
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
43
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
44
|
+
originalCwd = process.cwd();
|
|
45
|
+
originalHome = process.env.HOME;
|
|
46
|
+
process.chdir(join(sandbox, "project"));
|
|
47
|
+
process.env.HOME = join(sandbox, "home");
|
|
48
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
49
|
+
|
|
50
|
+
server = createMockServer({});
|
|
51
|
+
const port = await server.start();
|
|
52
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
53
|
+
|
|
54
|
+
stdout = createCaptureStream();
|
|
55
|
+
stderr = createCaptureStream();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function teardown() {
|
|
59
|
+
if (server) await server.stop();
|
|
60
|
+
process.chdir(originalCwd);
|
|
61
|
+
process.env.HOME = originalHome;
|
|
62
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
63
|
+
server = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("runSearch — happy path", () => {
|
|
67
|
+
beforeEach(setup);
|
|
68
|
+
afterEach(teardown);
|
|
69
|
+
|
|
70
|
+
it("renders results as a table", async () => {
|
|
71
|
+
server.setSearchResponse({
|
|
72
|
+
skills: [makeResult("alice", "pdf-helper", 500), makeResult("bob", "code-review", 1200)],
|
|
73
|
+
pagination: { total: 2, limit: 20, offset: 0 },
|
|
74
|
+
});
|
|
75
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], { stdout });
|
|
76
|
+
const out = stdout.text();
|
|
77
|
+
assert.match(out, /pdf-helper/);
|
|
78
|
+
assert.match(out, /code-review/);
|
|
79
|
+
assert.match(out, /Results for "pdf"/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("formats install counts with k/M suffixes", async () => {
|
|
83
|
+
server.setSearchResponse({
|
|
84
|
+
skills: [makeResult("a", "x", 1500), makeResult("b", "y", 2_500_000)],
|
|
85
|
+
pagination: { total: 2, limit: 20, offset: 0 },
|
|
86
|
+
});
|
|
87
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
|
|
88
|
+
const out = stdout.text();
|
|
89
|
+
assert.match(out, /1\.5k/);
|
|
90
|
+
assert.match(out, /2\.5M/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("--json outputs structured result with semanticSupported flag", async () => {
|
|
94
|
+
server.setSearchResponse({
|
|
95
|
+
skills: [makeResult("alice", "pdf-helper")],
|
|
96
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
97
|
+
});
|
|
98
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--json", "pdf"], { stdout });
|
|
99
|
+
const json = JSON.parse(stdout.text());
|
|
100
|
+
assert.equal(json.query, "pdf");
|
|
101
|
+
assert.equal(json.semanticSupported, false);
|
|
102
|
+
assert.equal(json.semantic, false);
|
|
103
|
+
assert.equal(json.results.length, 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("--limit passes through to the API", async () => {
|
|
107
|
+
server.setSearchResponse({
|
|
108
|
+
skills: [makeResult("alice", "x")],
|
|
109
|
+
pagination: { total: 1, limit: 50, offset: 0 },
|
|
110
|
+
});
|
|
111
|
+
// Just verify no error; the http unit tests cover the URL param wire format
|
|
112
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "50", "test"], { stdout });
|
|
113
|
+
assert.match(stdout.text(), /Results for "test"/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("--semantic emits a warning to stderr but proceeds with keyword search", async () => {
|
|
117
|
+
server.setSearchResponse({
|
|
118
|
+
skills: [makeResult("alice", "x")],
|
|
119
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
120
|
+
});
|
|
121
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--semantic", "test"], { stdout, stderr });
|
|
122
|
+
assert.match(stderr.text(), /reserved for v1\.1/);
|
|
123
|
+
assert.match(stdout.text(), /Results for "test"/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("--semantic warning fires even when search returns no results", async () => {
|
|
127
|
+
// Regression for round-1 review finding #7/C4: previously the
|
|
128
|
+
// warning fired AFTER the empty-results early return, so users
|
|
129
|
+
// got zero feedback that --semantic was downgraded to keyword
|
|
130
|
+
// search when no results were found.
|
|
131
|
+
server.setSearchResponse({
|
|
132
|
+
skills: [],
|
|
133
|
+
pagination: { total: 0, limit: 20, offset: 0 },
|
|
134
|
+
});
|
|
135
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--semantic", "obscure"], { stdout, stderr });
|
|
136
|
+
assert.match(stderr.text(), /reserved for v1\.1/);
|
|
137
|
+
assert.match(stdout.text(), /No skills found/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("renders 'no results' when search returns empty", async () => {
|
|
141
|
+
server.setSearchResponse({
|
|
142
|
+
skills: [],
|
|
143
|
+
pagination: { total: 0, limit: 20, offset: 0 },
|
|
144
|
+
});
|
|
145
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "obscure"], { stdout });
|
|
146
|
+
assert.match(stdout.text(), /No skills found matching/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("shows pagination hint when total > shown", async () => {
|
|
150
|
+
server.setSearchResponse({
|
|
151
|
+
skills: [makeResult("a", "x")],
|
|
152
|
+
pagination: { total: 100, limit: 1, offset: 0 },
|
|
153
|
+
});
|
|
154
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "1", "test"], { stdout });
|
|
155
|
+
assert.match(stdout.text(), /Showing 1 of 100/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("runSearch — error paths", () => {
|
|
160
|
+
beforeEach(setup);
|
|
161
|
+
afterEach(teardown);
|
|
162
|
+
|
|
163
|
+
it("rejects missing query", async () => {
|
|
164
|
+
await assert.rejects(
|
|
165
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl]),
|
|
166
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /query is required/i.test(err.message),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects invalid --limit (non-integer)", async () => {
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "abc", "test"]),
|
|
173
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects --limit out of range (101)", async () => {
|
|
178
|
+
await assert.rejects(
|
|
179
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "101", "test"]),
|
|
180
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rejects --limit 0", async () => {
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "0", "test"]),
|
|
187
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("rejects extra positional after query", async () => {
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl, "first", "second"]),
|
|
194
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("throws authError when no credentials", async () => {
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
() => runSearch(["test"]),
|
|
201
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|