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.
Files changed (43) 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/cli/market-view.ts +59 -18
  17. package/src/cli/market.ts +128 -44
  18. package/src/cli/router.ts +42 -10
  19. package/src/data/marketplaces.ts +1 -0
  20. package/src/main.tsx +1 -1
  21. package/src/market-tui.tsx +1 -1
  22. package/src/services/local-marketplace.ts +8 -0
  23. package/src/services/market-change.ts +6 -6
  24. package/src/services/market-local.ts +93 -0
  25. package/src/services/market-ops-plugin.ts +366 -47
  26. package/src/services/market-ops-publish.ts +363 -15
  27. package/src/services/market-ops-repo.ts +94 -13
  28. package/src/services/market-ops-skill.ts +93 -11
  29. package/src/services/market-ops.ts +72 -8
  30. package/src/services/market-publish.ts +10 -1
  31. package/src/services/market-types.ts +23 -0
  32. package/src/tui-runtime.tsx +2 -2
  33. package/src/types/index.ts +2 -0
  34. package/src/ui/adapters/marketAdapter.ts +123 -4
  35. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  36. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  37. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  38. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  39. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  40. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  41. package/src/ui/market/marketKeymap.ts +29 -2
  42. package/src/ui/market/useMarketState.ts +146 -5
  43. 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 view = await readPlugin(args.plugin);
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 } = bumpFrom(view);
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
- ...versionFiles(view),
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: [skillDir, ...versionFiles(view).map((f) => f.path)],
323
+ stage: layout
324
+ ? [MANIFEST, dir]
325
+ : [skillDir, ...versionFiles(view).map((f) => f.path)],
256
326
  files,
257
- message: `${plugin}: add ${args.skill} skill (${to})`,
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: `Adds the skill \`${args.skill}\` to \`${plugin}\` and bumps it to ${to}.`,
260
- verify: { dir: skillDir, files: sets.published },
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
- `Adds the skill ${args.skill} from ${where} to ${pluginId}`,
300
- versionLine(plugin, from, to),
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
- /** `market list`: every company marketplace here with its plugins. Read-only. */
447
- export async function marketList(): Promise<MarketResult> {
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
- label: string,
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
- entry.plugins = await pluginsAt(state.snapshot, m.name);
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
- entry.plugins = rowFor(m).plugins;
609
+ fromSnapshot = false;
549
610
  }
550
- } else {
551
- entry.plugins = rowFor(m).plugins;
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 shape = excludedByShape(item.path, item.symlink);
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[];
@@ -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` (bare
5
- * `magus market`) each hand `mountTui` the React tree they draw.
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/*`.
@@ -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
- | { kind: "marketplace"; entry: MarketOverviewEntry }
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
- /** The marketplaces, then the two rows that add one. */
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) => ({ kind: "marketplace" as const, 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
- return { tone: "info", badge: "◆ Company" };
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 label = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
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 && badge ? <span fg={colors.badgeFg}> {badge}</span> : null}
71
+ {!selected && shownBadge ? (
72
+ <span fg={colors.badgeFg}> {shownBadge}</span>
73
+ ) : null}
54
74
  </SelectableRow>
55
75
  );
56
76
  }