magus-cli 7.13.0 → 7.14.1

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 (46) hide show
  1. package/README.md +7 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  4. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  5. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  6. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  7. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  8. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  9. package/src/__tests__/market-r2-routing.test.ts +127 -0
  10. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  11. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  12. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  13. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  14. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  15. package/src/__tests__/market-router.test.ts +4 -4
  16. package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
  17. package/src/cli/market-view.ts +59 -18
  18. package/src/cli/market.ts +128 -44
  19. package/src/cli/router.ts +42 -10
  20. package/src/data/marketplaces.ts +1 -0
  21. package/src/main.tsx +1 -1
  22. package/src/market-tui.tsx +1 -1
  23. package/src/services/claude-cli.ts +13 -0
  24. package/src/services/local-marketplace.ts +8 -0
  25. package/src/services/market-change.ts +6 -6
  26. package/src/services/market-local.ts +93 -0
  27. package/src/services/market-ops-plugin.ts +366 -47
  28. package/src/services/market-ops-publish.ts +363 -15
  29. package/src/services/market-ops-repo.ts +94 -13
  30. package/src/services/market-ops-skill.ts +93 -11
  31. package/src/services/market-ops.ts +72 -8
  32. package/src/services/market-publish.ts +10 -1
  33. package/src/services/market-types.ts +23 -0
  34. package/src/services/update-engine.ts +97 -1
  35. package/src/tui-runtime.tsx +2 -2
  36. package/src/types/index.ts +2 -0
  37. package/src/ui/adapters/marketAdapter.ts +123 -4
  38. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  40. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  41. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  42. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  43. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  44. package/src/ui/market/marketKeymap.ts +29 -2
  45. package/src/ui/market/useMarketState.ts +146 -5
  46. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,470 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, readFileSync, symlinkSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import {
5
+ MANIFEST,
6
+ MarketFixture,
7
+ type Repo,
8
+ SKILL,
9
+ write,
10
+ writeJson,
11
+ } from "./market-fixture.js";
12
+
13
+ let f: MarketFixture | undefined;
14
+ afterEach(() => {
15
+ f?.close();
16
+ f = undefined;
17
+ });
18
+
19
+ function localPlugin(
20
+ world: MarketFixture,
21
+ name = "local-kit",
22
+ version: string | undefined = "1.2.3",
23
+ ): string {
24
+ const folder = world.folder("project", "copy-me");
25
+ writeJson(join(folder, ".claude-plugin", "plugin.json"), {
26
+ name,
27
+ description: "Local tools",
28
+ ...(version === undefined ? {} : { version }),
29
+ });
30
+ write(join(folder, "README.md"), "Local tools documentation\n");
31
+ return folder;
32
+ }
33
+
34
+ function sourceSnapshot(
35
+ world: MarketFixture,
36
+ folder: string,
37
+ ): Record<string, string> {
38
+ const prefix = relative(world.root, folder);
39
+ return Object.fromEntries(
40
+ Object.entries(world.snapshot()).filter(
41
+ ([path]) => path === prefix || path.startsWith(`${prefix}/`),
42
+ ),
43
+ );
44
+ }
45
+
46
+ function oneCommit(world: MarketFixture, repo: Repo, old: string): void {
47
+ expect(
48
+ world.git(
49
+ world.root,
50
+ "--git-dir",
51
+ repo.bare,
52
+ "rev-list",
53
+ "--count",
54
+ `${old}..HEAD`,
55
+ ),
56
+ ).toBe("1");
57
+ }
58
+
59
+ test("R4.3 R4.5 TEST-38 plugin add copies the exact allowed set in one approved commit and keeps the entire local folder", () => {
60
+ f = new MarketFixture();
61
+ const repo = f.seed({
62
+ extras: { ".gitignore": "plugins/local-kit/ignored-there.txt\n" },
63
+ });
64
+ f.register(repo);
65
+ const folder = localPlugin(f);
66
+ writeJson(join(folder, ".claude-plugin", "metadata.json"), { public: true });
67
+ write(join(folder, "src", "main.ts"), "export const greeting = 'hello';\n");
68
+ write(join(folder, "skills", "solo", "SKILL.md"), SKILL);
69
+ write(join(folder, ".env"), "KEEP_SECRET=here\n");
70
+ write(join(folder, ".mcp.json"), '{"private":"kept locally"}\n');
71
+ write(join(folder, ".hidden", "notes.md"), "Private notes\n");
72
+ write(join(folder, "src", ".credentials"), "Private credentials\n");
73
+ write(join(folder, "node_modules", "package", "index.js"), "dependency\n");
74
+ write(
75
+ join(folder, "src", "node_modules", "nested", "index.js"),
76
+ "nested dependency\n",
77
+ );
78
+ write(join(folder, "ignored-here.txt"), "Ignored by source\n");
79
+ write(join(folder, "ignored-there.txt"), "Ignored by destination\n");
80
+ write(
81
+ join(f.project, ".gitignore"),
82
+ `${relative(f.project, folder)}/ignored-here.txt\n`,
83
+ );
84
+ const secret = join(f.root, "private-file");
85
+ write(secret, "Never follow this link\n");
86
+ symlinkSync(secret, join(folder, "linked-secret"));
87
+ const privateDirectory = join(f.root, "private-directory");
88
+ write(join(privateDirectory, "outside.txt"), "Outside plugin\n");
89
+ symlinkSync(privateDirectory, join(folder, "linked-directory"));
90
+ const before = f.snapshot();
91
+ const sourceBefore = sourceSnapshot(f, folder);
92
+ const old = f.head(repo);
93
+ const args = ["plugin", "add", relative(f.project, folder), "--to", "acme"];
94
+
95
+ const plan = f.market(args);
96
+
97
+ expect({ exit: plan.code, outcome: plan.result.outcome }).toEqual({
98
+ exit: 3,
99
+ outcome: "needs-consent",
100
+ });
101
+ expect(typeof plan.result.planId).toBe("string");
102
+ for (const [path, reason] of [
103
+ [".env", "dotfile"],
104
+ [".mcp.json", "dotfile"],
105
+ [".hidden/notes.md", "dotfile"],
106
+ ["src/.credentials", "dotfile"],
107
+ ["node_modules/package/index.js", "node_modules"],
108
+ ["src/node_modules/nested/index.js", "node_modules"],
109
+ ["ignored-here.txt", "ignored-here"],
110
+ ["ignored-there.txt", "ignored-by-marketplace"],
111
+ ["linked-secret", "symlink"],
112
+ ["linked-directory", "symlink"],
113
+ ]) {
114
+ expect(
115
+ plan.result.notPublished?.some(
116
+ (file) => file.path.endsWith(path!) && file.reason === reason,
117
+ ),
118
+ ).toBe(true);
119
+ }
120
+ expect(f.snapshot()).toEqual(before);
121
+ expect(f.head(repo)).toBe(old);
122
+
123
+ const result = f.market([...args, `--yes=${plan.result.planId}`]);
124
+
125
+ expect({ exit: result.code, outcome: result.result.outcome }).toEqual({
126
+ exit: 0,
127
+ outcome: "ok",
128
+ });
129
+ oneCommit(f, repo, old);
130
+ const expected = [
131
+ "plugins/local-kit/.claude-plugin/metadata.json",
132
+ "plugins/local-kit/.claude-plugin/plugin.json",
133
+ "plugins/local-kit/README.md",
134
+ "plugins/local-kit/skills/solo/SKILL.md",
135
+ "plugins/local-kit/src/main.ts",
136
+ ];
137
+ expect(
138
+ f
139
+ .remotePaths(repo)
140
+ .filter((path) => path.startsWith("plugins/local-kit/"))
141
+ .sort(),
142
+ ).toEqual(expected.sort());
143
+ expect(
144
+ f
145
+ .git(f.root, "--git-dir", repo.bare, "diff", "--name-only", old, "HEAD")
146
+ .split("\n")
147
+ .sort(),
148
+ ).toEqual([MANIFEST, ...expected].sort());
149
+ expect(f.remoteBytes(repo, "plugins/local-kit/skills/solo/SKILL.md")).toBe(
150
+ SKILL,
151
+ );
152
+ expect(f.remoteJson(repo, MANIFEST).plugins).toContainEqual({
153
+ name: "local-kit",
154
+ source: "./plugins/local-kit",
155
+ description: "Local tools",
156
+ version: "1.2.3",
157
+ });
158
+ expect(result.result.steps.map((step) => step.id)).toEqual([
159
+ "copy",
160
+ "validate",
161
+ "commit",
162
+ "deliver",
163
+ "refresh",
164
+ ]);
165
+ expect(f.version("local-kit@acme")).toBeNull();
166
+ expect(sourceSnapshot(f, folder)).toEqual(sourceBefore);
167
+ f.assertOwned();
168
+ }, 60_000);
169
+
170
+ test.each([
171
+ {
172
+ version: "2.3.4",
173
+ override: undefined,
174
+ expectedName: "local-kit",
175
+ expectedVersion: "2.3.4",
176
+ },
177
+ {
178
+ version: undefined,
179
+ override: undefined,
180
+ expectedName: "local-kit",
181
+ expectedVersion: "0.1.0",
182
+ },
183
+ {
184
+ version: "not-a-version",
185
+ override: undefined,
186
+ expectedName: "local-kit",
187
+ expectedVersion: "0.1.0",
188
+ },
189
+ {
190
+ version: "1.2.3-beta",
191
+ override: undefined,
192
+ expectedName: "local-kit",
193
+ expectedVersion: "0.1.0",
194
+ },
195
+ {
196
+ version: "2.3.4",
197
+ override: "renamed-kit",
198
+ expectedName: "renamed-kit",
199
+ expectedVersion: "2.3.4",
200
+ },
201
+ ])(
202
+ "R4.3 TEST-39 plugin add normalizes target name/version while keeping the original: %j",
203
+ ({ version, override, expectedName, expectedVersion }) => {
204
+ f = new MarketFixture();
205
+ const repo = f.seed();
206
+ f.register(repo);
207
+ const folder = localPlugin(f, "local-kit", version);
208
+ // The helper's default argument is bypassed for the no-version case.
209
+ if (version === undefined)
210
+ writeJson(join(folder, ".claude-plugin", "plugin.json"), {
211
+ name: "local-kit",
212
+ description: "Local tools",
213
+ });
214
+ const before = sourceSnapshot(f, folder);
215
+ const old = f.head(repo);
216
+
217
+ const result = f.market([
218
+ "plugin",
219
+ "add",
220
+ folder,
221
+ ...(override ? ["--name", override] : []),
222
+ "--yes",
223
+ ]);
224
+
225
+ expect(result.code).toBe(0);
226
+ oneCommit(f, repo, old);
227
+ const plugin = f.remoteJson(
228
+ repo,
229
+ `plugins/${expectedName}/.claude-plugin/plugin.json`,
230
+ );
231
+ expect(plugin.name).toBe(expectedName);
232
+ expect(plugin.version).toBe(expectedVersion);
233
+ expect(f.remoteJson(repo, MANIFEST).plugins).toContainEqual({
234
+ name: expectedName,
235
+ source: `./plugins/${expectedName}`,
236
+ description: "Local tools",
237
+ version: expectedVersion,
238
+ });
239
+ expect(sourceSnapshot(f, folder)).toEqual(before);
240
+ f.assertOwned();
241
+ },
242
+ 60_000,
243
+ );
244
+
245
+ test.each([false, true])(
246
+ "R4.3 TEST-40 a taken plugin name refuses the copy; override=%s",
247
+ (override) => {
248
+ f = new MarketFixture();
249
+ const repo = f.seed();
250
+ f.register(repo);
251
+ const folder = localPlugin(f, override ? "my-tools" : "review-kit");
252
+ const before = f.snapshot();
253
+ const old = f.head(repo);
254
+
255
+ const result = f.market([
256
+ "plugin",
257
+ "add",
258
+ folder,
259
+ ...(override ? ["--name", "review-kit"] : []),
260
+ "--yes",
261
+ ]);
262
+
263
+ expect({
264
+ exit: result.code,
265
+ outcome: result.result.outcome,
266
+ error: result.result.error?.code,
267
+ }).toEqual({
268
+ exit: 1,
269
+ outcome: "failed",
270
+ error: "name-taken",
271
+ });
272
+ expect(f.head(repo)).toBe(old);
273
+ expect(f.snapshot()).toEqual(before);
274
+ },
275
+ 30_000,
276
+ );
277
+
278
+ test.each([
279
+ { kind: "missing", code: 2, error: "not-found" },
280
+ { kind: "no-manifest", code: 2, error: "not-found" },
281
+ { kind: "file", code: 1, error: "other" },
282
+ { kind: "folder-link", code: 1, error: "other" },
283
+ { kind: "config-plugins", code: 1, error: "other" },
284
+ { kind: "manifest-link", code: 1, error: "other" },
285
+ { kind: "invalid-json", code: 1, error: "other" },
286
+ { kind: "invalid-name", code: 2, error: "invalid-name" },
287
+ ])(
288
+ "R4.3 TEST-41 plugin add refuses $kind source with the contracted exit",
289
+ ({ kind, code, error }) => {
290
+ f = new MarketFixture();
291
+ const repo = f.seed();
292
+ f.register(repo);
293
+ let folder = join(f.project, "source-plugin");
294
+ switch (kind) {
295
+ case "missing":
296
+ break;
297
+ case "no-manifest":
298
+ write(join(folder, "README.md"), "No plugin manifest\n");
299
+ break;
300
+ case "file":
301
+ write(folder, "Not a directory\n");
302
+ break;
303
+ case "folder-link": {
304
+ const actual = localPlugin(f);
305
+ symlinkSync(actual, folder);
306
+ break;
307
+ }
308
+ case "config-plugins":
309
+ folder = join(f.config, "plugins", "cache", "local", "local-kit");
310
+ writeJson(join(folder, ".claude-plugin", "plugin.json"), {
311
+ name: "local-kit",
312
+ version: "1.0.0",
313
+ });
314
+ break;
315
+ case "manifest-link": {
316
+ mkdirSync(join(folder, ".claude-plugin"), { recursive: true });
317
+ const actual = join(f.root, "external-plugin.json");
318
+ writeJson(actual, { name: "local-kit", version: "1.0.0" });
319
+ symlinkSync(actual, join(folder, ".claude-plugin", "plugin.json"));
320
+ break;
321
+ }
322
+ case "invalid-json":
323
+ write(join(folder, ".claude-plugin", "plugin.json"), "{invalid");
324
+ break;
325
+ case "invalid-name":
326
+ writeJson(join(folder, ".claude-plugin", "plugin.json"), {
327
+ name: "../escape",
328
+ version: "1.0.0",
329
+ });
330
+ break;
331
+ }
332
+ const before = f.snapshot();
333
+ const old = f.head(repo);
334
+
335
+ const result = f.market(["plugin", "add", folder, "--yes"]);
336
+
337
+ expect({
338
+ exit: result.code,
339
+ outcome: result.result.outcome,
340
+ error: result.result.error?.code,
341
+ }).toEqual({
342
+ exit: code,
343
+ outcome: "failed",
344
+ error,
345
+ });
346
+ expect(f.head(repo)).toBe(old);
347
+ expect(f.snapshot()).toEqual(before);
348
+ },
349
+ 30_000,
350
+ );
351
+
352
+ test.each([
353
+ { args: ["plugin", "add"] },
354
+ { args: ["plugin", "add", "one", "two"] },
355
+ ])(
356
+ "R4.3 TEST-42 plugin add rejects the wrong positional argument count: $args",
357
+ ({ args }) => {
358
+ f = new MarketFixture();
359
+ const before = f.snapshot();
360
+
361
+ const result = f.market(args);
362
+
363
+ expect(result.code).toBe(2);
364
+ expect(f.snapshot()).toEqual(before);
365
+ },
366
+ 30_000,
367
+ );
368
+
369
+ test("R4.3 TEST-42 --to is required with several marketplaces and changes only the chosen marketplace", () => {
370
+ f = new MarketFixture();
371
+ const a = f.seed();
372
+ const b = f.seed({ name: "beta", slug: "beta/market" });
373
+ f.register(a);
374
+ f.register(b);
375
+ const folder = localPlugin(f);
376
+ const before = f.snapshot();
377
+ const oldA = f.head(a);
378
+ const oldB = f.head(b);
379
+
380
+ const ambiguous = f.market(["plugin", "add", folder]);
381
+
382
+ expect(ambiguous.code).toBe(2);
383
+ expect(ambiguous.result.error?.code).toBe("ambiguous");
384
+ expect(f.snapshot()).toEqual(before);
385
+ const result = f.market(["plugin", "add", folder, "--to", "beta", "--yes"]);
386
+ expect(result.code).toBe(0);
387
+ expect(f.head(a)).toBe(oldA);
388
+ oneCommit(f, b, oldB);
389
+ expect(
390
+ f
391
+ .remoteJson(b, MANIFEST)
392
+ .plugins.some((plugin: { name: string }) => plugin.name === "local-kit"),
393
+ ).toBe(true);
394
+ f.assertOwned();
395
+ }, 60_000);
396
+
397
+ test.each(["skill", "plugin"] as const)(
398
+ "R4.4 TEST-43 adding a local %s with no company explains create/connect first and keeps it",
399
+ (kind) => {
400
+ f = new MarketFixture();
401
+ const folder =
402
+ kind === "plugin" ? localPlugin(f) : f.folder("project", "solo");
403
+ if (kind === "skill") write(join(folder, "SKILL.md"), SKILL);
404
+ const before = f.snapshot();
405
+ const args =
406
+ kind === "plugin"
407
+ ? ["plugin", "add", folder]
408
+ : ["skill", "add", "solo", "--plugin", "fresh-kit", "--create-plugin"];
409
+
410
+ const result = f.market([...args, "--yes"]);
411
+
412
+ expect(result.code).toBe(2);
413
+ expect(result.result.error?.code).toBe("not-found");
414
+ expect(JSON.stringify(result.result)).toMatch(/create|connect/i);
415
+ expect(JSON.stringify(result.result)).toMatch(/marketplace/i);
416
+ expect(f.snapshot()).toEqual(before);
417
+ },
418
+ 30_000,
419
+ );
420
+
421
+ test.each(["source", "name", "target"] as const)(
422
+ "R4.5 TEST-44 plugin add refuses consent after changing %s",
423
+ (changed) => {
424
+ f = new MarketFixture();
425
+ const repo = f.seed();
426
+ f.register(repo);
427
+ const beta =
428
+ changed === "target"
429
+ ? f.seed({ name: "beta", slug: "beta/market" })
430
+ : undefined;
431
+ if (beta) f.register(beta);
432
+ const folder = localPlugin(f);
433
+ const args = ["plugin", "add", folder, "--to", "acme"];
434
+ const before = f.snapshot();
435
+ const plan = f.market(args);
436
+ expect(plan.code).toBe(3);
437
+ expect(typeof plan.result.planId).toBe("string");
438
+ expect(f.snapshot()).toEqual(before);
439
+ if (changed === "source")
440
+ write(join(folder, "README.md"), "Changed after consent\n");
441
+ const sourceBefore = sourceSnapshot(f, folder);
442
+ const old = f.head(repo);
443
+ const oldBeta = beta ? f.head(beta) : undefined;
444
+ const changedArgs =
445
+ changed === "target"
446
+ ? ["plugin", "add", folder, "--to", "beta"]
447
+ : [...args, ...(changed === "name" ? ["--name", "different-kit"] : [])];
448
+
449
+ const result = f.market([...changedArgs, `--yes=${plan.result.planId}`]);
450
+
451
+ expect({
452
+ exit: result.code,
453
+ outcome: result.result.outcome,
454
+ error: result.result.error?.code,
455
+ }).toEqual({
456
+ exit: 3,
457
+ outcome: "needs-consent",
458
+ error: "plan-changed",
459
+ });
460
+ expect(result.result.planId).not.toBe(plan.result.planId);
461
+ expect(f.head(repo)).toBe(old);
462
+ if (beta) expect(f.head(beta)).toBe(oldBeta!);
463
+ expect(sourceSnapshot(f, folder)).toEqual(sourceBefore);
464
+ expect(
465
+ readFileSync(join(folder, ".claude-plugin", "plugin.json"), "utf8"),
466
+ ).toContain('"local-kit"');
467
+ f.assertOwned();
468
+ },
469
+ 60_000,
470
+ );