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,341 @@
1
+ /**
2
+ * A scope key on the Plugins screen uninstalls through `uninstallPluginInScope`.
3
+ * When the scope's settings.json turns the plugin on but Claude Code's registry
4
+ * holds its row at ANOTHER scope, `claude plugin uninstall --scope <scope>`
5
+ * refuses with "is installed in <other> scope, not <scope>" and changes
6
+ * nothing. In a profiled project that is the normal state of a PROJECT enable:
7
+ * the profile render wrote it, so the profile is what must drop it. Where no
8
+ * profile owns the enable, the refusal must reach the user in magus's terms,
9
+ * never as Claude Code's raw text.
10
+ *
11
+ * Real files, real reconcile: a temp project, HOME pointed at a temp dir. Only
12
+ * the `claude` CLI is doubled, and its uninstall refuses the way Claude Code
13
+ * does, touching no file.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
17
+ import { mkdtemp, rm } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import fs from "fs-extra";
21
+ import { readManifest, writeManifest } from "../services/manifest.js";
22
+ import { activateProfile } from "../services/profile-activate.js";
23
+ import {
24
+ type PluginApplyDeps,
25
+ REAL_PLUGIN_DEPS,
26
+ REAL_REMOVE_DEPS,
27
+ uninstallPluginInScope,
28
+ } from "../services/update-engine.js";
29
+ import type { ProfileManifest } from "../types/index.js";
30
+
31
+ type Scope = "user" | "project" | "local";
32
+
33
+ let project: string;
34
+ let home: string;
35
+ let log: ReturnType<typeof spyOn>;
36
+ const saved = {
37
+ HOME: process.env.HOME,
38
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
39
+ };
40
+
41
+ function manifest(): ProfileManifest {
42
+ return {
43
+ version: 2,
44
+ profiles: {
45
+ team: {
46
+ name: "Team",
47
+ plugins: { "dev@magus": "latest", "multimodel@magus": "latest" },
48
+ },
49
+ },
50
+ };
51
+ }
52
+
53
+ /** The settings.json `claude plugin … --scope <scope>` reads and writes. */
54
+ function settingsFor(scope: Scope): string {
55
+ if (scope === "user") return join(home, ".claude", "settings.json");
56
+ if (scope === "local") return join(project, ".claude", "settings.local.json");
57
+ return join(project, ".claude", "settings.json");
58
+ }
59
+
60
+ async function setEnabled(scope: Scope, pluginId: string): Promise<void> {
61
+ const file = settingsFor(scope);
62
+ const json = (await fs.pathExists(file))
63
+ ? ((await fs.readJson(file)) as Record<string, unknown>)
64
+ : {};
65
+ json.enabledPlugins = {
66
+ ...((json.enabledPlugins as Record<string, boolean>) ?? {}),
67
+ [pluginId]: true,
68
+ };
69
+ await fs.outputFile(file, JSON.stringify(json));
70
+ }
71
+
72
+ async function enabledIn(scope: Scope): Promise<Record<string, boolean>> {
73
+ const file = settingsFor(scope);
74
+ if (!(await fs.pathExists(file))) return {};
75
+ const json = (await fs.readJson(file)) as {
76
+ enabledPlugins?: Record<string, boolean>;
77
+ };
78
+ return json.enabledPlugins ?? {};
79
+ }
80
+
81
+ async function profilePlugins(): Promise<Record<string, unknown>> {
82
+ const { profiles } = await readManifest(project);
83
+ return (profiles.team?.plugins ?? {}) as Record<string, unknown>;
84
+ }
85
+
86
+ /** Claude Code's refusal when the registry row is at `rowAt`, not `asked`. */
87
+ function wrongScopeRefusal(pluginId: string, rowAt: Scope, asked: Scope) {
88
+ return new Error(
89
+ `✘ Failed to uninstall plugin "${pluginId}": Plugin "${pluginId}" is installed in ${rowAt} scope, not ${asked}. Use --scope ${rowAt} to uninstall.`,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The real engine deps with only the `claude` CLI and the version read-back
95
+ * doubled. `uninstall` is the given behaviour; it never touches a file.
96
+ */
97
+ function deps(
98
+ uninstall: (pluginId: string, scope: Scope) => Promise<void>,
99
+ ): PluginApplyDeps {
100
+ const {
101
+ loadedVersion: _loaded,
102
+ registryRows: _rows,
103
+ ...real
104
+ } = REAL_PLUGIN_DEPS;
105
+ return {
106
+ ...real,
107
+ install: async () => {
108
+ throw new Error("install must not run here");
109
+ },
110
+ update: async () => {},
111
+ repair: async () => {},
112
+ readInstalled: async () => "1.0.0",
113
+ saveInstalled: async () => {},
114
+ repairStaleLoad: async () => {
115
+ throw new Error("repairStaleLoad must not run here");
116
+ },
117
+ remove: { ...REAL_REMOVE_DEPS, uninstall },
118
+ };
119
+ }
120
+
121
+ /** Claude Code holding the plugin's only registry row at `rowAt`. */
122
+ function rowOnlyAt(rowAt: Scope): PluginApplyDeps {
123
+ return deps(async (pluginId, scope) => {
124
+ if (scope !== rowAt) throw wrongScopeRefusal(pluginId, rowAt, scope);
125
+ });
126
+ }
127
+
128
+ /** Claude Code's older refusal, which names no scope the plugin is at. */
129
+ function notInstalledRefusal(pluginId: string, asked: Scope) {
130
+ return new Error(
131
+ `✘ Failed to uninstall plugin "${pluginId}": Plugin "${pluginId}" is not installed in ${asked} scope.`,
132
+ );
133
+ }
134
+
135
+ async function rejection(promise: Promise<unknown>): Promise<string> {
136
+ try {
137
+ await promise;
138
+ } catch (error) {
139
+ return error instanceof Error ? error.message : String(error);
140
+ }
141
+ throw new Error("expected the uninstall to reject, but it resolved");
142
+ }
143
+
144
+ beforeEach(async () => {
145
+ project = await mkdtemp(join(tmpdir(), "scope-wrong-scope-"));
146
+ home = await mkdtemp(join(tmpdir(), "scope-wrong-scope-home-"));
147
+ process.env.HOME = home;
148
+ process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
149
+ log = spyOn(console, "log").mockImplementation(() => {});
150
+ });
151
+
152
+ afterEach(async () => {
153
+ log.mockRestore();
154
+ process.env.HOME = saved.HOME;
155
+ if (saved.CLAUDE_CONFIG_DIR === undefined) {
156
+ // biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
157
+ delete process.env.CLAUDE_CONFIG_DIR;
158
+ } else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
159
+ await rm(project, { recursive: true, force: true });
160
+ await rm(home, { recursive: true, force: true });
161
+ });
162
+
163
+ describe("a scope uninstall Claude Code refuses as the wrong scope", () => {
164
+ test("PROJECT, enabled by the active profile: the profile drops it and the project no longer enables it", async () => {
165
+ await writeManifest(manifest(), project);
166
+ await activateProfile(manifest(), "team", project, {
167
+ onDisplace: () => {},
168
+ });
169
+ expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
170
+
171
+ await uninstallPluginInScope(
172
+ "multimodel@magus",
173
+ "project",
174
+ project,
175
+ rowOnlyAt("user"),
176
+ "team",
177
+ );
178
+
179
+ expect((await enabledIn("project"))["multimodel@magus"]).not.toBe(true);
180
+ expect(await profilePlugins()).not.toHaveProperty("multimodel@magus");
181
+ });
182
+
183
+ test("PROJECT, enabled with no profile to own it: refused in magus's terms, naming USER", async () => {
184
+ await setEnabled("project", "multimodel@magus");
185
+
186
+ const message = await rejection(
187
+ uninstallPluginInScope(
188
+ "multimodel@magus",
189
+ "project",
190
+ project,
191
+ rowOnlyAt("user"),
192
+ ),
193
+ );
194
+
195
+ expect(message).toMatch(/USER/);
196
+ expect(message).not.toContain("Failed to uninstall plugin");
197
+ expect(message).not.toContain("--scope");
198
+ });
199
+
200
+ test("USER, enabled while the only row is PROJECT: refused in magus's terms, naming PROJECT", async () => {
201
+ await writeManifest(manifest(), project);
202
+ await activateProfile(manifest(), "team", project, {
203
+ onDisplace: () => {},
204
+ });
205
+ await setEnabled("user", "multimodel@magus");
206
+ const userBefore = await fs.readFile(settingsFor("user"), "utf8");
207
+
208
+ const message = await rejection(
209
+ uninstallPluginInScope(
210
+ "multimodel@magus",
211
+ "user",
212
+ project,
213
+ rowOnlyAt("project"),
214
+ "team",
215
+ ),
216
+ );
217
+
218
+ expect(message).toMatch(/PROJECT/);
219
+ expect(message).not.toContain("Failed to uninstall plugin");
220
+ expect(message).not.toContain("--scope");
221
+ expect(await fs.readFile(settingsFor("user"), "utf8")).toBe(userBefore);
222
+ expect(await profilePlugins()).toHaveProperty("multimodel@magus");
223
+ });
224
+
225
+ test("PROJECT, any other CLI failure: rejected with its own text, the profile untouched", async () => {
226
+ await writeManifest(manifest(), project);
227
+ await activateProfile(manifest(), "team", project, {
228
+ onDisplace: () => {},
229
+ });
230
+ const failing = deps(async () => {
231
+ throw new Error("claude timed out");
232
+ });
233
+
234
+ const message = await rejection(
235
+ uninstallPluginInScope(
236
+ "multimodel@magus",
237
+ "project",
238
+ project,
239
+ failing,
240
+ "team",
241
+ ),
242
+ );
243
+
244
+ expect(message).toBe("claude timed out");
245
+ expect(await profilePlugins()).toHaveProperty("multimodel@magus");
246
+ expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
247
+ });
248
+
249
+ test("PROJECT, the older not-installed wording, no profile to own it: refused in magus's terms, naming PROJECT", async () => {
250
+ await setEnabled("project", "multimodel@magus");
251
+ const olderWording = deps(async (pluginId, scope) => {
252
+ throw notInstalledRefusal(pluginId, scope);
253
+ });
254
+
255
+ const message = await rejection(
256
+ uninstallPluginInScope(
257
+ "multimodel@magus",
258
+ "project",
259
+ project,
260
+ olderWording,
261
+ ),
262
+ );
263
+
264
+ expect(message).toMatch(/PROJECT/);
265
+ expect(message).not.toContain("Failed to uninstall plugin");
266
+ expect(message).not.toContain("--scope");
267
+ });
268
+
269
+ test("LOCAL, enabled while the only row is USER: refused naming USER, the local settings untouched", async () => {
270
+ await setEnabled("local", "multimodel@magus");
271
+ const localBefore = await fs.readFile(settingsFor("local"), "utf8");
272
+
273
+ const message = await rejection(
274
+ uninstallPluginInScope(
275
+ "multimodel@magus",
276
+ "local",
277
+ project,
278
+ rowOnlyAt("user"),
279
+ ),
280
+ );
281
+
282
+ expect(message).toMatch(/USER/);
283
+ expect(message).not.toContain("Failed to uninstall plugin");
284
+ expect(message).not.toContain("--scope");
285
+ expect(await fs.readFile(settingsFor("local"), "utf8")).toBe(localBefore);
286
+ });
287
+
288
+ test("USER, not enabled there while PROJECT installs it: resolves, the profile still lists it and the project still enables it", async () => {
289
+ await writeManifest(manifest(), project);
290
+ await activateProfile(manifest(), "team", project, {
291
+ onDisplace: () => {},
292
+ });
293
+ expect(await enabledIn("user")).not.toHaveProperty("multimodel@magus");
294
+
295
+ await uninstallPluginInScope(
296
+ "multimodel@magus",
297
+ "user",
298
+ project,
299
+ rowOnlyAt("project"),
300
+ "team",
301
+ );
302
+
303
+ expect(await profilePlugins()).toHaveProperty("multimodel@magus");
304
+ expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
305
+ expect(await enabledIn("user")).not.toHaveProperty("multimodel@magus");
306
+ });
307
+
308
+ test("PROJECT, the profile lists it but the enable survives the drop: refused saying the profile no longer lists it, naming USER", async () => {
309
+ // The profile file lists the plugin; the remove double models a drop
310
+ // that succeeds while the PROJECT enable stays on.
311
+ await writeManifest(manifest(), project);
312
+ const base = rowOnlyAt("user");
313
+ const remove = base.remove;
314
+ if (!remove) throw new Error("the harness deps carry remove");
315
+ const surviving: PluginApplyDeps = {
316
+ ...base,
317
+ recordInProfile: async () => {},
318
+ remove: {
319
+ ...remove,
320
+ dependents: undefined,
321
+ dropFromProfile: async () => true,
322
+ isInstalled: async (_id, scope) => scope === "project",
323
+ },
324
+ };
325
+
326
+ const message = await rejection(
327
+ uninstallPluginInScope(
328
+ "multimodel@magus",
329
+ "project",
330
+ project,
331
+ surviving,
332
+ "team",
333
+ ),
334
+ );
335
+
336
+ expect(message).toContain("no longer in the profile");
337
+ expect(message).toMatch(/USER/);
338
+ expect(message).not.toContain("Failed to uninstall plugin");
339
+ expect(message).not.toContain("--scope");
340
+ });
341
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type {
11
+ LocalCopy,
11
12
  MarketPlan,
12
13
  MarketResult,
13
14
  SkillStatusRow,
@@ -89,23 +90,24 @@ export function stepLine(step: StepRecord, label: string): string {
89
90
  return fail(`${label}: ${step.detail ?? "failed"}`);
90
91
  }
91
92
 
92
- function localCopyLine(result: MarketResult): string[] {
93
- const copy = result.localCopy;
94
- if (!copy) return [];
93
+ function copyLine(copy: LocalCopy): string {
95
94
  const whose = copy.scope === "user" ? "your USER" : "this project's";
96
95
  if (copy.action === "removed") {
97
- return [
98
- ok(
99
- `removed ${whose} copy of ${bold(copy.name)}${copy.scope === "project" ? ` — commit the deletion (.claude/skills/${copy.name})` : ""}`,
100
- ),
101
- ];
96
+ return ok(
97
+ `removed ${whose} copy of ${bold(copy.name)}${copy.scope === "project" ? ` — commit the deletion (.claude/skills/${copy.name})` : ""}`,
98
+ );
102
99
  }
103
100
  if (copy.action === "created") {
104
- return [ok(`${whose} copy of ${bold(copy.name)} is ready to edit`)];
101
+ return ok(`${whose} copy of ${bold(copy.name)} is ready to edit`);
105
102
  }
103
+ return info(`kept ${whose} copy of ${bold(copy.name)}: ${copy.reason ?? ""}`);
104
+ }
105
+
106
+ function localCopyLine(result: MarketResult): string[] {
106
107
  return [
107
- info(`kept ${whose} copy of ${bold(copy.name)}: ${copy.reason ?? ""}`),
108
- ];
108
+ ...(result.localCopy ? [result.localCopy] : []),
109
+ ...(result.localCopies ?? []),
110
+ ].map(copyLine);
109
111
  }
110
112
 
111
113
  function installLines(result: MarketResult): string[] {
@@ -213,6 +215,22 @@ export function statusLines(result: MarketResult): string[] {
213
215
  export function listLines(result: MarketResult): string[] {
214
216
  const lines = headerFor("market list");
215
217
  const rows = result.marketplaces ?? [];
218
+ const local = result.local ?? [];
219
+ const localLines =
220
+ local.length === 0
221
+ ? []
222
+ : [
223
+ ` ${bold("Here, in no marketplace")}`,
224
+ ...local.map(
225
+ (l) => ` ${scopeBadge(l.scope)} ${bold(l.name)} ${dim(l.kind)}`,
226
+ ),
227
+ note(
228
+ rows.length === 0
229
+ ? "a company marketplace is needed before these can be added to one"
230
+ : "add one with `magus market skill add <skill> --plugin <p@m> --create-plugin` or `magus market plugin add <path>`",
231
+ ),
232
+ "",
233
+ ];
216
234
  if (rows.length === 0) {
217
235
  lines.push(info(result.message));
218
236
  lines.push(
@@ -220,10 +238,13 @@ export function listLines(result: MarketResult): string[] {
220
238
  "create one with `magus market create`, or `magus market connect` an existing one",
221
239
  ),
222
240
  );
223
- return [...lines, ""];
241
+ return [...lines, "", ...localLines];
224
242
  }
225
243
  for (const m of rows) {
226
- lines.push(` ${bold(m.name)} ${dim(m.repo)} ${scopeBadge(m.scope)}`);
244
+ const by = m.company ? ` by ${m.company}` : "";
245
+ lines.push(
246
+ ` ${bold(m.name)}${by} ${dim(m.repo)} ${scopeBadge(m.scope)}`,
247
+ );
227
248
  if (m.plugins.length === 0) lines.push(listItem(dim("no plugins yet")));
228
249
  for (const p of m.plugins) {
229
250
  lines.push(
@@ -235,7 +256,7 @@ export function listLines(result: MarketResult): string[] {
235
256
  }
236
257
  lines.push("");
237
258
  }
238
- return lines;
259
+ return [...lines, ...localLines];
239
260
  }
240
261
 
241
262
  /** `magus market --help`. */
@@ -244,23 +265,38 @@ export function marketHelp(): string {
244
265
 
245
266
  Usage: magus market <command> [args]
246
267
 
268
+ The screen for all of this is \`magus edit\`; these commands run without one.
269
+
247
270
  list Company marketplaces on this machine, with their
248
271
  plugins and skills. Changes nothing.
249
272
  fetch [<m>] Bring magus's copies of company marketplaces up to
250
273
  date with GitHub. Changes nothing else.
251
- create <owner>/<repo> --private|--public [--name <m>] [--description <t>]
274
+ create <owner>/<repo> --private|--public [--name <m>] [--company <name>]
275
+ [--description <t>]
252
276
  Create a GitHub repository holding a new company
253
277
  marketplace and add it to this machine.
254
- connect <owner/repo|git-url|path> [--name <m>]
278
+ --company is the name magus shows it by ("by
279
+ <name>"); the GitHub owner when left out. One line,
280
+ at most 64 characters.
281
+ connect <owner/repo|git-url|path> [--name <m>] [--company <name>]
255
282
  Use an existing repository as a company marketplace.
256
- --name only when the repository holds no marketplace.
283
+ --name and --company only when the repository holds
284
+ no marketplace; otherwise its manifest names both.
257
285
  remove <m> Remove a company marketplace from this machine,
258
286
  uninstalling its plugins. The repository stays.
259
287
  plugin create <p@m> [--description <t>]
260
288
  plugin delete <p@m> Add or delete a plugin in the marketplace.
261
- skill add <skill> --plugin <p@m> [--from project|global] [--keep-local]
289
+ plugin add <path> [--to <m>] [--name <p>]
290
+ Copy a plugin folder of your own (one holding
291
+ .claude-plugin/plugin.json) into the marketplace
292
+ as a new plugin, at its own version (0.1.0 when it
293
+ has none). The folder stays where it is.
294
+ skill add <skill> --plugin <p@m> [--create-plugin [--description <t>]]
295
+ [--from project|global] [--keep-local]
262
296
  Add a skill from this project (or your USER skills)
263
297
  to a plugin, then use the plugin's version.
298
+ --create-plugin creates the plugin in the same
299
+ commit when the marketplace has none of that name.
264
300
  skill edit <skill> [--plugin <p@m>] [--to project|global]
265
301
  Copy a plugin's skill here to edit it.
266
302
  skill remove <skill> --plugin <p@m>
@@ -274,6 +310,11 @@ Usage: magus market <command> [args]
274
310
  then remove the local copy. --force replaces the
275
311
  plugin's version when the copy's origin is unknown
276
312
  or both sides changed. --dry-run shows the plan.
313
+ publish --plugin <p@m> [--keep-local] [--dry-run]
314
+ Send every skill of the plugin edited here as one
315
+ new patch version. A copy whose origin is unknown,
316
+ or that changed on both sides, is left out: publish
317
+ it on its own with --force.
277
318
 
278
319
  @m may be left out when this machine has one company marketplace.
279
320