magus-cli 7.13.0 → 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.
- 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/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/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/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[];
|
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 {
|
|
@@ -11,30 +11,146 @@ import type {
|
|
|
11
11
|
} from "../../services/market-ops.js";
|
|
12
12
|
import type {
|
|
13
13
|
InstallScope,
|
|
14
|
+
LocalItem,
|
|
14
15
|
MarketResult,
|
|
15
16
|
SkillStatusRow,
|
|
16
17
|
} from "../../services/market-types.js";
|
|
17
18
|
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
19
|
+
import { displayWidth, truncate } from "../text-width.js";
|
|
18
20
|
import type { theme } from "../theme.js";
|
|
19
21
|
|
|
20
22
|
// ─── Screen 1 ────────────────────────────────────────────────────────────────
|
|
21
23
|
|
|
22
24
|
export type MarketplacesItem =
|
|
23
|
-
| {
|
|
25
|
+
| {
|
|
26
|
+
kind: "marketplace";
|
|
27
|
+
entry: MarketOverviewEntry;
|
|
28
|
+
/** Its plugins with skills edited here, ready to push. */
|
|
29
|
+
toPush: readonly string[];
|
|
30
|
+
}
|
|
24
31
|
| { kind: "create" }
|
|
25
|
-
| { kind: "connect" }
|
|
32
|
+
| { kind: "connect" }
|
|
33
|
+
/** The heading over the local rows; never selected. */
|
|
34
|
+
| { kind: "local-heading"; count: number }
|
|
35
|
+
| { kind: "local"; item: LocalItem };
|
|
26
36
|
|
|
27
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The marketplaces, the two rows that add one, then — when there are any —
|
|
39
|
+
* the skills and plugin folders here that no marketplace holds yet.
|
|
40
|
+
*/
|
|
28
41
|
export function marketplacesItems(
|
|
29
42
|
entries: readonly MarketOverviewEntry[],
|
|
43
|
+
status: readonly SkillStatusRow[] = [],
|
|
44
|
+
local: readonly LocalItem[] = [],
|
|
30
45
|
): MarketplacesItem[] {
|
|
31
46
|
return [
|
|
32
|
-
...entries.map((entry) => ({
|
|
47
|
+
...entries.map((entry) => ({
|
|
48
|
+
kind: "marketplace" as const,
|
|
49
|
+
entry,
|
|
50
|
+
toPush: pluginsToPush(entry.name, status),
|
|
51
|
+
})),
|
|
33
52
|
{ kind: "create" },
|
|
34
53
|
{ kind: "connect" },
|
|
54
|
+
...(local.length > 0
|
|
55
|
+
? [
|
|
56
|
+
{ kind: "local-heading" as const, count: local.length },
|
|
57
|
+
...local.map((item) => ({ kind: "local" as const, item })),
|
|
58
|
+
]
|
|
59
|
+
: []),
|
|
35
60
|
];
|
|
36
61
|
}
|
|
37
62
|
|
|
63
|
+
/** Whether a row can hold the selection: every row but a heading. */
|
|
64
|
+
export function selectable(item: MarketplacesItem | undefined): boolean {
|
|
65
|
+
return item !== undefined && item.kind !== "local-heading";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The next selectable index from `from` in direction `step` (±1), or `from`
|
|
70
|
+
* when there is none that way.
|
|
71
|
+
*/
|
|
72
|
+
export function nextSelectable(
|
|
73
|
+
items: readonly MarketplacesItem[],
|
|
74
|
+
from: number,
|
|
75
|
+
step: number,
|
|
76
|
+
): number {
|
|
77
|
+
for (let i = from + step; i >= 0 && i < items.length; i += step) {
|
|
78
|
+
if (selectable(items[i])) return i;
|
|
79
|
+
}
|
|
80
|
+
return from;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The skill rows `edited` here, by the plugin `ref` that owns them. */
|
|
84
|
+
export function editedSkills(
|
|
85
|
+
status: readonly SkillStatusRow[],
|
|
86
|
+
ref: string,
|
|
87
|
+
): string[] {
|
|
88
|
+
return [
|
|
89
|
+
...new Set(
|
|
90
|
+
status
|
|
91
|
+
.filter((r) => r.state === "edited" && r.owner === ref)
|
|
92
|
+
.map((r) => r.skill),
|
|
93
|
+
),
|
|
94
|
+
].sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The plugins of `marketplace` with at least one skill edited here. */
|
|
98
|
+
export function pluginsToPush(
|
|
99
|
+
marketplace: string,
|
|
100
|
+
status: readonly SkillStatusRow[],
|
|
101
|
+
): string[] {
|
|
102
|
+
const suffix = `@${marketplace}`;
|
|
103
|
+
return [
|
|
104
|
+
...new Set(
|
|
105
|
+
status
|
|
106
|
+
.filter(
|
|
107
|
+
(r) => r.state === "edited" && r.owner?.endsWith(suffix) === true,
|
|
108
|
+
)
|
|
109
|
+
.map((r) => (r.owner as string).slice(0, -suffix.length)),
|
|
110
|
+
),
|
|
111
|
+
].sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What a local row is, in the user's words. */
|
|
115
|
+
export function localKindWords(item: LocalItem): string {
|
|
116
|
+
return item.kind === "plugin" ? "plugin" : "skill";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The narrowest a cut secondary text is still worth showing. */
|
|
120
|
+
const MIN_CUT = 4;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fit one list row into `width` columns without wrapping. The name and the
|
|
124
|
+
* parts of `fixed` columns (a scope badge, the gaps around it) always show:
|
|
125
|
+
* the name is cut with "…" to leave them room. The secondary texts follow in
|
|
126
|
+
* order while each fits whole after a gap of `gap`; the first that does not
|
|
127
|
+
* is cut to what is left (when that is at least a few columns), and the rest
|
|
128
|
+
* are dropped.
|
|
129
|
+
*/
|
|
130
|
+
export function fitRow<T extends { text: string }>(
|
|
131
|
+
width: number,
|
|
132
|
+
name: string,
|
|
133
|
+
fixed: number,
|
|
134
|
+
secondary: readonly T[],
|
|
135
|
+
gap = 2,
|
|
136
|
+
): { name: string; secondary: T[] } {
|
|
137
|
+
const nameOut = truncate(name, Math.max(1, width - fixed));
|
|
138
|
+
let used = displayWidth(nameOut) + fixed;
|
|
139
|
+
const out: T[] = [];
|
|
140
|
+
for (const part of secondary) {
|
|
141
|
+
const need = gap + displayWidth(part.text);
|
|
142
|
+
if (used + need <= width) {
|
|
143
|
+
out.push(part);
|
|
144
|
+
used += need;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const room = width - used - gap;
|
|
148
|
+
if (room >= MIN_CUT) out.push({ ...part, text: truncate(part.text, room) });
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
return { name: nameOut, secondary: out };
|
|
152
|
+
}
|
|
153
|
+
|
|
38
154
|
/** "1 plugin", "3 plugins". */
|
|
39
155
|
export function plural(n: number, word: string): string {
|
|
40
156
|
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
@@ -73,6 +189,8 @@ export type MarketplaceItem =
|
|
|
73
189
|
skills: readonly string[];
|
|
74
190
|
installs: ScopeVersions;
|
|
75
191
|
expanded: boolean;
|
|
192
|
+
/** Its skills edited here, which `p` pushes as one new version. */
|
|
193
|
+
edited: readonly string[];
|
|
76
194
|
}
|
|
77
195
|
| {
|
|
78
196
|
kind: "skill";
|
|
@@ -119,6 +237,7 @@ export function marketplaceItems(
|
|
|
119
237
|
skills: p.skills,
|
|
120
238
|
installs: installs[ref] ?? NO_INSTALLS,
|
|
121
239
|
expanded: open,
|
|
240
|
+
edited: editedSkills(status, ref),
|
|
122
241
|
});
|
|
123
242
|
if (!open) continue;
|
|
124
243
|
for (const skill of p.skills) {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
FailureRow,
|
|
16
16
|
PluginContentsEntry,
|
|
17
17
|
} from "../state/types.js";
|
|
18
|
+
import { truncate } from "../text-width.js";
|
|
18
19
|
import type { BadgeTone } from "../theme.js";
|
|
19
20
|
import {
|
|
20
21
|
failureReport,
|
|
@@ -215,6 +216,12 @@ function pushPluginItems(
|
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The widest company name a group header shows. The header keeps its badge
|
|
221
|
+
* whole and clips the title instead, so a long name is clipped here first.
|
|
222
|
+
*/
|
|
223
|
+
const COMPANY_BADGE_COLUMNS = 24;
|
|
224
|
+
|
|
218
225
|
/**
|
|
219
226
|
* Derives tone and badge for a category item based on marketplace identity.
|
|
220
227
|
*/
|
|
@@ -244,7 +251,13 @@ function categoryStyling(
|
|
|
244
251
|
// MadAppGang, and being this machine's company marketplace is the stronger
|
|
245
252
|
// fact.
|
|
246
253
|
if (mp.company) {
|
|
247
|
-
|
|
254
|
+
const company = companyName(mp);
|
|
255
|
+
return {
|
|
256
|
+
tone: "info",
|
|
257
|
+
badge: company
|
|
258
|
+
? `by ${truncate(company, COMPANY_BADGE_COLUMNS)}`
|
|
259
|
+
: "◆ Company",
|
|
260
|
+
};
|
|
248
261
|
}
|
|
249
262
|
if (isMadAppGang(mp)) {
|
|
250
263
|
return { tone: "byline", badge: "by MadAppGang" };
|
|
@@ -262,6 +275,17 @@ function isMadAppGang(mp: Marketplace): boolean {
|
|
|
262
275
|
return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
/**
|
|
279
|
+
* The name a company marketplace is shown by: the manifest's `owner.name`,
|
|
280
|
+
* else the GitHub owner of its repository.
|
|
281
|
+
*/
|
|
282
|
+
export function companyName(mp: Marketplace): string | undefined {
|
|
283
|
+
if (mp.ownerName) return mp.ownerName;
|
|
284
|
+
const repo = mp.source.repo ?? "";
|
|
285
|
+
const slash = repo.indexOf("/");
|
|
286
|
+
return slash > 0 ? repo.slice(0, slash) : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
265
289
|
// ─── "It failed, and here is why" ────────────────────────────────────────────
|
|
266
290
|
|
|
267
291
|
/** A version token — `v2.0.3`, `8.1.3`, `1.0.0-beta.2`. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { displayWidth, truncate } from "../../text-width.js";
|
|
2
3
|
import { type BadgeTone, theme } from "../../theme.js";
|
|
3
4
|
import { SelectableRow, rowLead } from "./SelectableRow.js";
|
|
4
5
|
|
|
@@ -17,6 +18,12 @@ interface ListCategoryRowProps {
|
|
|
17
18
|
* that is broken rather than as a section title.
|
|
18
19
|
*/
|
|
19
20
|
expandable?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Columns the row may use. When given, the title and count are cut with
|
|
23
|
+
* "…" to fit one line (the badge goes first), so a narrow panel never
|
|
24
|
+
* wraps the heading onto a second row.
|
|
25
|
+
*/
|
|
26
|
+
width?: number;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function ListCategoryRow({
|
|
@@ -27,10 +34,21 @@ export function ListCategoryRow({
|
|
|
27
34
|
tone = "neutral",
|
|
28
35
|
selected,
|
|
29
36
|
expandable = true,
|
|
37
|
+
width,
|
|
30
38
|
}: ListCategoryRowProps) {
|
|
31
39
|
const colors = theme.badge[tone];
|
|
32
40
|
const marker = expandable ? (expanded ? "▼ " : "▶ ") : "";
|
|
33
|
-
const
|
|
41
|
+
const full = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
|
|
42
|
+
// The lead cell and the trailing space inside the chip take two columns.
|
|
43
|
+
const label =
|
|
44
|
+
width === undefined ? full : truncate(full, Math.max(1, width - 2));
|
|
45
|
+
const shownBadge =
|
|
46
|
+
width === undefined ||
|
|
47
|
+
(badge !== undefined &&
|
|
48
|
+
label === full &&
|
|
49
|
+
displayWidth(full) + 3 + displayWidth(badge) <= width)
|
|
50
|
+
? badge
|
|
51
|
+
: undefined;
|
|
34
52
|
|
|
35
53
|
// Both forms start with the row's lead cell — a blank inside the chip, the
|
|
36
54
|
// selection mark on the cursor — so the title never moves as it is selected.
|
|
@@ -50,7 +68,9 @@ export function ListCategoryRow({
|
|
|
50
68
|
{label}{" "}
|
|
51
69
|
</strong>
|
|
52
70
|
)}
|
|
53
|
-
{!selected &&
|
|
71
|
+
{!selected && shownBadge ? (
|
|
72
|
+
<span fg={colors.badgeFg}> {shownBadge}</span>
|
|
73
|
+
) : null}
|
|
54
74
|
</SelectableRow>
|
|
55
75
|
);
|
|
56
76
|
}
|