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,655 @@
1
+ /**
2
+ * market-ops-plugin.ts — plans for `plugin create`, `plugin add` and
3
+ * `plugin delete`.
4
+ *
5
+ * Read-only planners. Each plan syncs magus's working clone in its apply and
6
+ * is planned again there before its first write.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { requireClaudeConfigDir } from "../utils/config-dir.js";
12
+ import { displayPath } from "../utils/display-path.js";
13
+ import { checkIgnore } from "./git-write.js";
14
+ import { forgetBase, forgetBasesFor, recordBase } from "./market-bases.js";
15
+ import {
16
+ type ChangeSpec,
17
+ changeSteps,
18
+ cloneOf,
19
+ deliveryTarget,
20
+ linkRefusal,
21
+ readJson,
22
+ targetLine,
23
+ writeJson,
24
+ } from "./market-change.js";
25
+ import { type Snapshot, cloneUrl } from "./market-clone.js";
26
+ import {
27
+ copyFileUnder,
28
+ linkUnder,
29
+ refuseLinkUnder,
30
+ removeUnder,
31
+ } from "./market-fs.js";
32
+ import {
33
+ SYMLINK_MODE,
34
+ type SkillFile,
35
+ addPluginEntry,
36
+ currentVersion,
37
+ findSkillOwners,
38
+ inDomain,
39
+ joinRel,
40
+ nextVersion,
41
+ pluginEntries,
42
+ pluginRelDir,
43
+ pluginRootOf,
44
+ removePluginEntry,
45
+ setEntryVersion,
46
+ treeHash,
47
+ validPackageName,
48
+ validSkillName,
49
+ } from "./market-layout.js";
50
+ import {
51
+ type MarketCommand,
52
+ companyByName,
53
+ computePlanId,
54
+ freshSnapshot,
55
+ manifestAt,
56
+ planError,
57
+ resolvePluginRef,
58
+ } from "./market-ops.js";
59
+ import {
60
+ type InstalledAt,
61
+ installReaches,
62
+ installedScopes,
63
+ moveInstalls,
64
+ profileManaged,
65
+ readSourceSets,
66
+ removeLocalCopy,
67
+ } from "./market-publish.js";
68
+ import {
69
+ type TrustedRegistration,
70
+ listCompanyMarketplaces,
71
+ } from "./market-registry.js";
72
+ import {
73
+ type FileChange,
74
+ type LocalCopy,
75
+ MarketError,
76
+ type MarketPlan,
77
+ type MarketStep,
78
+ type PlanAnswer,
79
+ } from "./market-types.js";
80
+ import { fastForwardCloneIfPresent } from "./marketplace-refresh.js";
81
+ import { getUserSkillsDir } from "./skills-manager.js";
82
+
83
+ import {
84
+ MANIFEST,
85
+ type PluginView,
86
+ SCOPE_WORD,
87
+ bumpFrom,
88
+ bumpStep,
89
+ filesUnder,
90
+ installLines,
91
+ installStep,
92
+ readPlugin,
93
+ versionFiles,
94
+ versionLine,
95
+ } from "./market-ops-content.js";
96
+
97
+ // ─── plugin create ───────────────────────────────────────────────────────────
98
+
99
+ /** The version a plugin new to a marketplace starts at. */
100
+ export const NEW_PLUGIN_VERSION = "0.1.0";
101
+
102
+ /** Where a new plugin goes in a marketplace snapshot. */
103
+ export interface NewPluginLayout {
104
+ plugin: string;
105
+ /** The plugin's folder, repository-relative. */
106
+ dir: string;
107
+ pluginJsonPath: string;
108
+ }
109
+
110
+ /**
111
+ * Check `plugin` can be added to the marketplace as read, and name the
112
+ * folder it goes in: under `metadata.pluginRoot`, else `plugins/<name>`.
113
+ * Read-only.
114
+ */
115
+ export function newPluginLayout(
116
+ m: TrustedRegistration,
117
+ snap: Snapshot,
118
+ manifest: Record<string, unknown>,
119
+ plugin: string,
120
+ ): NewPluginLayout {
121
+ if (!validPackageName(plugin)) {
122
+ throw new MarketError(
123
+ "invalid-name",
124
+ `${plugin} cannot be a plugin name (lower case letters, digits and dashes)`,
125
+ );
126
+ }
127
+ if (pluginEntries(manifest).some((p) => p.name === plugin)) {
128
+ throw new MarketError(
129
+ "name-taken",
130
+ `${m.name} already has a plugin named ${plugin}`,
131
+ );
132
+ }
133
+ const root = pluginRootOf(manifest);
134
+ const dir =
135
+ (typeof root === "string" ? pluginRelDir(plugin, root) : null) ??
136
+ joinRel("plugins", plugin);
137
+ if (filesUnder(snap, dir).length > 0) {
138
+ throw new MarketError(
139
+ "name-taken",
140
+ `${m.name} already has a folder ${dir}`,
141
+ "pick another plugin name",
142
+ );
143
+ }
144
+ return {
145
+ plugin,
146
+ dir,
147
+ pluginJsonPath: joinRel(dir, ".claude-plugin", "plugin.json"),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Write a new plugin's `plugin.json` and its marketplace entry into a
153
+ * working clone.
154
+ */
155
+ export function writeNewPlugin(
156
+ clone: string,
157
+ m: TrustedRegistration,
158
+ manifest: Record<string, unknown>,
159
+ layout: NewPluginLayout,
160
+ description: string | undefined,
161
+ ): void {
162
+ const owner = (manifest.owner as { name?: unknown } | undefined)?.name;
163
+ const words = description ?? `${layout.plugin} plugin`;
164
+ writeJson(
165
+ clone,
166
+ layout.pluginJsonPath,
167
+ {
168
+ name: layout.plugin,
169
+ version: NEW_PLUGIN_VERSION,
170
+ description: words,
171
+ author: { name: typeof owner === "string" ? owner : m.name },
172
+ },
173
+ m.name,
174
+ );
175
+ const current = readJson(clone, MANIFEST, m.name);
176
+ writeJson(
177
+ clone,
178
+ MANIFEST,
179
+ addPluginEntry(current, {
180
+ name: layout.plugin,
181
+ source: `./${layout.dir}`,
182
+ description: words,
183
+ version: NEW_PLUGIN_VERSION,
184
+ }),
185
+ m.name,
186
+ );
187
+ }
188
+
189
+ export async function planPluginCreate(
190
+ ref: string,
191
+ description?: string,
192
+ ): Promise<PlanAnswer> {
193
+ const command: MarketCommand = "market plugin create";
194
+ try {
195
+ const { plugin, m } = await resolvePluginRef(ref);
196
+ if (!validPackageName(plugin)) {
197
+ throw new MarketError(
198
+ "invalid-name",
199
+ `${plugin} cannot be a plugin name (lower case letters, digits and dashes)`,
200
+ );
201
+ }
202
+ const snap = await freshSnapshot(m);
203
+ const manifest = await manifestAt(snap, m.name);
204
+ const layout = newPluginLayout(m, snap, manifest, plugin);
205
+ const { dir, pluginJsonPath } = layout;
206
+ const version = NEW_PLUGIN_VERSION;
207
+ const target = await deliveryTarget(m.source, m.name);
208
+ const files = [
209
+ { path: MANIFEST, change: "modified" as const },
210
+ { path: pluginJsonPath, change: "added" as const },
211
+ ];
212
+ const state: ChangeSpec["state"] = {};
213
+ const steps: MarketStep[] = [
214
+ {
215
+ id: "edit",
216
+ label: `wrote ${plugin}`,
217
+ outside: false,
218
+ async run(ctx) {
219
+ writeNewPlugin(cloneOf(ctx), m, manifest, layout, description);
220
+ return undefined;
221
+ },
222
+ },
223
+ ...changeSteps({
224
+ label: m.name,
225
+ source: m.source,
226
+ branch: snap.branch,
227
+ target,
228
+ stage: [MANIFEST, dir],
229
+ files,
230
+ message: `${plugin}: create plugin (${version})`,
231
+ slug: `${plugin}-create`,
232
+ prBody: `Creates the plugin \`${plugin}\` (${version}) in ${m.name}.`,
233
+ state,
234
+ }),
235
+ refreshStep(m.name, state),
236
+ ];
237
+ const plan: MarketPlan = {
238
+ command,
239
+ lines: [
240
+ `Creates the plugin ${plugin} (${version}) in ${m.name}`,
241
+ targetLine(target, snap.branch),
242
+ "Installs nothing: install it from magus when you want it",
243
+ ],
244
+ planId: computePlanId({
245
+ op: "plugin-create",
246
+ plugin,
247
+ marketplace: m.name,
248
+ commit: snap.commit,
249
+ description,
250
+ target,
251
+ }),
252
+ summary: {
253
+ marketplace: m.name,
254
+ plugin,
255
+ version: { from: null, to: version },
256
+ files,
257
+ target,
258
+ },
259
+ steps,
260
+ sync: { name: m.name, url: cloneUrl(m.source) },
261
+ lock: m.name,
262
+ replan: () => planPluginCreate(ref, description),
263
+ doneMessage: `${plugin}@${m.name} ${version} is in the marketplace.`,
264
+ };
265
+ return { kind: "plan", plan };
266
+ } catch (error) {
267
+ return planError(command, error);
268
+ }
269
+ }
270
+
271
+ /** After a push, bring Claude Code's clone forward so magus sees the change. */
272
+ export function refreshStep(
273
+ name: string,
274
+ state: ChangeSpec["state"],
275
+ ): MarketStep {
276
+ return {
277
+ id: "refresh",
278
+ label: `brought Claude Code's copy of ${name} up to date`,
279
+ outside: true,
280
+ async run() {
281
+ if (!state.pushed) return { skipped: true };
282
+ const r = await fastForwardCloneIfPresent(name);
283
+ return r === "refreshed" ? undefined : { skipped: true, detail: r };
284
+ },
285
+ };
286
+ }
287
+
288
+ // ─── plugin add ──────────────────────────────────────────────────────────────
289
+
290
+ export interface PluginAddArgs {
291
+ /** The plugin folder: absolute, or relative to the project. */
292
+ path: string;
293
+ /** The company marketplace; may be left out when there is one. */
294
+ to?: string;
295
+ /** The plugin's name there; its `plugin.json` name when left out. */
296
+ name?: string;
297
+ }
298
+
299
+ const PLUGIN_JSON = ".claude-plugin/plugin.json";
300
+
301
+ /** The marketplace a `plugin add` goes to. */
302
+ async function addTarget(to: string | undefined): Promise<TrustedRegistration> {
303
+ if (to) return companyByName(to);
304
+ const all = await listCompanyMarketplaces();
305
+ if (all.length === 1 && all[0]) return all[0];
306
+ if (all.length === 0) {
307
+ throw new MarketError(
308
+ "not-found",
309
+ "There is no company marketplace on this machine",
310
+ "run `magus market create` or `magus market connect` first",
311
+ );
312
+ }
313
+ throw new MarketError(
314
+ "ambiguous",
315
+ `More than one company marketplace is on this machine (${all.map((m) => m.name).join(", ")})`,
316
+ "name one with --to <marketplace>",
317
+ );
318
+ }
319
+
320
+ /** The local plugin folder, checked: a real folder holding plugin.json. */
321
+ function pluginFolder(
322
+ cwd: string,
323
+ given: string,
324
+ ): {
325
+ folder: string;
326
+ json: Record<string, unknown>;
327
+ } {
328
+ const folder = path.resolve(cwd, given);
329
+ let stat: fs.Stats | null = null;
330
+ try {
331
+ stat = fs.lstatSync(folder);
332
+ } catch {
333
+ stat = null;
334
+ }
335
+ if (!stat) {
336
+ throw new MarketError("not-found", `There is no folder at ${given}`);
337
+ }
338
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
339
+ throw new MarketError(
340
+ "other",
341
+ `${given} is ${stat.isSymbolicLink() ? "a link to another folder" : "not a folder"}; magus adds only a plugin folder of its own`,
342
+ );
343
+ }
344
+ const installed = path.join(requireClaudeConfigDir(), "plugins");
345
+ if (folder === installed || folder.startsWith(`${installed}${path.sep}`)) {
346
+ throw new MarketError(
347
+ "other",
348
+ `${given} is a plugin Claude Code installed from a marketplace, not one of your own`,
349
+ );
350
+ }
351
+ const link = linkUnder(folder, PLUGIN_JSON);
352
+ if (link !== null) {
353
+ throw new MarketError(
354
+ "other",
355
+ `${given} reaches its plugin.json through a link at ${link}; magus copies only files of the folder's own`,
356
+ );
357
+ }
358
+ let json: unknown;
359
+ try {
360
+ json = JSON.parse(fs.readFileSync(path.join(folder, PLUGIN_JSON), "utf8"));
361
+ } catch (error) {
362
+ throw new MarketError(
363
+ (error as NodeJS.ErrnoException).code === "ENOENT"
364
+ ? "not-found"
365
+ : "other",
366
+ (error as NodeJS.ErrnoException).code === "ENOENT"
367
+ ? `${given} is not a plugin: it has no ${PLUGIN_JSON}`
368
+ : `${given}'s ${PLUGIN_JSON} is not valid JSON`,
369
+ );
370
+ }
371
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
372
+ throw new MarketError(
373
+ "other",
374
+ `${given}'s ${PLUGIN_JSON} does not hold a JSON object`,
375
+ );
376
+ }
377
+ return { folder, json: json as Record<string, unknown> };
378
+ }
379
+
380
+ /**
381
+ * `market plugin add <path>`: copy a plugin folder of your own into a company
382
+ * marketplace as a new plugin, at its own version (0.1.0 when it has none).
383
+ * The folder stays where it is. Read-only.
384
+ */
385
+ export async function planPluginAdd(
386
+ args: PluginAddArgs,
387
+ cwd: string,
388
+ ): Promise<PlanAnswer> {
389
+ const command: MarketCommand = "market plugin add";
390
+ try {
391
+ const { folder, json } = pluginFolder(cwd, args.path);
392
+ const own = typeof json.name === "string" ? json.name : undefined;
393
+ const plugin = args.name ?? own ?? path.basename(folder);
394
+ if (!validPackageName(plugin)) {
395
+ throw new MarketError(
396
+ "invalid-name",
397
+ `${plugin} cannot be a plugin name (lower case letters, digits and dashes)`,
398
+ "pass --name <name>",
399
+ );
400
+ }
401
+ const m = await addTarget(args.to);
402
+ const snap = await freshSnapshot(m);
403
+ const manifest = await manifestAt(snap, m.name);
404
+ const layout = newPluginLayout(m, snap, manifest, plugin);
405
+ const { dir, pluginJsonPath } = layout;
406
+ const theirVersion =
407
+ typeof json.version === "string" && nextVersion(json.version) !== null
408
+ ? json.version
409
+ : null;
410
+ const version = theirVersion ?? NEW_PLUGIN_VERSION;
411
+ const sets = await readSourceSets(folder, snap.dir, dir, ".claude-plugin");
412
+ if (!sets.published.some((f) => f.path === PLUGIN_JSON)) {
413
+ throw new MarketError(
414
+ "other",
415
+ `${plugin}'s ${PLUGIN_JSON} would not be copied (${sets.notPublished.find((n) => n.path === PLUGIN_JSON)?.reason ?? "excluded"})`,
416
+ );
417
+ }
418
+ // plugin.json is written rather than copied when the marketplace needs
419
+ // a different name or a version it did not carry.
420
+ const rewrite = own !== plugin || theirVersion === null;
421
+ const description =
422
+ typeof json.description === "string" ? json.description : undefined;
423
+ const target = await deliveryTarget(m.source, m.name);
424
+ const files = [
425
+ { path: MANIFEST, change: "modified" as const },
426
+ ...sets.published.map((f) => ({
427
+ path: joinRel(dir, f.path),
428
+ change: "added" as const,
429
+ })),
430
+ ];
431
+ const state: ChangeSpec["state"] = {};
432
+ const steps: MarketStep[] = [
433
+ {
434
+ id: "copy",
435
+ label: `copied ${plugin} into ${m.name}`,
436
+ outside: false,
437
+ async run(ctx) {
438
+ const clone = cloneOf(ctx);
439
+ const linked = linkRefusal(m.name);
440
+ for (const f of sets.published) {
441
+ refuseLinkUnder(clone, joinRel(dir, f.path), linked);
442
+ }
443
+ for (const f of sets.published) {
444
+ copyFileUnder(
445
+ clone,
446
+ joinRel(dir, f.path),
447
+ path.join(folder, f.path),
448
+ linked,
449
+ );
450
+ }
451
+ if (rewrite) {
452
+ writeJson(
453
+ clone,
454
+ pluginJsonPath,
455
+ { ...json, name: plugin, version },
456
+ m.name,
457
+ );
458
+ }
459
+ const current = readJson(clone, MANIFEST, m.name);
460
+ writeJson(
461
+ clone,
462
+ MANIFEST,
463
+ addPluginEntry(current, {
464
+ name: plugin,
465
+ source: `./${dir}`,
466
+ ...(description ? { description } : {}),
467
+ version,
468
+ }),
469
+ m.name,
470
+ );
471
+ return undefined;
472
+ },
473
+ },
474
+ ...changeSteps({
475
+ label: m.name,
476
+ source: m.source,
477
+ branch: snap.branch,
478
+ target,
479
+ stage: [MANIFEST, dir],
480
+ files,
481
+ message: `${plugin}: add plugin (${version})`,
482
+ slug: `${plugin}-add`,
483
+ prBody: `Adds the plugin \`${plugin}\` (${version}) to ${m.name}.`,
484
+ ...(rewrite ? {} : { verify: [{ dir, files: sets.published }] }),
485
+ state,
486
+ }),
487
+ refreshStep(m.name, state),
488
+ ];
489
+ const lines = [
490
+ `Adds the plugin ${plugin} (${version}) from ${displayPath(folder, cwd)} to ${m.name}, in ${dir}`,
491
+ `Copies ${sets.published.length} file${sets.published.length === 1 ? "" : "s"}: ${sets.published.map((f) => f.path).join(", ")}`,
492
+ ...(sets.untrackedFolder
493
+ ? [
494
+ "This folder is not tracked in the project's git; every file is listed",
495
+ ]
496
+ : []),
497
+ ...(sets.notPublished.length > 0
498
+ ? [
499
+ `Not copied: ${sets.notPublished.map((n) => `${n.path} (${n.reason})`).join(", ")}`,
500
+ ]
501
+ : []),
502
+ ...(rewrite
503
+ ? [
504
+ `Writes its plugin.json with the name ${plugin} and the version ${version}`,
505
+ ]
506
+ : []),
507
+ targetLine(target, snap.branch),
508
+ `Keeps that folder: install ${plugin}@${m.name} in magus to use the marketplace's copy, then remove the folder so it does not load twice`,
509
+ ];
510
+ const plan: MarketPlan = {
511
+ command,
512
+ lines,
513
+ planId: computePlanId({
514
+ op: "plugin-add",
515
+ folder,
516
+ plugin,
517
+ marketplace: m.name,
518
+ commit: snap.commit,
519
+ hash: sets.hash,
520
+ notPublished: sets.notPublished,
521
+ version,
522
+ rewrite,
523
+ target,
524
+ }),
525
+ summary: {
526
+ marketplace: m.name,
527
+ plugin,
528
+ version: { from: null, to: version },
529
+ files,
530
+ notPublished: sets.notPublished,
531
+ target,
532
+ },
533
+ steps,
534
+ sync: { name: m.name, url: cloneUrl(m.source) },
535
+ lock: m.name,
536
+ replan: () => planPluginAdd({ ...args, to: m.name }, cwd),
537
+ doneMessage: `${plugin}@${m.name} ${version} is in the marketplace. Install it in magus to use it from there.`,
538
+ };
539
+ return { kind: "plan", plan };
540
+ } catch (error) {
541
+ return planError(command, error);
542
+ }
543
+ }
544
+
545
+ // ─── plugin delete ───────────────────────────────────────────────────────────
546
+
547
+ export async function planPluginDelete(
548
+ ref: string,
549
+ cwd: string,
550
+ ): Promise<PlanAnswer> {
551
+ const command: MarketCommand = "market plugin delete";
552
+ try {
553
+ const view = await readPlugin(ref);
554
+ const { m, snap, plugin, dir } = view;
555
+ // Deleting a plugin removes its folder. A plugin at the marketplace's
556
+ // root, or one whose folder holds or sits inside another plugin's, has
557
+ // no folder of its own to remove.
558
+ const others = pluginEntries(view.manifest)
559
+ .filter((p) => p.name !== plugin)
560
+ .map((p) => ({
561
+ name: p.name,
562
+ dir: pluginRelDir(p.source, pluginRootOf(view.manifest)),
563
+ }));
564
+ const overlap = others.find(
565
+ (o) =>
566
+ o.dir !== null &&
567
+ (o.dir === dir ||
568
+ o.dir === "" ||
569
+ o.dir.startsWith(`${dir}/`) ||
570
+ dir.startsWith(`${o.dir}/`)),
571
+ );
572
+ if (dir === "" || overlap) {
573
+ throw new MarketError(
574
+ "other",
575
+ dir === ""
576
+ ? `${plugin} is the root of ${m.name}, so deleting its folder would delete the whole marketplace — nothing changed`
577
+ : `${plugin} shares its folder with ${overlap?.name} in ${m.name}, so deleting it would delete that plugin too — nothing changed`,
578
+ "remove its entry from the marketplace by hand, or move the plugin into a folder of its own first",
579
+ );
580
+ }
581
+ const under = filesUnder(snap, dir);
582
+ const files = [
583
+ { path: MANIFEST, change: "modified" as const },
584
+ ...under.map((f) => ({
585
+ path: joinRel(dir, f.path),
586
+ change: "deleted" as const,
587
+ })),
588
+ ];
589
+ const installs = await installedScopes(`${plugin}@${m.name}`, cwd);
590
+ const target = await deliveryTarget(m.source, m.name);
591
+ const state: ChangeSpec["state"] = {};
592
+ const steps: MarketStep[] = [
593
+ {
594
+ id: "edit",
595
+ label: `deleted ${plugin}`,
596
+ outside: false,
597
+ async run(ctx) {
598
+ const clone = cloneOf(ctx);
599
+ removeUnder(clone, dir, linkRefusal(m.name));
600
+ const current = readJson(clone, MANIFEST, m.name);
601
+ writeJson(
602
+ clone,
603
+ MANIFEST,
604
+ removePluginEntry(current, plugin),
605
+ m.name,
606
+ );
607
+ return undefined;
608
+ },
609
+ },
610
+ ...changeSteps({
611
+ label: m.name,
612
+ source: m.source,
613
+ branch: snap.branch,
614
+ target,
615
+ stage: [MANIFEST, dir],
616
+ files,
617
+ message: `${plugin}: delete plugin`,
618
+ slug: `${plugin}-delete`,
619
+ prBody: `Deletes the plugin \`${plugin}\` from ${m.name}.`,
620
+ state,
621
+ }),
622
+ refreshStep(m.name, state),
623
+ ];
624
+ const lines = [
625
+ `Deletes the plugin ${plugin} from ${m.name}: ${under.length} file${under.length === 1 ? "" : "s"}`,
626
+ targetLine(target, snap.branch),
627
+ ...(installs.length > 0
628
+ ? [
629
+ `Still installed here at ${installs.map((i) => `${SCOPE_WORD[i.scope]} ${i.version}`).join(", ")} — it keeps loading until it is uninstalled in magus`,
630
+ ]
631
+ : []),
632
+ ];
633
+ const plan: MarketPlan = {
634
+ command,
635
+ lines,
636
+ planId: computePlanId({
637
+ op: "plugin-delete",
638
+ plugin,
639
+ marketplace: m.name,
640
+ commit: snap.commit,
641
+ target,
642
+ installs,
643
+ }),
644
+ summary: { marketplace: m.name, plugin, files, target },
645
+ steps,
646
+ sync: { name: m.name, url: cloneUrl(m.source) },
647
+ lock: m.name,
648
+ replan: () => planPluginDelete(ref, cwd),
649
+ doneMessage: `${plugin} is no longer in ${m.name}.`,
650
+ };
651
+ return { kind: "plan", plan };
652
+ } catch (error) {
653
+ return planError(command, error);
654
+ }
655
+ }