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.
- package/README.md +7 -4
- package/package.json +4 -4
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-router.test.ts +4 -4
- package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
- package/src/cli/market-view.ts +59 -18
- package/src/cli/market.ts +128 -44
- package/src/cli/router.ts +42 -10
- package/src/data/marketplaces.ts +1 -0
- package/src/main.tsx +1 -1
- package/src/market-tui.tsx +1 -1
- package/src/services/claude-cli.ts +13 -0
- package/src/services/local-marketplace.ts +8 -0
- package/src/services/market-change.ts +6 -6
- package/src/services/market-local.ts +93 -0
- package/src/services/market-ops-plugin.ts +366 -47
- package/src/services/market-ops-publish.ts +363 -15
- package/src/services/market-ops-repo.ts +94 -13
- package/src/services/market-ops-skill.ts +93 -11
- package/src/services/market-ops.ts +72 -8
- package/src/services/market-publish.ts +10 -1
- package/src/services/market-types.ts +23 -0
- package/src/services/update-engine.ts +97 -1
- package/src/tui-runtime.tsx +2 -2
- package/src/types/index.ts +2 -0
- package/src/ui/adapters/marketAdapter.ts +123 -4
- package/src/ui/adapters/pluginsAdapter.ts +25 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/market/MarketplaceScreen.tsx +55 -24
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
- package/src/ui/market/MarketplacesScreen.tsx +120 -29
- package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
- package/src/ui/market/marketKeymap.ts +29 -2
- package/src/ui/market/useMarketState.ts +146 -5
- package/src/utils/display-path.ts +33 -0
|
@@ -88,10 +88,17 @@ import {
|
|
|
88
88
|
filesUnder,
|
|
89
89
|
installLines,
|
|
90
90
|
installStep,
|
|
91
|
+
pluginViewAt,
|
|
91
92
|
readPlugin,
|
|
92
93
|
versionFiles,
|
|
93
94
|
versionLine,
|
|
94
95
|
} from "./market-ops-content.js";
|
|
96
|
+
import {
|
|
97
|
+
NEW_PLUGIN_VERSION,
|
|
98
|
+
newPluginLayout,
|
|
99
|
+
refreshStep,
|
|
100
|
+
writeNewPlugin,
|
|
101
|
+
} from "./market-ops-plugin.js";
|
|
95
102
|
|
|
96
103
|
// ─── skill add ───────────────────────────────────────────────────────────────
|
|
97
104
|
|
|
@@ -100,6 +107,13 @@ export interface SkillAddArgs {
|
|
|
100
107
|
plugin: string;
|
|
101
108
|
from: "project" | "global";
|
|
102
109
|
keepLocal: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Create the plugin, in the same commit, when the marketplace has none of
|
|
112
|
+
* that name. Without it a missing plugin is `not-found`.
|
|
113
|
+
*/
|
|
114
|
+
createPlugin?: boolean;
|
|
115
|
+
/** The new plugin's description; read only when it is created. */
|
|
116
|
+
description?: string;
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
export function skillFolder(
|
|
@@ -169,7 +183,34 @@ export async function planSkillAdd(
|
|
|
169
183
|
`${where} has no skill named ${args.skill}`,
|
|
170
184
|
);
|
|
171
185
|
}
|
|
172
|
-
const
|
|
186
|
+
const ref = await resolvePluginRef(args.plugin);
|
|
187
|
+
const snapNow = await freshSnapshot(ref.m);
|
|
188
|
+
const manifestNow = await manifestAt(snapNow, ref.m.name);
|
|
189
|
+
const exists = pluginEntries(manifestNow).some(
|
|
190
|
+
(p) => p.name === ref.plugin,
|
|
191
|
+
);
|
|
192
|
+
if (!exists && !args.createPlugin) {
|
|
193
|
+
throw new MarketError(
|
|
194
|
+
"not-found",
|
|
195
|
+
`${ref.m.name} has no plugin named ${ref.plugin}`,
|
|
196
|
+
"`magus market list` shows its plugins; add --create-plugin to create it with this skill",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
// A plugin created here starts at its first version: nothing to bump.
|
|
200
|
+
const layout = exists
|
|
201
|
+
? null
|
|
202
|
+
: newPluginLayout(ref.m, snapNow, manifestNow, ref.plugin);
|
|
203
|
+
const view: PluginView = layout
|
|
204
|
+
? {
|
|
205
|
+
m: ref.m,
|
|
206
|
+
snap: snapNow,
|
|
207
|
+
manifest: manifestNow,
|
|
208
|
+
plugin: ref.plugin,
|
|
209
|
+
dir: layout.dir,
|
|
210
|
+
pluginJsonPath: layout.pluginJsonPath,
|
|
211
|
+
pluginJson: null,
|
|
212
|
+
}
|
|
213
|
+
: await pluginViewAt(ref.m, snapNow, manifestNow, ref.plugin);
|
|
173
214
|
const { m, snap, plugin, dir } = view;
|
|
174
215
|
const skillDir = joinRel(dir, "skills", args.skill);
|
|
175
216
|
if (filesUnder(snap, skillDir).length > 0) {
|
|
@@ -179,7 +220,9 @@ export async function planSkillAdd(
|
|
|
179
220
|
"to send your edits to it, use `magus market publish`",
|
|
180
221
|
);
|
|
181
222
|
}
|
|
182
|
-
const { from, to } =
|
|
223
|
+
const { from, to } = layout
|
|
224
|
+
? { from: null, to: NEW_PLUGIN_VERSION }
|
|
225
|
+
: bumpFrom(view);
|
|
183
226
|
const sets = await readSourceSets(folder, snap.dir, skillDir);
|
|
184
227
|
if (sets.published.length === 0) {
|
|
185
228
|
throw new MarketError(
|
|
@@ -213,15 +256,40 @@ export async function planSkillAdd(
|
|
|
213
256
|
reason: keepReason,
|
|
214
257
|
}
|
|
215
258
|
: { scope: copyScope, name: args.skill, action: "removed" };
|
|
259
|
+
const created = layout
|
|
260
|
+
? [
|
|
261
|
+
{ path: MANIFEST, change: "modified" as const },
|
|
262
|
+
{ path: layout.pluginJsonPath, change: "added" as const },
|
|
263
|
+
]
|
|
264
|
+
: versionFiles(view);
|
|
216
265
|
const files = [
|
|
217
266
|
...sets.published.map((f) => ({
|
|
218
267
|
path: joinRel(skillDir, f.path),
|
|
219
268
|
change: "added" as const,
|
|
220
269
|
})),
|
|
221
|
-
...
|
|
270
|
+
...created,
|
|
222
271
|
];
|
|
223
272
|
const state: ChangeSpec["state"] & { moved?: boolean } = {};
|
|
224
273
|
const steps: MarketStep[] = [
|
|
274
|
+
...(layout
|
|
275
|
+
? [
|
|
276
|
+
{
|
|
277
|
+
id: "edit",
|
|
278
|
+
label: `wrote ${plugin}`,
|
|
279
|
+
outside: false,
|
|
280
|
+
async run(ctx: Parameters<MarketStep["run"]>[0]) {
|
|
281
|
+
writeNewPlugin(
|
|
282
|
+
cloneOf(ctx),
|
|
283
|
+
m,
|
|
284
|
+
view.manifest,
|
|
285
|
+
layout,
|
|
286
|
+
args.description,
|
|
287
|
+
);
|
|
288
|
+
return undefined;
|
|
289
|
+
},
|
|
290
|
+
} satisfies MarketStep,
|
|
291
|
+
]
|
|
292
|
+
: []),
|
|
225
293
|
{
|
|
226
294
|
id: "copy",
|
|
227
295
|
label: `copied ${args.skill} into ${plugin}`,
|
|
@@ -246,20 +314,27 @@ export async function planSkillAdd(
|
|
|
246
314
|
return undefined;
|
|
247
315
|
},
|
|
248
316
|
},
|
|
249
|
-
bumpStep(view, to),
|
|
317
|
+
...(layout ? [] : [bumpStep(view, to)]),
|
|
250
318
|
...changeSteps({
|
|
251
319
|
label: m.name,
|
|
252
320
|
source: m.source,
|
|
253
321
|
branch: snap.branch,
|
|
254
322
|
target,
|
|
255
|
-
stage:
|
|
323
|
+
stage: layout
|
|
324
|
+
? [MANIFEST, dir]
|
|
325
|
+
: [skillDir, ...versionFiles(view).map((f) => f.path)],
|
|
256
326
|
files,
|
|
257
|
-
message:
|
|
327
|
+
message: layout
|
|
328
|
+
? `${plugin}: create plugin with ${args.skill} skill (${to})`
|
|
329
|
+
: `${plugin}: add ${args.skill} skill (${to})`,
|
|
258
330
|
slug: `${plugin}-${to}`,
|
|
259
|
-
prBody:
|
|
260
|
-
|
|
331
|
+
prBody: layout
|
|
332
|
+
? `Creates the plugin \`${plugin}\` (${to}) in ${m.name} with the skill \`${args.skill}\`.`
|
|
333
|
+
: `Adds the skill \`${args.skill}\` to \`${plugin}\` and bumps it to ${to}.`,
|
|
334
|
+
verify: [{ dir: skillDir, files: sets.published }],
|
|
261
335
|
state,
|
|
262
336
|
}),
|
|
337
|
+
...(layout ? [refreshStep(m.name, state)] : []),
|
|
263
338
|
installStep(view, installs, to, state),
|
|
264
339
|
{
|
|
265
340
|
id: "local-copy",
|
|
@@ -296,8 +371,14 @@ export async function planSkillAdd(
|
|
|
296
371
|
},
|
|
297
372
|
];
|
|
298
373
|
const lines = [
|
|
299
|
-
|
|
300
|
-
|
|
374
|
+
...(layout
|
|
375
|
+
? [
|
|
376
|
+
`Creates the plugin ${plugin} (${to}) in ${m.name}, with the skill ${args.skill} from ${where}, in one commit`,
|
|
377
|
+
]
|
|
378
|
+
: [
|
|
379
|
+
`Adds the skill ${args.skill} from ${where} to ${pluginId}`,
|
|
380
|
+
versionLine(plugin, from, to),
|
|
381
|
+
]),
|
|
301
382
|
`Publishes ${sets.published.length} file${sets.published.length === 1 ? "" : "s"}: ${sets.published.map((f) => f.path).join(", ")}`,
|
|
302
383
|
...(sets.untrackedFolder
|
|
303
384
|
? [
|
|
@@ -324,6 +405,7 @@ export async function planSkillAdd(
|
|
|
324
405
|
from: args.from,
|
|
325
406
|
keepLocal: args.keepLocal,
|
|
326
407
|
plugin: pluginId,
|
|
408
|
+
createPlugin: layout ? { description: args.description ?? null } : null,
|
|
327
409
|
commit: snap.commit,
|
|
328
410
|
hash: sets.hash,
|
|
329
411
|
notPublished: sets.notPublished,
|
|
@@ -344,7 +426,7 @@ export async function planSkillAdd(
|
|
|
344
426
|
steps,
|
|
345
427
|
sync: { name: m.name, url: cloneUrl(m.source) },
|
|
346
428
|
lock: m.name,
|
|
347
|
-
replan: () => planSkillAdd(args, cwd),
|
|
429
|
+
replan: () => planSkillAdd({ ...args, plugin: pluginId }, cwd),
|
|
348
430
|
doneMessage: `${args.skill} is in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
|
|
349
431
|
};
|
|
350
432
|
return { kind: "plan", plan };
|
|
@@ -31,8 +31,10 @@ import {
|
|
|
31
31
|
pluginRelDir,
|
|
32
32
|
pluginRootOf,
|
|
33
33
|
} from "./market-layout.js";
|
|
34
|
+
import { localItems } from "./market-local.js";
|
|
34
35
|
import {
|
|
35
36
|
type TrustedRegistration,
|
|
37
|
+
isGithubSource,
|
|
36
38
|
listCompanyMarketplaces,
|
|
37
39
|
} from "./market-registry.js";
|
|
38
40
|
import {
|
|
@@ -400,6 +402,28 @@ function readJsonFile(file: string): unknown {
|
|
|
400
402
|
}
|
|
401
403
|
}
|
|
402
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The company a marketplace belongs to: its manifest's `owner.name`, else the
|
|
407
|
+
* GitHub owner of its source. Undefined for a non-GitHub source whose
|
|
408
|
+
* manifest names no owner.
|
|
409
|
+
*/
|
|
410
|
+
export function companyOf(
|
|
411
|
+
manifest: unknown,
|
|
412
|
+
source: string,
|
|
413
|
+
): string | undefined {
|
|
414
|
+
const owner =
|
|
415
|
+
manifest && typeof manifest === "object" && !Array.isArray(manifest)
|
|
416
|
+
? (manifest as { owner?: unknown }).owner
|
|
417
|
+
: undefined;
|
|
418
|
+
const name =
|
|
419
|
+
owner && typeof owner === "object"
|
|
420
|
+
? (owner as { name?: unknown }).name
|
|
421
|
+
: undefined;
|
|
422
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
423
|
+
if (isGithubSource(source)) return source.split("/")[0] || undefined;
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
403
427
|
/** The plugins and skills of a marketplace as Claude Code's copy holds them. */
|
|
404
428
|
function rowFor(m: TrustedRegistration): MarketplaceRow {
|
|
405
429
|
const row: MarketplaceRow = {
|
|
@@ -408,10 +432,14 @@ function rowFor(m: TrustedRegistration): MarketplaceRow {
|
|
|
408
432
|
scope: "user",
|
|
409
433
|
plugins: [],
|
|
410
434
|
};
|
|
435
|
+
const fallback = companyOf(null, m.source);
|
|
436
|
+
if (fallback) row.company = fallback;
|
|
411
437
|
if (!m.location) return row;
|
|
412
438
|
const manifest = readJsonFile(
|
|
413
439
|
path.join(m.location, ".claude-plugin", "marketplace.json"),
|
|
414
440
|
);
|
|
441
|
+
const company = companyOf(manifest, m.source);
|
|
442
|
+
if (company) row.company = company;
|
|
415
443
|
const root = pluginRootOf(manifest);
|
|
416
444
|
for (const entry of pluginEntries(manifest)) {
|
|
417
445
|
const dir = pluginDirFor(m.location, entry.source, root);
|
|
@@ -443,9 +471,35 @@ function rowFor(m: TrustedRegistration): MarketplaceRow {
|
|
|
443
471
|
return row;
|
|
444
472
|
}
|
|
445
473
|
|
|
446
|
-
/**
|
|
447
|
-
export
|
|
474
|
+
/** Every plugin name among `marketplaces`. */
|
|
475
|
+
export function companyPluginNames(
|
|
476
|
+
marketplaces: ReadonlyArray<{ plugins: MarketplaceRow["plugins"] }>,
|
|
477
|
+
): Set<string> {
|
|
478
|
+
return new Set(marketplaces.flatMap((m) => m.plugins.map((p) => p.name)));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Every skill name a company plugin among `marketplaces` holds. */
|
|
482
|
+
export function companySkillNames(
|
|
483
|
+
marketplaces: ReadonlyArray<{ plugins: MarketplaceRow["plugins"] }>,
|
|
484
|
+
): Set<string> {
|
|
485
|
+
return new Set(
|
|
486
|
+
marketplaces.flatMap((m) => m.plugins.flatMap((p) => p.skills)),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* `market list`: every company marketplace here with its plugins, and the
|
|
492
|
+
* skills and plugin folders here that none of them holds. Read-only.
|
|
493
|
+
*/
|
|
494
|
+
export async function marketList(
|
|
495
|
+
cwd: string = process.cwd(),
|
|
496
|
+
): Promise<MarketResult> {
|
|
448
497
|
const marketplaces = (await listCompanyMarketplaces()).map(rowFor);
|
|
498
|
+
const local = await localItems(
|
|
499
|
+
cwd,
|
|
500
|
+
companySkillNames(marketplaces),
|
|
501
|
+
companyPluginNames(marketplaces),
|
|
502
|
+
);
|
|
449
503
|
return {
|
|
450
504
|
command: "market list",
|
|
451
505
|
outcome: "ok",
|
|
@@ -454,6 +508,7 @@ export async function marketList(): Promise<MarketResult> {
|
|
|
454
508
|
? "No company marketplaces on this machine"
|
|
455
509
|
: `${marketplaces.length} company marketplace${marketplaces.length === 1 ? "" : "s"} on this machine`,
|
|
456
510
|
marketplaces,
|
|
511
|
+
local,
|
|
457
512
|
steps: [],
|
|
458
513
|
};
|
|
459
514
|
}
|
|
@@ -480,15 +535,16 @@ export interface MarketOverviewEntry {
|
|
|
480
535
|
fix: string;
|
|
481
536
|
/** The commit the plugins were read at, when a local copy had one. */
|
|
482
537
|
commit: string | null;
|
|
538
|
+
/** The company it belongs to, as {@link companyOf} reads it. */
|
|
539
|
+
company?: string;
|
|
483
540
|
plugins: MarketplaceRow["plugins"];
|
|
484
541
|
}
|
|
485
542
|
|
|
486
543
|
/** The plugins of a marketplace snapshot, with their versions and skills. */
|
|
487
544
|
async function pluginsAt(
|
|
488
545
|
snap: Snapshot,
|
|
489
|
-
|
|
546
|
+
manifest: Record<string, unknown>,
|
|
490
547
|
): Promise<MarketplaceRow["plugins"]> {
|
|
491
|
-
const manifest = await manifestAt(snap, label);
|
|
492
548
|
const root = pluginRootOf(manifest);
|
|
493
549
|
const out: MarketplaceRow["plugins"] = [];
|
|
494
550
|
for (const entry of pluginEntries(manifest)) {
|
|
@@ -541,14 +597,22 @@ export async function marketOverview(): Promise<MarketOverviewEntry[]> {
|
|
|
541
597
|
commit: state.snapshot?.commit ?? null,
|
|
542
598
|
plugins: [],
|
|
543
599
|
};
|
|
600
|
+
let fromSnapshot = false;
|
|
544
601
|
if (state.snapshot) {
|
|
545
602
|
try {
|
|
546
|
-
|
|
603
|
+
const manifest = await manifestAt(state.snapshot, m.name);
|
|
604
|
+
entry.plugins = await pluginsAt(state.snapshot, manifest);
|
|
605
|
+
const company = companyOf(manifest, m.source);
|
|
606
|
+
if (company) entry.company = company;
|
|
607
|
+
fromSnapshot = true;
|
|
547
608
|
} catch {
|
|
548
|
-
|
|
609
|
+
fromSnapshot = false;
|
|
549
610
|
}
|
|
550
|
-
}
|
|
551
|
-
|
|
611
|
+
}
|
|
612
|
+
if (!fromSnapshot) {
|
|
613
|
+
const row = rowFor(m);
|
|
614
|
+
entry.plugins = row.plugins;
|
|
615
|
+
if (row.company) entry.company = row.company;
|
|
552
616
|
}
|
|
553
617
|
out.push(entry);
|
|
554
618
|
}
|
|
@@ -87,12 +87,21 @@ export async function readSourceSets(
|
|
|
87
87
|
folder: string,
|
|
88
88
|
destRepo: string,
|
|
89
89
|
destDir: string,
|
|
90
|
+
/**
|
|
91
|
+
* A top-level folder whose own dot-name does not exclude it: a plugin's
|
|
92
|
+
* `.claude-plugin`. What lies inside it is judged by the usual rules.
|
|
93
|
+
*/
|
|
94
|
+
keepDotDir?: string,
|
|
90
95
|
): Promise<SourceSets> {
|
|
91
96
|
const items = inventory(folder);
|
|
92
97
|
const notPublished: SourceSets["notPublished"] = [];
|
|
93
98
|
const candidates: string[] = [];
|
|
94
99
|
for (const item of items) {
|
|
95
|
-
const
|
|
100
|
+
const judged =
|
|
101
|
+
keepDotDir && item.path.startsWith(`${keepDotDir}/`)
|
|
102
|
+
? item.path.slice(keepDotDir.length + 1)
|
|
103
|
+
: item.path;
|
|
104
|
+
const shape = excludedByShape(judged, item.symlink);
|
|
96
105
|
if (shape) notPublished.push({ path: item.path, reason: shape });
|
|
97
106
|
else candidates.push(item.path);
|
|
98
107
|
}
|
|
@@ -60,10 +60,29 @@ export type InstallScope = "user" | "project" | "local";
|
|
|
60
60
|
export interface MarketplaceRow {
|
|
61
61
|
name: string;
|
|
62
62
|
repo: string;
|
|
63
|
+
/**
|
|
64
|
+
* The company it belongs to: the manifest's `owner.name`, else the GitHub
|
|
65
|
+
* owner of its source. Absent when neither names one.
|
|
66
|
+
*/
|
|
67
|
+
company?: string;
|
|
63
68
|
scope: "user";
|
|
64
69
|
plugins: Array<{ name: string; version: string; skills: string[] }>;
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A skill or plugin folder on this machine that is not part of any company
|
|
74
|
+
* marketplace: one of this project's or your USER skills, or a plugin folder
|
|
75
|
+
* among them (one holding `.claude-plugin/plugin.json`).
|
|
76
|
+
*/
|
|
77
|
+
export interface LocalItem {
|
|
78
|
+
kind: "skill" | "plugin";
|
|
79
|
+
/** The folder's name; for a plugin, its `plugin.json` name when it has one. */
|
|
80
|
+
name: string;
|
|
81
|
+
scope: "project" | "user";
|
|
82
|
+
/** The folder, absolute. */
|
|
83
|
+
path: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
export interface SkillStatusRow {
|
|
68
87
|
skill: string;
|
|
69
88
|
from: "project" | "user";
|
|
@@ -128,7 +147,11 @@ export interface MarketResult {
|
|
|
128
147
|
installs?: InstallRow[];
|
|
129
148
|
otherProjects?: number;
|
|
130
149
|
localCopy?: LocalCopy;
|
|
150
|
+
/** A plugin-wide publish settles one local copy per published skill. */
|
|
151
|
+
localCopies?: LocalCopy[];
|
|
131
152
|
marketplaces?: MarketplaceRow[];
|
|
153
|
+
/** `market list`: skills and plugins on this machine that no marketplace holds. */
|
|
154
|
+
local?: LocalItem[];
|
|
132
155
|
skills?: SkillStatusRow[];
|
|
133
156
|
plan?: string[];
|
|
134
157
|
steps: StepRecord[];
|
|
@@ -43,6 +43,7 @@ import { selectProfileId } from "./active-closure.js";
|
|
|
43
43
|
import type { PluginScope } from "./claude-cli.js";
|
|
44
44
|
import {
|
|
45
45
|
installPlugin,
|
|
46
|
+
installedScopeFromAnswer,
|
|
46
47
|
isNotInstalledAnswer,
|
|
47
48
|
repairPlugin,
|
|
48
49
|
uninstallPluginViaCli,
|
|
@@ -1508,7 +1509,25 @@ export async function uninstallPluginInScope(
|
|
|
1508
1509
|
}
|
|
1509
1510
|
}
|
|
1510
1511
|
|
|
1511
|
-
|
|
1512
|
+
try {
|
|
1513
|
+
await remove.uninstall(pluginId, scope, projectPath);
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (!isNotInstalledAnswer(error)) throw error;
|
|
1516
|
+
await settleScopeWithNoInstall(
|
|
1517
|
+
pluginId,
|
|
1518
|
+
scope,
|
|
1519
|
+
projectPath,
|
|
1520
|
+
deps,
|
|
1521
|
+
error,
|
|
1522
|
+
profile,
|
|
1523
|
+
);
|
|
1524
|
+
if (scope === "project") {
|
|
1525
|
+
await remove
|
|
1526
|
+
.forgetVersion?.(pluginId, scope, projectPath)
|
|
1527
|
+
.catch(() => {});
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1512
1531
|
if (await remove.isInstalled(pluginId, scope, projectPath)) {
|
|
1513
1532
|
throw new Error(`still installed at ${scope.toUpperCase()} scope`);
|
|
1514
1533
|
}
|
|
@@ -1531,6 +1550,83 @@ export async function uninstallPluginInScope(
|
|
|
1531
1550
|
await recordInProfileOrExplain(deps, projectPath, done);
|
|
1532
1551
|
}
|
|
1533
1552
|
|
|
1553
|
+
/**
|
|
1554
|
+
* The half of {@link uninstallPluginInScope} that runs when Claude Code answers
|
|
1555
|
+
* that it holds no install of the plugin at `scope` here, so its uninstall had
|
|
1556
|
+
* nothing to remove.
|
|
1557
|
+
*
|
|
1558
|
+
* At PROJECT the setting that turns the plugin on is the profile render's, so
|
|
1559
|
+
* the profile drops it and the live files are brought back in line with the
|
|
1560
|
+
* profile. USER and LOCAL settings belong to no profile, so nothing is changed
|
|
1561
|
+
* there. Either way the scope is read back afterwards: still installed means
|
|
1562
|
+
* nothing magus owns turns it on, and the refusal names the scope Claude Code
|
|
1563
|
+
* reported, in magus's terms rather than the CLI's text.
|
|
1564
|
+
*/
|
|
1565
|
+
async function settleScopeWithNoInstall(
|
|
1566
|
+
pluginId: string,
|
|
1567
|
+
scope: PluginScope,
|
|
1568
|
+
projectPath: string,
|
|
1569
|
+
deps: PluginApplyDeps,
|
|
1570
|
+
answer: unknown,
|
|
1571
|
+
profile: string | null | undefined,
|
|
1572
|
+
): Promise<void> {
|
|
1573
|
+
const remove = deps.remove;
|
|
1574
|
+
if (!remove) throw new Error("magus cannot remove plugins from here");
|
|
1575
|
+
const name = pluginName(pluginId);
|
|
1576
|
+
const here = scope.toUpperCase();
|
|
1577
|
+
|
|
1578
|
+
let wasListed = false;
|
|
1579
|
+
if (scope === "project") {
|
|
1580
|
+
wasListed = await profileListsPlugin(pluginId, projectPath, profile);
|
|
1581
|
+
if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
|
|
1582
|
+
throw new Error(`${name} is still in the profile at ${here} scope`);
|
|
1583
|
+
}
|
|
1584
|
+
await recordInProfileOrExplain(
|
|
1585
|
+
deps,
|
|
1586
|
+
projectPath,
|
|
1587
|
+
`${name} is no longer in the profile`,
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (!(await remove.isInstalled(pluginId, scope, projectPath))) return;
|
|
1592
|
+
// The profile was changed above, so the refusal says so: the user must not
|
|
1593
|
+
// find a plugin gone from the profile with nothing telling them why.
|
|
1594
|
+
const lead = wasListed
|
|
1595
|
+
? `${name} is no longer in the profile, but it is`
|
|
1596
|
+
: `${name} is`;
|
|
1597
|
+
const installedAt = installedScopeFromAnswer(answer);
|
|
1598
|
+
if (installedAt && installedAt !== scope) {
|
|
1599
|
+
const there = installedAt.toUpperCase();
|
|
1600
|
+
throw new Error(
|
|
1601
|
+
`${lead}${wasListed ? " still" : ""} installed at ${there} scope, not ${here} — remove it from ${there}`,
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
`${lead} not installed at ${here} scope in this project — remove it from the scope that installed it`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Whether `profile` lists the plugin, the profile picked as
|
|
1611
|
+
* `removePluginFromProfile` picks it: undefined means the active one.
|
|
1612
|
+
*/
|
|
1613
|
+
async function profileListsPlugin(
|
|
1614
|
+
pluginId: string,
|
|
1615
|
+
projectPath: string,
|
|
1616
|
+
profile: string | null | undefined,
|
|
1617
|
+
): Promise<boolean> {
|
|
1618
|
+
const manifest = await readManifest(projectPath);
|
|
1619
|
+
const id =
|
|
1620
|
+
profile === undefined
|
|
1621
|
+
? selectProfileId(
|
|
1622
|
+
Object.keys(manifest.profiles),
|
|
1623
|
+
undefined,
|
|
1624
|
+
await activeProfile(projectPath),
|
|
1625
|
+
)
|
|
1626
|
+
: profile;
|
|
1627
|
+
return id ? profileLists(manifest.profiles[id], pluginId) : false;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1534
1630
|
// -- binaries ----------------------------------------------------------------
|
|
1535
1631
|
|
|
1536
1632
|
/**
|
package/src/tui-runtime.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What every magus TUI shares before its first screen: the renderer, the one
|
|
3
3
|
* shutdown owner, Ctrl+C, the OSC 52 copy of a mouse selection, and the light
|
|
4
|
-
* or dark theme. `tui.tsx` (bare `magus`) and `market-tui.tsx` (
|
|
5
|
-
* `
|
|
4
|
+
* or dark theme. `tui.tsx` (bare `magus`) and `market-tui.tsx` (`magus
|
|
5
|
+
* edit`) each hand `mountTui` the React tree they draw.
|
|
6
6
|
*
|
|
7
7
|
* Like those two, this module is only ever imported dynamically from
|
|
8
8
|
* `main.tsx`, so nothing on a non-interactive path loads `@opentui/*`.
|
package/src/types/index.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface Marketplace {
|
|
|
55
55
|
* marker and this machine trusts it. Listed above every other marketplace.
|
|
56
56
|
*/
|
|
57
57
|
company?: boolean;
|
|
58
|
+
/** `owner.name` from the marketplace's own manifest, when it names one. */
|
|
59
|
+
ownerName?: string;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export interface DiscoveredMarketplace {
|