magus-cli 7.12.2 → 7.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,357 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import {
11
+ EDITED,
12
+ ID,
13
+ MarketFixture,
14
+ SKILL,
15
+ SKILL_REL,
16
+ expectPublished,
17
+ sourceModule,
18
+ write,
19
+ writeJson,
20
+ } from "./market-fixture.js";
21
+
22
+ let f: MarketFixture | undefined;
23
+ afterEach(() => {
24
+ f?.close();
25
+ f = undefined;
26
+ });
27
+
28
+ test("TEST-27 REQ-21 source inventory exclusions list every reason and never publish secret bytes", () => {
29
+ f = new MarketFixture();
30
+ const repo = f.seed({
31
+ extras: { ".gitignore": "plugins/review-kit/skills/review/dest.tmp\n" },
32
+ });
33
+ f.register(repo);
34
+ const folder = f.folder();
35
+ write(join(folder, "SKILL.md"), EDITED);
36
+ write(join(folder, ".env"), "PRIVATE_TOKEN=never-publish\n");
37
+ write(
38
+ join(folder, "node_modules", "pkg", "secret.txt"),
39
+ "never publish dependency\n",
40
+ );
41
+ write(join(folder, "ignored.txt"), "never publish ignored content\n");
42
+ write(join(folder, "dest.tmp"), "never publish destination ignored\n");
43
+ write(join(folder, "references", "guide.md"), "Publish this guide\n");
44
+ write(join(f.project, ".gitignore"), ".claude/skills/review/ignored.txt\n");
45
+ const outside = join(f.root, "outside.txt");
46
+ write(outside, "outside secret\n");
47
+ symlinkSync(outside, join(folder, "link.txt"));
48
+ const before = f.snapshot();
49
+
50
+ const sets = f.value<{
51
+ published: Array<{ path: string }>;
52
+ notPublished: Array<{ path: string; reason: string }>;
53
+ }>(
54
+ `const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.readSourceSets(${JSON.stringify(folder)},${JSON.stringify(f.clone(repo))},'plugins/review-kit/skills/review')));`,
55
+ );
56
+
57
+ expect(sets.published.map((file) => file.path).sort()).toEqual([
58
+ "SKILL.md",
59
+ "references/guide.md",
60
+ ]);
61
+ expect(
62
+ sets.notPublished.sort((a, b) => a.path.localeCompare(b.path)),
63
+ ).toEqual(
64
+ [
65
+ { path: ".env", reason: "dotfile" },
66
+ { path: "dest.tmp", reason: "ignored-by-marketplace" },
67
+ { path: "ignored.txt", reason: "ignored-here" },
68
+ { path: "link.txt", reason: "symlink" },
69
+ { path: "node_modules/pkg/secret.txt", reason: "node_modules" },
70
+ ].sort((a, b) => a.path.localeCompare(b.path)),
71
+ );
72
+ expect(f.snapshot()).toEqual(before);
73
+ expect(readFileSync(outside, "utf8")).toBe("outside secret\n");
74
+ }, 30_000);
75
+
76
+ test("TEST-27 REQ-21 an ignored skill root bypasses source ignore rules, not shape or destination exclusions", () => {
77
+ f = new MarketFixture();
78
+ const repo = f.seed({ extras: { ".gitignore": "*.tmp\n" } });
79
+ f.register(repo);
80
+ write(join(f.project, ".gitignore"), ".claude/skills/review/\n*.secret\n");
81
+ write(join(f.folder(), "SKILL.md"), EDITED);
82
+ write(
83
+ join(f.folder(), "notes.secret"),
84
+ "Included because the root is ignored\n",
85
+ );
86
+ write(join(f.folder(), ".env"), "Still excluded\n");
87
+ write(join(f.folder(), "dest.tmp"), "Still excluded by marketplace\n");
88
+
89
+ const sets = f.value<{
90
+ untrackedFolder: boolean;
91
+ published: Array<{ path: string }>;
92
+ notPublished: Array<{ path: string; reason: string }>;
93
+ }>(
94
+ `const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.readSourceSets(${JSON.stringify(f.folder())},${JSON.stringify(f.clone(repo))},'plugins/review-kit/skills/review')));`,
95
+ );
96
+
97
+ expect(sets.untrackedFolder).toBe(true);
98
+ expect(sets.published.map((file) => file.path).sort()).toEqual([
99
+ "SKILL.md",
100
+ "notes.secret",
101
+ ]);
102
+ expect(sets.notPublished).toContainEqual({ path: ".env", reason: "dotfile" });
103
+ expect(sets.notPublished).toContainEqual({
104
+ path: "dest.tmp",
105
+ reason: "ignored-by-marketplace",
106
+ });
107
+ }, 30_000);
108
+
109
+ test("TEST-27 REQ-30 excluded plugin files compare same, survive publish, and an exact full copy is removable", () => {
110
+ f = new MarketFixture();
111
+ const repo = f.seed({
112
+ extras: {
113
+ "plugins/review-kit/skills/review/.gitkeep": "",
114
+ "plugins/review-kit/skills/review/ignored.txt":
115
+ "Plugin content ignored by the source project\n",
116
+ },
117
+ });
118
+ f.register(repo);
119
+ f.install();
120
+ write(join(f.project, ".gitignore"), ".claude/skills/review/ignored.txt\n");
121
+ f.edit();
122
+
123
+ const status = f.market(["status", "review"]);
124
+
125
+ expect(
126
+ status.result.skills?.find((row) => row.skill === "review")?.state,
127
+ ).toBe("same");
128
+ write(join(f.folder(), "SKILL.md"), EDITED);
129
+ const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
130
+ expect(r.code).toBe(0);
131
+ expect(
132
+ r.result.files?.map(({ path, change }) => ({
133
+ path: path === SKILL_REL ? "SKILL.md" : path,
134
+ change,
135
+ })),
136
+ ).toEqual([{ path: "SKILL.md", change: "modified" }]);
137
+ expect(f.remotePaths(repo)).toContain(
138
+ "plugins/review-kit/skills/review/.gitkeep",
139
+ );
140
+ expect(
141
+ f.remoteFile(repo, "plugins/review-kit/skills/review/ignored.txt"),
142
+ ).toContain("Plugin content");
143
+ expect(r.result.localCopy?.action).toBe("removed");
144
+ expect(existsSync(f.folder())).toBe(false);
145
+ f.assertOwned();
146
+ }, 60_000);
147
+
148
+ test.each([
149
+ { file: ".env", reason: "dotfile" },
150
+ { file: "dest.tmp", reason: "ignored-by-marketplace" },
151
+ { file: "ignored.txt", reason: "ignored-here" },
152
+ ])(
153
+ "TEST-27 REQ-15 unpublished $file is kept after a successful push",
154
+ async ({ file, reason }) => {
155
+ f = new MarketFixture();
156
+ const repo = f.seed({ extras: { ".gitignore": "*.tmp\n" } });
157
+ f.register(repo);
158
+ f.install();
159
+ f.edit();
160
+ write(join(f.folder(), "SKILL.md"), EDITED);
161
+ write(join(f.folder(), file), "Not published; must survive\n");
162
+ if (reason === "ignored-here")
163
+ write(
164
+ join(f.project, ".gitignore"),
165
+ ".claude/skills/review/ignored.txt\n",
166
+ );
167
+
168
+ const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
169
+
170
+ expect(r.code).toBe(0);
171
+ expectPublished(f, repo);
172
+ expect(r.result.notPublished).toContainEqual({ path: file, reason });
173
+ expect(r.result.localCopy?.action).toBe("kept");
174
+ expect(readFileSync(join(f.folder(), file), "utf8")).toBe(
175
+ "Not published; must survive\n",
176
+ );
177
+ expect(f.remotePaths(repo)).not.toContain(
178
+ `plugins/review-kit/skills/review/${file}`,
179
+ );
180
+ f.assertOwned();
181
+ },
182
+ 60_000,
183
+ );
184
+
185
+ test.each([
186
+ "not-installed",
187
+ "keep-local",
188
+ "profile-managed",
189
+ "custom-profile-folder",
190
+ "local-off",
191
+ "global-project-only",
192
+ ] as const)(
193
+ "TEST-30 REQ-15 removes only a reachable exact active copy: %s",
194
+ (kind) => {
195
+ f = new MarketFixture();
196
+ const repo = f.seed();
197
+ f.register(repo);
198
+ if (kind !== "not-installed")
199
+ f.install(ID, kind === "global-project-only" ? "project" : "user");
200
+ const global = kind === "global-project-only";
201
+ f.edit(global ? ["--to", "global"] : []);
202
+ const folder = f.folder(global ? "global" : "project");
203
+ write(join(folder, "SKILL.md"), EDITED);
204
+ if (kind === "profile-managed" || kind === "custom-profile-folder") {
205
+ f.profile();
206
+ f.active(kind === "profile-managed" ? { review: "a".repeat(64) } : {});
207
+ }
208
+ if (kind === "local-off")
209
+ writeJson(join(f.project, ".claude", "settings.local.json"), {
210
+ enabledPlugins: { [ID]: false },
211
+ });
212
+ f.rememberOwned();
213
+
214
+ const r = f.market([
215
+ "publish",
216
+ "review",
217
+ "--plugin",
218
+ ID,
219
+ "--yes",
220
+ ...(global ? ["--from", "global"] : []),
221
+ ...(kind === "keep-local" ? ["--keep-local"] : []),
222
+ ]);
223
+
224
+ expect(r.code).toBe(0);
225
+ const removed = kind === "custom-profile-folder";
226
+ expect(r.result.localCopy?.action).toBe(removed ? "removed" : "kept");
227
+ expect(existsSync(folder)).toBe(!removed);
228
+ expectPublished(f, repo);
229
+ if (!removed) expect(typeof r.result.localCopy?.reason).toBe("string");
230
+ f.assertOwned();
231
+ },
232
+ 60_000,
233
+ );
234
+
235
+ test.each([
236
+ { user: true, project: true, local: false, expected: "local" },
237
+ { user: true, project: false, local: true, expected: null },
238
+ { user: true, project: false, local: undefined, expected: "project" },
239
+ { user: false, project: undefined, local: undefined, expected: "user" },
240
+ { user: undefined, project: undefined, local: undefined, expected: null },
241
+ ])(
242
+ "TEST-30 REQ-15 activation precedence is LOCAL over PROJECT over USER: %j",
243
+ (values) => {
244
+ f = new MarketFixture();
245
+ for (const [scope, file] of [
246
+ ["user", join(f.config, "settings.json")],
247
+ ["project", join(f.project, ".claude", "settings.json")],
248
+ ["local", join(f.project, ".claude", "settings.local.json")],
249
+ ] as const)
250
+ if (values[scope] !== undefined)
251
+ writeJson(file, { enabledPlugins: { [ID]: values[scope] } });
252
+
253
+ const off = f.value(
254
+ `const m=await import(${JSON.stringify(sourceModule("services/market-publish.js"))}); console.log(JSON.stringify(await m.turnedOffHere(${JSON.stringify(ID)},${JSON.stringify(f.project)})));`,
255
+ );
256
+
257
+ expect(off).toBe(values.expected);
258
+ },
259
+ );
260
+
261
+ test.each(["new-file", "changed-file", "turned-off"] as const)(
262
+ "TEST-49 REQ-15 source or activation changed at CLI completion remains kept: %s",
263
+ async (kind) => {
264
+ f = new MarketFixture();
265
+ const repo = await f.prepared();
266
+ const file = join(
267
+ f.folder(),
268
+ kind === "new-file" ? "late.txt" : "SKILL.md",
269
+ );
270
+ f.setModes(
271
+ kind === "turned-off"
272
+ ? { lateOff: "local" }
273
+ : { lateFile: { path: file, content: "Late content must survive\n" } },
274
+ );
275
+
276
+ const r = f.market(["publish", "review", "--plugin", ID, "--yes"]);
277
+
278
+ expect(r.code).toBe(0);
279
+ expectPublished(f, repo);
280
+ expect(r.result.localCopy?.action).toBe("kept");
281
+ expect(existsSync(f.folder())).toBe(true);
282
+ if (kind !== "turned-off")
283
+ expect(readFileSync(file, "utf8")).toBe("Late content must survive\n");
284
+ expect(typeof r.result.localCopy?.reason).toBe("string");
285
+ f.assertOwned();
286
+ },
287
+ 60_000,
288
+ );
289
+
290
+ test.each(["plugin", "skill", "manifest"] as const)(
291
+ "TEST-44 REQ-21 refuses a symlink at the %s write boundary",
292
+ (boundary) => {
293
+ f = new MarketFixture();
294
+ const repo = f.seed();
295
+ const outside = join(f.root, "outside");
296
+ mkdirSync(outside);
297
+ write(join(outside, "sentinel.txt"), "Do not alter\n");
298
+ let args: string[];
299
+ if (boundary === "manifest") {
300
+ const manifestText = readFileSync(
301
+ join(repo.author, ".claude-plugin", "marketplace.json"),
302
+ "utf8",
303
+ );
304
+ rmSync(join(repo.author, ".claude-plugin"), { recursive: true });
305
+ write(join(outside, "marketplace.json"), manifestText);
306
+ symlinkSync(outside, join(repo.author, ".claude-plugin"));
307
+ args = ["plugin", "create", "unsafe@acme", "--yes"];
308
+ } else {
309
+ const rel =
310
+ boundary === "plugin"
311
+ ? "plugins/review-kit"
312
+ : "plugins/review-kit/skills/review";
313
+ rmSync(join(repo.author, rel), { recursive: true });
314
+ if (boundary === "plugin")
315
+ writeJson(join(outside, ".claude-plugin", "plugin.json"), {
316
+ name: "review-kit",
317
+ version: "0.3.1",
318
+ });
319
+ else write(join(outside, "SKILL.md"), SKILL);
320
+ symlinkSync(outside, join(repo.author, rel));
321
+ args =
322
+ boundary === "plugin"
323
+ ? ["plugin", "delete", ID, "--yes"]
324
+ : ["skill", "remove", "review", "--plugin", ID, "--yes"];
325
+ }
326
+ f.push(repo);
327
+ f.register(repo);
328
+ const head = f.head(repo);
329
+ const sentinel = readFileSync(join(outside, "sentinel.txt"), "utf8");
330
+
331
+ const r = f.market(args);
332
+
333
+ // Refuse at ownership lookup (usage) or at the link write guard.
334
+ if (boundary === "skill") expect([1, 2]).toContain(r.code);
335
+ else expect(r.code).toBe(1);
336
+ expect(f.head(repo)).toBe(head);
337
+ expect(readFileSync(join(outside, "sentinel.txt"), "utf8")).toBe(sentinel);
338
+ f.assertOwned();
339
+ },
340
+ 60_000,
341
+ );
342
+
343
+ test("TEST-44 REQ-21 skill edit refuses a linked destination parent", () => {
344
+ f = new MarketFixture();
345
+ const repo = f.seed();
346
+ f.register(repo);
347
+ const outside = join(f.root, "outside");
348
+ mkdirSync(outside);
349
+ mkdirSync(join(f.project, ".claude"), { recursive: true });
350
+ symlinkSync(outside, join(f.project, ".claude", "skills"));
351
+
352
+ const r = f.market(["skill", "edit", "review", "--plugin", ID, "--yes"]);
353
+
354
+ expect(r.code).toBe(1);
355
+ expect(existsSync(join(outside, "review"))).toBe(false);
356
+ f.assertOwned();
357
+ }, 30_000);
@@ -0,0 +1,304 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, readFileSync, symlinkSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import {
5
+ ID,
6
+ MANIFEST,
7
+ MarketFixture,
8
+ PLUGIN_MANIFEST,
9
+ type Repo,
10
+ readJson,
11
+ sourceModule,
12
+ writeJson,
13
+ } from "./market-fixture.js";
14
+
15
+ const machines: MarketFixture[] = [];
16
+ function machine(): MarketFixture {
17
+ const f = new MarketFixture();
18
+ machines.push(f);
19
+ return f;
20
+ }
21
+ afterEach(() => {
22
+ for (const f of machines.splice(0).reverse()) f.close();
23
+ });
24
+ function shareRemote(from: Repo, to: MarketFixture): void {
25
+ const link = join(to.root, "remotes", `${from.slug}.git`);
26
+ mkdirSync(dirname(link), { recursive: true });
27
+ symlinkSync(from.bare, link);
28
+ }
29
+ function registration(f: MarketFixture, marketplaces: Record<string, unknown>) {
30
+ return f.value<{
31
+ added: unknown[];
32
+ trusted: string[];
33
+ conflicts: Array<{ name: string; message: string; fix?: string }>;
34
+ failed: unknown[];
35
+ }>(
36
+ `const m=await import(${JSON.stringify(sourceModule("services/marketplace-sync.js"))}); console.log(JSON.stringify(await m.registerClosureMarketplaces(${JSON.stringify(marketplaces)},${JSON.stringify(f.project)})));`,
37
+ );
38
+ }
39
+ function conflicts(f: MarketFixture, marketplaces: Record<string, unknown>) {
40
+ return f.value<Array<{ name: string; message: string; fix?: string }>>(
41
+ `const m=await import(${JSON.stringify(sourceModule("services/marketplace-sync.js"))}); console.log(JSON.stringify(await m.findMarketplaceConflicts(${JSON.stringify(marketplaces)},${JSON.stringify(f.project)})));`,
42
+ );
43
+ }
44
+ function addPlugin(f: MarketFixture, repo: Repo, name: string) {
45
+ const manifest = readJson(join(repo.author, MANIFEST));
46
+ manifest.plugins.push({
47
+ name,
48
+ source: `./plugins/${name}`,
49
+ version: "0.1.0",
50
+ description: `${name} tools`,
51
+ });
52
+ writeJson(join(repo.author, MANIFEST), manifest);
53
+ writeJson(
54
+ join(repo.author, "plugins", name, ".claude-plugin", "plugin.json"),
55
+ { name, version: "0.1.0", description: `${name} tools` },
56
+ );
57
+ }
58
+
59
+ test("TEST-36 REQ-33 mismatched company source is a read-only conflict with no destructive remove fix", () => {
60
+ const f = machine();
61
+ const repo = f.seed({ slug: "different/market" });
62
+ f.register(repo);
63
+ const refs = { acme: { source: "github", repo: "expected/market" } };
64
+ const before = f.snapshot();
65
+
66
+ const readOnly = conflicts(f, refs);
67
+ const applied = registration(f, refs);
68
+
69
+ expect(readOnly.map((row) => row.name)).toEqual(["acme"]);
70
+ expect(applied.conflicts.map((row) => row.name)).toEqual(["acme"]);
71
+ expect(applied.added).toEqual([]);
72
+ expect(applied.trusted).toEqual([]);
73
+ for (const row of [...readOnly, ...applied.conflicts]) {
74
+ expect(row.message).toMatch(/nothing was changed/i);
75
+ expect(row.fix ?? "").not.toContain("market remove");
76
+ expect(row.fix ?? "").toMatch(/check/i);
77
+ }
78
+ expect(f.snapshot()).toEqual(before);
79
+ expect(f.journal()).toEqual([]);
80
+ f.assertOwned();
81
+ }, 30_000);
82
+
83
+ test.each(["fork", "path", "directory"] as const)(
84
+ "TEST-37 REQ-33 default magus registration from a %s is not a company conflict",
85
+ (source) => {
86
+ const f = machine();
87
+ const repo = f.seed({
88
+ name: "magus",
89
+ slug: "developer/magus-fork",
90
+ marker: false,
91
+ });
92
+ f.register(repo, { trust: false });
93
+ if (source === "path")
94
+ f.registration(repo, { source: "git", url: repo.author });
95
+ if (source === "directory")
96
+ f.registration(repo, { source: "directory", path: repo.author });
97
+ const refs = { magus: { source: "github", repo: "MadAppGang/magus" } };
98
+ const before = f.snapshot();
99
+
100
+ expect(conflicts(f, refs)).toEqual([]);
101
+ const r = registration(f, refs);
102
+ expect(r.conflicts).toEqual([]);
103
+ expect(r.added).toEqual([]);
104
+ expect(f.snapshot()).toEqual(before);
105
+ f.assertOwned();
106
+ },
107
+ 30_000,
108
+ );
109
+
110
+ test.each(["case", "relative-directory"] as const)(
111
+ "TEST-37 REQ-33 equivalent %s source trusts the company without changing its registration",
112
+ (spelling) => {
113
+ const f = machine();
114
+ const repo = f.seed();
115
+ f.register(repo, { trust: false });
116
+ let refs: Record<string, unknown>;
117
+ if (spelling === "case") {
118
+ f.registration(repo, { source: "github", repo: "Acme/Market" });
119
+ refs = { acme: { source: "github", repo: "acme/market" } };
120
+ } else {
121
+ f.registration(repo, { source: "directory", path: repo.author });
122
+ refs = {
123
+ acme: { source: "directory", path: relative(f.project, repo.author) },
124
+ };
125
+ }
126
+
127
+ expect(conflicts(f, refs)).toEqual([]);
128
+ const r = registration(f, refs);
129
+ expect(r.conflicts).toEqual([]);
130
+ expect(r.trusted).toEqual(["acme"]);
131
+ expect(registration(f, refs).trusted).toEqual([]);
132
+ f.assertOwned();
133
+ },
134
+ 30_000,
135
+ );
136
+
137
+ test.each([
138
+ { args: ["update", "team", "--dry-run"], readonly: true, stop: false },
139
+ { args: ["update", "team", "--check"], readonly: true, stop: false },
140
+ { args: ["update", "team"], readonly: false, stop: false },
141
+ { args: ["install", "team", "--yes"], readonly: false, stop: true },
142
+ ])(
143
+ "TEST-36 REQ-26 wrong-source work stays out in $args",
144
+ ({ args, readonly, stop }) => {
145
+ const f = machine();
146
+ const repo = f.seed({ slug: "different/market" });
147
+ f.register(repo);
148
+ f.install();
149
+ const manifest = readJson(join(repo.author, MANIFEST));
150
+ manifest.plugins[0].version = "0.3.2";
151
+ writeJson(join(repo.author, MANIFEST), manifest);
152
+ writeJson(join(repo.author, PLUGIN_MANIFEST), {
153
+ name: "review-kit",
154
+ version: "0.3.2",
155
+ });
156
+ f.push(repo);
157
+ f.git(f.clone(repo), "pull", "--ff-only");
158
+ f.profile(
159
+ { [ID]: "latest" },
160
+ { acme: { source: "github", repo: "expected/market" } },
161
+ );
162
+ if (!stop) f.active();
163
+ const profile = readFileSync(
164
+ join(f.project, ".claude", "profiles.json"),
165
+ "utf8",
166
+ );
167
+ const registry = readFileSync(
168
+ join(f.config, "plugins", "installed_plugins.json"),
169
+ "utf8",
170
+ );
171
+ const userSettings = readFileSync(join(f.config, "settings.json"), "utf8");
172
+ const before = f.snapshot();
173
+
174
+ const r = f.cli(args);
175
+
176
+ if (stop) expect(r.code).toBe(1);
177
+ expect(r.out + r.err).toContain("acme");
178
+ expect(r.out + r.err).toMatch(
179
+ /left out|conflict|different|not the|does not match/i,
180
+ );
181
+ expect(r.out + r.err).not.toContain("magus market remove");
182
+ expect(
183
+ readFileSync(join(f.config, "plugins", "installed_plugins.json"), "utf8"),
184
+ ).toBe(registry);
185
+ expect(readFileSync(join(f.config, "settings.json"), "utf8")).toBe(
186
+ userSettings,
187
+ );
188
+ expect(
189
+ readFileSync(join(f.project, ".claude", "profiles.json"), "utf8"),
190
+ ).toBe(profile);
191
+ expect(f.version()).toBe("0.3.1");
192
+ if (stop)
193
+ expect(
194
+ existsSync(join(f.project, ".claude", "profiles", "active.json")),
195
+ ).toBe(false);
196
+ if (readonly) expect(f.snapshot()).toEqual(before);
197
+ },
198
+ 60_000,
199
+ );
200
+
201
+ test.each(["install", "update"] as const)(
202
+ "TEST-40 REQ-6 REQ-7 Save Profile on the author onboards a fresh teammate via %s",
203
+ (verb) => {
204
+ const author = machine();
205
+ const repo = author.seed({ dependencies: ["base-kit"] });
206
+ addPlugin(author, repo, "base-kit");
207
+ author.push(repo);
208
+ author.register(repo);
209
+ author.install(ID, "project");
210
+ const saved = author.value<Record<string, any>>(
211
+ `const p=await import(${JSON.stringify(sourceModule("services/profiles.js"))}); const fs=await import('node:fs'); await p.saveProfile('Team',{${JSON.stringify(ID)}:true},'project',${JSON.stringify(author.project)}); console.log(JSON.stringify(JSON.parse(fs.readFileSync(${JSON.stringify(join(author.project, ".claude", "profiles.json"))},'utf8'))));`,
212
+ );
213
+ expect(saved.profiles.team.marketplaces.acme).toEqual({
214
+ source: "github",
215
+ repo: repo.slug,
216
+ });
217
+ expect(saved.profiles.team.marketplaces.magus).toBeUndefined();
218
+ const mate = machine();
219
+ shareRemote(repo, mate);
220
+ writeJson(join(mate.project, ".claude", "profiles.json"), saved);
221
+
222
+ const r = mate.cli(
223
+ verb === "install" ? [verb, "team", "--yes"] : [verb, "team"],
224
+ );
225
+
226
+ expect(r.code).toBe(0);
227
+ expect(mate.version(ID, "project")).toBe("0.3.1");
228
+ expect(mate.version("base-kit@acme", "project")).toBe("0.1.0");
229
+ expect(mate.version(ID, "user")).toBeNull();
230
+ expect(
231
+ readJson(join(mate.config, "magus-market", "trusted.json")).marketplaces
232
+ .acme,
233
+ ).toEqual({ source: repo.slug, via: "profile" });
234
+ const notices = (r.out + r.err).match(/company marketplace|trust/gi) ?? [];
235
+ expect(notices.length).toBeGreaterThan(0);
236
+ },
237
+ 60_000,
238
+ );
239
+
240
+ test.each(["github", "git"] as const)(
241
+ "TEST-41 REQ-7 company %s source refreshes inside the hour despite autoUpdate false and adds new dependency",
242
+ (source) => {
243
+ const author = machine();
244
+ const repo = author.seed({ dependencies: ["base-kit"] });
245
+ addPlugin(author, repo, "base-kit");
246
+ author.push(repo);
247
+ const mate = machine();
248
+ shareRemote(repo, mate);
249
+ mate.setModes({ autoUpdate: false });
250
+ mate.profile();
251
+ const first = mate.cli(["update", "team"]);
252
+ expect(first.code).toBe(0);
253
+ expect(mate.version(ID, "project")).toBe("0.3.1");
254
+ if (source === "git")
255
+ mate.registration(repo, { source: "git", url: repo.url });
256
+ const knownFile = join(mate.config, "plugins", "known_marketplaces.json");
257
+ const known = readJson(knownFile);
258
+ known.acme.autoUpdate = false;
259
+ writeJson(knownFile, known);
260
+ addPlugin(author, repo, "extra-kit");
261
+ const manifest = readJson(join(repo.author, MANIFEST));
262
+ manifest.plugins.find(
263
+ (entry: { name: string }) => entry.name === "review-kit",
264
+ ).version = "0.3.2";
265
+ writeJson(join(repo.author, MANIFEST), manifest);
266
+ writeJson(join(repo.author, PLUGIN_MANIFEST), {
267
+ name: "review-kit",
268
+ version: "0.3.2",
269
+ dependencies: ["base-kit", "extra-kit"],
270
+ });
271
+ author.push(repo);
272
+
273
+ const second = mate.cli(["update", "team"]);
274
+
275
+ expect(second.code).toBe(0);
276
+ expect(mate.version(ID, "project")).toBe("0.3.2");
277
+ expect(mate.version("extra-kit@acme", "project")).toBe("0.1.0");
278
+ expect(mate.version("base-kit@acme", "project")).toBe("0.1.0");
279
+ },
280
+ 60_000,
281
+ );
282
+
283
+ test("TEST-42 REQ-26 an unreadable company marketplace does not prevent unrelated teammate plugins", () => {
284
+ const owner = machine();
285
+ const bad = owner.seed();
286
+ const good = owner.seed({ name: "safe", slug: "safe/market" });
287
+ const mate = machine();
288
+ shareRemote(bad, mate);
289
+ shareRemote(good, mate);
290
+ mate.setModes({ denyReadSlug: bad.slug });
291
+ mate.profile(
292
+ { [ID]: "latest", "review-kit@safe": "latest" },
293
+ {
294
+ acme: { source: "github", repo: bad.slug },
295
+ safe: { source: "github", repo: good.slug },
296
+ },
297
+ );
298
+
299
+ const r = mate.cli(["update", "team"]);
300
+
301
+ expect(r.out + r.err).toContain("ask its owner");
302
+ expect(mate.version("review-kit@safe", "project")).toBe("0.3.1");
303
+ expect(mate.version(ID, "project")).toBeNull();
304
+ }, 60_000);