vskill 0.2.7 → 0.2.9

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.
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
3
  // Mock node:fs
4
4
  // ---------------------------------------------------------------------------
5
5
  const mockMkdirSync = vi.fn();
6
+ const mockMkdtempSync = vi.fn().mockReturnValue("/tmp/vskill-marketplace-abc123");
6
7
  const mockWriteFileSync = vi.fn();
7
8
  const mockReadFileSync = vi.fn();
8
9
  const mockExistsSync = vi.fn();
@@ -14,6 +15,7 @@ const mockCopyFileSync = vi.fn();
14
15
  const mockRmSync = vi.fn();
15
16
  vi.mock("node:fs", () => ({
16
17
  mkdirSync: (...args) => mockMkdirSync(...args),
18
+ mkdtempSync: (...args) => mockMkdtempSync(...args),
17
19
  writeFileSync: (...args) => mockWriteFileSync(...args),
18
20
  readFileSync: (...args) => mockReadFileSync(...args),
19
21
  existsSync: (...args) => mockExistsSync(...args),
@@ -28,9 +30,11 @@ vi.mock("node:fs", () => ({
28
30
  // Mock node:os (control homedir for global lockfile tests)
29
31
  // ---------------------------------------------------------------------------
30
32
  const mockHomedir = vi.fn().mockReturnValue("/home/testuser");
33
+ const mockTmpdir = vi.fn().mockReturnValue("/tmp");
31
34
  vi.mock("node:os", () => ({
32
- default: { homedir: () => mockHomedir() },
35
+ default: { homedir: () => mockHomedir(), tmpdir: () => mockTmpdir() },
33
36
  homedir: () => mockHomedir(),
37
+ tmpdir: () => mockTmpdir(),
34
38
  }));
35
39
  // ---------------------------------------------------------------------------
36
40
  // Mock node:path (pass-through with join tracking)
@@ -43,6 +47,13 @@ vi.mock("node:path", async () => {
43
47
  };
44
48
  });
45
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
+ // ---------------------------------------------------------------------------
46
57
  // Mock node:crypto
47
58
  // ---------------------------------------------------------------------------
48
59
  const mockDigest = vi.fn().mockReturnValue("abcdef123456xxxx");
@@ -66,9 +77,11 @@ vi.mock("../agents/agents-registry.js", () => ({
66
77
  // ---------------------------------------------------------------------------
67
78
  const mockEnsureLockfile = vi.fn();
68
79
  const mockWriteLockfile = vi.fn();
80
+ const mockReadLockfile = vi.fn().mockReturnValue(null);
69
81
  vi.mock("../lockfile/index.js", () => ({
70
82
  ensureLockfile: (...args) => mockEnsureLockfile(...args),
71
83
  writeLockfile: (...args) => mockWriteLockfile(...args),
84
+ readLockfile: (...args) => mockReadLockfile(...args),
72
85
  }));
73
86
  // ---------------------------------------------------------------------------
74
87
  // Mock scanner
@@ -171,7 +184,7 @@ vi.mock("../marketplace/index.js", async () => {
171
184
  // ---------------------------------------------------------------------------
172
185
  // Import module under test AFTER mocks
173
186
  // ---------------------------------------------------------------------------
174
- const { addCommand } = await import("./add.js");
187
+ const { addCommand, detectMarketplaceRepo } = await import("./add.js");
175
188
  // ---------------------------------------------------------------------------
176
189
  // Helpers
177
190
  // ---------------------------------------------------------------------------
@@ -862,8 +875,8 @@ describe("addCommand multi-skill discovery (GitHub path)", () => {
862
875
  text: async () => "# Skill content",
863
876
  });
864
877
  await addCommand("owner/repo", {});
865
- // Should have fetched 3 SKILL.md files
866
- expect(globalThis.fetch).toHaveBeenCalledTimes(3);
878
+ // Marketplace detection now retries + raw fallback (3 attempts) + 3 SKILL.md files = 6
879
+ expect(globalThis.fetch).toHaveBeenCalledTimes(6);
867
880
  // Should have scanned 3 skills
868
881
  expect(mockRunTier1Scan).toHaveBeenCalledTimes(3);
869
882
  // Lockfile should have 3 entries
@@ -1883,8 +1896,8 @@ describe("addCommand flat name identifier guidance", () => {
1883
1896
  const logOutput = console.log.mock.calls
1884
1897
  .map((c) => String(c[0]))
1885
1898
  .join("\n");
1886
- // Should suggest the direct format including skill name
1887
- expect(logOutput).toContain("remotion-dev/skills/remotion-dev-skills-remotion");
1899
+ // Should suggest the owner/repo format (no longer 3-part)
1900
+ expect(logOutput).toContain("vskill install remotion-dev/skills");
1888
1901
  });
1889
1902
  });
1890
1903
  // ---------------------------------------------------------------------------
@@ -1995,7 +2008,7 @@ describe("addCommand registry fallback auto-selects matching skill", () => {
1995
2008
  expect(lockArg.skills).toHaveProperty("Code-Review");
1996
2009
  expect(lockArg.skills).not.toHaveProperty("unit-test");
1997
2010
  });
1998
- it("tip message suggests 3-part owner/repo/skill format", async () => {
2011
+ it("tip message suggests owner/repo format for direct install", async () => {
1999
2012
  mockGetSkill.mockResolvedValue({
2000
2013
  name: "excalidraw-diagram-generator",
2001
2014
  author: "github",
@@ -2015,7 +2028,7 @@ describe("addCommand registry fallback auto-selects matching skill", () => {
2015
2028
  const logOutput = console.log.mock.calls
2016
2029
  .map((c) => String(c[0]))
2017
2030
  .join("\n");
2018
- expect(logOutput).toContain("github/awesome-copilot/excalidraw-diagram-generator");
2031
+ expect(logOutput).toContain("vskill install github/awesome-copilot");
2019
2032
  });
2020
2033
  it("does not auto-filter when _targetSkill is not set (direct owner/repo)", async () => {
2021
2034
  mockDiscoverSkills.mockResolvedValue([
@@ -2034,4 +2047,236 @@ describe("addCommand registry fallback auto-selects matching skill", () => {
2034
2047
  expect(Object.keys(lockArg.skills)).toHaveLength(2);
2035
2048
  });
2036
2049
  });
2050
+ // ---------------------------------------------------------------------------
2051
+ // Marketplace Detection & Install Mode (increment 0382)
2052
+ // ---------------------------------------------------------------------------
2053
+ const SAMPLE_MARKETPLACE_JSON = JSON.stringify({
2054
+ name: "test-marketplace",
2055
+ owner: { name: "Test Author" },
2056
+ plugins: [
2057
+ { name: "plugin-a", source: "./plugins/plugin-a", version: "1.0.0", description: "First plugin" },
2058
+ { name: "plugin-b", source: "./plugins/plugin-b", version: "2.0.0", description: "Second plugin" },
2059
+ { name: "plugin-c", source: "./plugins/plugin-c", version: "1.5.0", description: "Third plugin" },
2060
+ ],
2061
+ });
2062
+ describe("detectMarketplaceRepo", () => {
2063
+ it("TC-001: detects repo with .claude-plugin/marketplace.json", async () => {
2064
+ globalThis.fetch = vi.fn()
2065
+ .mockResolvedValueOnce({
2066
+ ok: true,
2067
+ json: async () => ({ download_url: "https://raw.githubusercontent.com/owner/repo/main/.claude-plugin/marketplace.json" }),
2068
+ })
2069
+ .mockResolvedValueOnce({
2070
+ ok: true,
2071
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2072
+ });
2073
+ const result = await detectMarketplaceRepo("owner", "repo");
2074
+ expect(result.isMarketplace).toBe(true);
2075
+ expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
2076
+ });
2077
+ it("TC-002: returns false for repo without marketplace.json", async () => {
2078
+ globalThis.fetch = vi.fn().mockResolvedValue({
2079
+ ok: false,
2080
+ status: 404,
2081
+ });
2082
+ const result = await detectMarketplaceRepo("owner", "repo");
2083
+ expect(result.isMarketplace).toBe(false);
2084
+ expect(result.manifestContent).toBeUndefined();
2085
+ });
2086
+ it("TC-003: returns false on network error (graceful fallback)", async () => {
2087
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
2088
+ const result = await detectMarketplaceRepo("owner", "repo");
2089
+ expect(result.isMarketplace).toBe(false);
2090
+ });
2091
+ it("decodes base64 content when download_url is missing", async () => {
2092
+ const base64Content = Buffer.from(SAMPLE_MARKETPLACE_JSON).toString("base64");
2093
+ globalThis.fetch = vi.fn().mockResolvedValue({
2094
+ ok: true,
2095
+ json: async () => ({ content: base64Content, encoding: "base64" }),
2096
+ });
2097
+ const result = await detectMarketplaceRepo("owner", "repo");
2098
+ expect(result.isMarketplace).toBe(true);
2099
+ expect(result.manifestContent).toBe(SAMPLE_MARKETPLACE_JSON);
2100
+ });
2101
+ it("returns false when marketplace.json has no plugins", async () => {
2102
+ const emptyManifest = JSON.stringify({ name: "empty", owner: { name: "Test" }, plugins: [] });
2103
+ globalThis.fetch = vi.fn()
2104
+ .mockResolvedValueOnce({
2105
+ ok: true,
2106
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2107
+ })
2108
+ .mockResolvedValueOnce({
2109
+ ok: true,
2110
+ text: async () => emptyManifest,
2111
+ });
2112
+ const result = await detectMarketplaceRepo("owner", "repo");
2113
+ expect(result.isMarketplace).toBe(false);
2114
+ });
2115
+ });
2116
+ describe("addCommand marketplace integration", () => {
2117
+ beforeEach(() => {
2118
+ mockEnsureLockfile.mockReturnValue({
2119
+ version: 1,
2120
+ agents: [],
2121
+ skills: {},
2122
+ createdAt: new Date().toISOString(),
2123
+ updatedAt: new Date().toISOString(),
2124
+ });
2125
+ mockGetMarketplaceName.mockReturnValue("test-marketplace");
2126
+ // Ensure fs mocks have correct return values (clearAllMocks preserves them,
2127
+ // but restoreAllMocks from other describe blocks might have reset them)
2128
+ mockMkdtempSync.mockReturnValue("/tmp/vskill-marketplace-abc123");
2129
+ });
2130
+ it("TC-004: routes to marketplace flow when repo is a marketplace", async () => {
2131
+ // Marketplace detection succeeds
2132
+ globalThis.fetch = vi.fn()
2133
+ .mockResolvedValueOnce({
2134
+ ok: true,
2135
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2136
+ })
2137
+ .mockResolvedValueOnce({
2138
+ ok: true,
2139
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2140
+ });
2141
+ // Claude CLI available + native install succeeds
2142
+ mockIsClaudeCliAvailable.mockReturnValue(true);
2143
+ mockRegisterMarketplace.mockReturnValue(true);
2144
+ mockInstallNativePlugin.mockReturnValue(true);
2145
+ // --yes to auto-select all
2146
+ await addCommand("owner/repo", { yes: true });
2147
+ // Should NOT call discoverSkills
2148
+ expect(mockDiscoverSkills).not.toHaveBeenCalled();
2149
+ // Should call registerMarketplace and installNativePlugin
2150
+ expect(mockRegisterMarketplace).toHaveBeenCalled();
2151
+ expect(mockInstallNativePlugin).toHaveBeenCalledTimes(3);
2152
+ // Should write lockfile with marketplace source
2153
+ expect(mockWriteLockfile).toHaveBeenCalled();
2154
+ const lockArg = mockWriteLockfile.mock.calls[0][0];
2155
+ expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
2156
+ });
2157
+ it("TC-005: falls through to discovery when repo is NOT a marketplace", async () => {
2158
+ // Marketplace detection fails (404)
2159
+ globalThis.fetch = vi.fn().mockResolvedValue({
2160
+ ok: false,
2161
+ status: 404,
2162
+ });
2163
+ // Discovery returns skills
2164
+ mockDiscoverSkills.mockResolvedValue([
2165
+ { name: "my-skill", path: "SKILL.md", rawUrl: "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" },
2166
+ ]);
2167
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
2168
+ mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
2169
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
2170
+ // Re-mock fetch for skill content
2171
+ globalThis.fetch = vi.fn().mockResolvedValue({
2172
+ ok: true,
2173
+ text: async () => "# My Skill Content",
2174
+ });
2175
+ await addCommand("owner/repo", {});
2176
+ // Should call discoverSkills
2177
+ expect(mockDiscoverSkills).toHaveBeenCalledWith("owner", "repo");
2178
+ });
2179
+ it("TC-006: --plugin flag bypasses marketplace detection", async () => {
2180
+ mockCheckInstallSafety.mockResolvedValue({ blocked: false, rejected: false });
2181
+ mockDetectInstalledAgents.mockResolvedValue([makeAgent()]);
2182
+ mockRunTier1Scan.mockReturnValue(makeScanResult());
2183
+ // Mock fetch for marketplace.json lookup (via installRepoPlugin)
2184
+ globalThis.fetch = vi.fn().mockResolvedValue({
2185
+ ok: true,
2186
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2187
+ });
2188
+ mockReadFileSync.mockReturnValue(SAMPLE_MARKETPLACE_JSON);
2189
+ mockExistsSync.mockReturnValue(true);
2190
+ mockReaddirSync.mockReturnValue([]);
2191
+ mockStatSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
2192
+ // With --plugin flag, it goes to installRepoPlugin directly
2193
+ await addCommand("owner/repo", { plugin: "plugin-a" }).catch(() => { });
2194
+ // discoverSkills should NOT be called (plugin flag routes differently)
2195
+ // detectMarketplaceRepo is also not called (plugin flag checked first)
2196
+ });
2197
+ it("TC-013: summary shows correct counts with mixed success/failure", async () => {
2198
+ globalThis.fetch = vi.fn()
2199
+ .mockResolvedValueOnce({
2200
+ ok: true,
2201
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2202
+ })
2203
+ .mockResolvedValueOnce({
2204
+ ok: true,
2205
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2206
+ });
2207
+ mockIsClaudeCliAvailable.mockReturnValue(true);
2208
+ mockRegisterMarketplace.mockReturnValue(true);
2209
+ // plugin-a succeeds, plugin-b fails, plugin-c succeeds
2210
+ mockInstallNativePlugin
2211
+ .mockReturnValueOnce(true)
2212
+ .mockReturnValueOnce(false)
2213
+ .mockReturnValueOnce(true);
2214
+ await addCommand("owner/repo", { yes: true });
2215
+ // Should write lockfile with 2 installed plugins (not the failed one)
2216
+ expect(mockWriteLockfile).toHaveBeenCalled();
2217
+ const lockArg = mockWriteLockfile.mock.calls[0][0];
2218
+ expect(lockArg.skills["plugin-a"]).toBeDefined();
2219
+ expect(lockArg.skills["plugin-b"]).toBeUndefined();
2220
+ expect(lockArg.skills["plugin-c"]).toBeDefined();
2221
+ });
2222
+ it("TC-014: temp directory is cleaned up after install", async () => {
2223
+ globalThis.fetch = vi.fn()
2224
+ .mockResolvedValueOnce({
2225
+ ok: true,
2226
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2227
+ })
2228
+ .mockResolvedValueOnce({
2229
+ ok: true,
2230
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2231
+ });
2232
+ mockIsClaudeCliAvailable.mockReturnValue(true);
2233
+ mockRegisterMarketplace.mockReturnValue(true);
2234
+ mockInstallNativePlugin.mockReturnValue(true);
2235
+ await addCommand("owner/repo", { yes: true });
2236
+ // rmSync should have been called to clean up the temp dir
2237
+ expect(mockRmSync).toHaveBeenCalledWith("/tmp/vskill-marketplace-abc123", { recursive: true, force: true });
2238
+ });
2239
+ it("TC-015: lockfile records marketplace source format", async () => {
2240
+ globalThis.fetch = vi.fn()
2241
+ .mockResolvedValueOnce({
2242
+ ok: true,
2243
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2244
+ })
2245
+ .mockResolvedValueOnce({
2246
+ ok: true,
2247
+ text: async () => SAMPLE_MARKETPLACE_JSON,
2248
+ });
2249
+ mockIsClaudeCliAvailable.mockReturnValue(true);
2250
+ mockRegisterMarketplace.mockReturnValue(true);
2251
+ mockInstallNativePlugin.mockReturnValue(true);
2252
+ await addCommand("owner/repo", { yes: true });
2253
+ const lockArg = mockWriteLockfile.mock.calls[0][0];
2254
+ expect(lockArg.skills["plugin-a"].source).toBe("marketplace:owner/repo#plugin-a");
2255
+ expect(lockArg.skills["plugin-a"].marketplace).toBe("test-marketplace");
2256
+ expect(lockArg.skills["plugin-a"].pluginDir).toBe(true);
2257
+ expect(lockArg.skills["plugin-a"].version).toBe("1.0.0");
2258
+ expect(lockArg.skills["plugin-b"].version).toBe("2.0.0");
2259
+ });
2260
+ it("TC-012: falls back to extraction when claude CLI is unavailable", async () => {
2261
+ globalThis.fetch = vi.fn()
2262
+ .mockResolvedValueOnce({
2263
+ ok: true,
2264
+ json: async () => ({ download_url: "https://example.com/marketplace.json" }),
2265
+ })
2266
+ .mockResolvedValueOnce({
2267
+ ok: true,
2268
+ text: async () => JSON.stringify({
2269
+ name: "test-mp",
2270
+ owner: { name: "Test" },
2271
+ plugins: [{ name: "solo-plugin", source: "./plugins/solo", version: "1.0.0", description: "Only plugin" }],
2272
+ }),
2273
+ });
2274
+ mockIsClaudeCliAvailable.mockReturnValue(false);
2275
+ // installRepoPlugin will be called as fallback — it will fail but that's ok for this test
2276
+ await addCommand("owner/repo", { yes: true }).catch(() => { });
2277
+ // Should NOT call registerMarketplace or installNativePlugin
2278
+ expect(mockRegisterMarketplace).not.toHaveBeenCalled();
2279
+ expect(mockInstallNativePlugin).not.toHaveBeenCalled();
2280
+ });
2281
+ });
2037
2282
  //# sourceMappingURL=add.test.js.map