magus-cli 7.13.0 → 7.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +7 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  4. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  5. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  6. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  7. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  8. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  9. package/src/__tests__/market-r2-routing.test.ts +127 -0
  10. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  11. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  12. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  13. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  14. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  15. package/src/__tests__/market-router.test.ts +4 -4
  16. package/src/__tests__/scope-uninstall-wrong-scope.test.ts +341 -0
  17. package/src/cli/market-view.ts +59 -18
  18. package/src/cli/market.ts +128 -44
  19. package/src/cli/router.ts +42 -10
  20. package/src/data/marketplaces.ts +1 -0
  21. package/src/main.tsx +1 -1
  22. package/src/market-tui.tsx +1 -1
  23. package/src/services/claude-cli.ts +13 -0
  24. package/src/services/local-marketplace.ts +8 -0
  25. package/src/services/market-change.ts +6 -6
  26. package/src/services/market-local.ts +93 -0
  27. package/src/services/market-ops-plugin.ts +366 -47
  28. package/src/services/market-ops-publish.ts +363 -15
  29. package/src/services/market-ops-repo.ts +94 -13
  30. package/src/services/market-ops-skill.ts +93 -11
  31. package/src/services/market-ops.ts +72 -8
  32. package/src/services/market-publish.ts +10 -1
  33. package/src/services/market-types.ts +23 -0
  34. package/src/services/update-engine.ts +97 -1
  35. package/src/tui-runtime.tsx +2 -2
  36. package/src/types/index.ts +2 -0
  37. package/src/ui/adapters/marketAdapter.ts +123 -4
  38. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  40. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  41. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  42. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  43. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  44. package/src/ui/market/marketKeymap.ts +29 -2
  45. package/src/ui/market/useMarketState.ts +146 -5
  46. package/src/utils/display-path.ts +33 -0
@@ -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
  }
@@ -1,6 +1,7 @@
1
- import type React from "react";
1
+ import React from "react";
2
2
  import {
3
3
  type MarketplaceItem,
4
+ fitRow,
4
5
  freshnessWords,
5
6
  marketplaceItems,
6
7
  plural,
@@ -40,12 +41,36 @@ const SKILL_INDENT = 6;
40
41
  /** Cells a plugin row starts in. */
41
42
  const PLUGIN_INDENT = 2;
42
43
 
43
- function renderRow(
44
+ /** Columns the scope squares and the ▶ marker take before a plugin's name. */
45
+ const PLUGIN_PREFIX = 6;
46
+
47
+ /**
48
+ * One row of screen 2 in `width` columns, never wrapped: the name always
49
+ * shows; the version, the skill count and the marks are cut from the end.
50
+ */
51
+ export function renderMarketplaceRow(
44
52
  item: MarketplaceItem,
45
- _index: number,
46
53
  selected: boolean,
54
+ width: number,
47
55
  ): React.ReactNode {
48
56
  if (item.kind === "plugin") {
57
+ const fit = fitRow(
58
+ Math.max(1, width - PLUGIN_INDENT),
59
+ item.name,
60
+ PLUGIN_PREFIX,
61
+ [
62
+ { text: item.version, tone: "muted" as const },
63
+ { text: plural(item.skills.length, "skill"), tone: "muted" as const },
64
+ ...(item.edited.length > 0
65
+ ? [
66
+ {
67
+ text: `● ${plural(item.edited.length, "skill")} edited here`,
68
+ tone: "warning" as const,
69
+ },
70
+ ]
71
+ : []),
72
+ ],
73
+ );
49
74
  return (
50
75
  <SelectableRow selected={selected} indent={PLUGIN_INDENT}>
51
76
  <ScopeSquares
@@ -55,31 +80,32 @@ function renderRow(
55
80
  selected={selected}
56
81
  />
57
82
  {item.expanded ? " ▼ " : " ▶ "}
58
- <strong>{item.name}</strong>
59
- {" "}
60
- <MetaText text={item.version} selected={selected} />
61
- {" "}
62
- <MetaText
63
- text={plural(item.skills.length, "skill")}
64
- selected={selected}
65
- />
83
+ <strong>{fit.name}</strong>
84
+ {fit.secondary.map((p) => (
85
+ <React.Fragment key={p.text}>
86
+ {" "}
87
+ <MetaText text={p.text} tone={p.tone} selected={selected} />
88
+ </React.Fragment>
89
+ ))}
66
90
  </SelectableRow>
67
91
  );
68
92
  }
69
93
  const mark = skillMark(item.local);
94
+ const fit = fitRow(
95
+ Math.max(1, width - SKILL_INDENT),
96
+ item.skill,
97
+ 0,
98
+ mark ? [{ text: `${mark.glyph} ${mark.words}`, tone: mark.tone }] : [],
99
+ );
70
100
  return (
71
101
  <SelectableRow selected={selected} indent={SKILL_INDENT}>
72
- {item.skill}
73
- {mark ? (
74
- <>
102
+ {fit.name}
103
+ {fit.secondary.map((p) => (
104
+ <React.Fragment key={p.text}>
75
105
  {" "}
76
- <MetaText
77
- text={`${mark.glyph} ${mark.words}`}
78
- tone={mark.tone}
79
- selected={selected}
80
- />
81
- </>
82
- ) : null}
106
+ <MetaText text={p.text} tone={p.tone} selected={selected} />
107
+ </React.Fragment>
108
+ ))}
83
109
  </SelectableRow>
84
110
  );
85
111
  }
@@ -125,6 +151,8 @@ export function MarketplaceScreen({
125
151
  publish: () => {
126
152
  if (item?.kind === "skill" && item.local) {
127
153
  void market.publish(item.skill, item.ref, name, item.local);
154
+ } else if (item?.kind === "plugin") {
155
+ void market.publishPlugin(item.ref, name);
128
156
  }
129
157
  },
130
158
  add: () => {
@@ -167,7 +195,7 @@ export function MarketplaceScreen({
167
195
 
168
196
  return (
169
197
  <ScreenLayout
170
- title={`magus market › ${name}`}
198
+ title={`magus edit › ${name}`}
171
199
  subtitle={
172
200
  market.data.status === "loading"
173
201
  ? "reading…"
@@ -186,7 +214,7 @@ export function MarketplaceScreen({
186
214
  bindings: [...MARKETPLACE_KEYS, ...MARKETPLACE_APP_KEYS],
187
215
  pinned: [MARKET_HELP],
188
216
  }}
189
- listPanel={
217
+ listPanel={(width: number) =>
190
218
  failed ? (
191
219
  <ErrorNotice
192
220
  title={`Could not read ${name}.`}
@@ -200,6 +228,7 @@ export function MarketplaceScreen({
200
228
  count={entry?.plugins.length ?? 0}
201
229
  selected={false}
202
230
  expandable={false}
231
+ width={width}
203
232
  />
204
233
  {items.length === 0 && market.data.status !== "loading" ? (
205
234
  <ToneLine tone="muted">
@@ -211,7 +240,9 @@ export function MarketplaceScreen({
211
240
  <ScrollableList
212
241
  items={items}
213
242
  selectedIndex={index}
214
- renderItem={renderRow}
243
+ renderItem={(it, _i, selected) =>
244
+ renderMarketplaceRow(it, selected, width)
245
+ }
215
246
  maxHeight={Math.max(
216
247
  1,
217
248
  dimensions.listPanelHeight - HEADING_ROWS,
@@ -1,7 +1,9 @@
1
+ import { displayPath } from "../../utils/display-path.js";
1
2
  import {
2
3
  type MarketplacesItem,
3
4
  freshnessTone,
4
5
  freshnessWords,
6
+ localKindWords,
5
7
  plural,
6
8
  } from "../adapters/marketAdapter.js";
7
9
  import {
@@ -22,12 +24,53 @@ import {
22
24
  */
23
25
  export function MarketplacesDetail({
24
26
  item,
27
+ canAdd,
28
+ cwd,
25
29
  note,
26
30
  }: {
27
31
  item: MarketplacesItem | undefined;
32
+ /** Whether there is a company marketplace to add a local row to. */
33
+ canAdd: boolean;
34
+ /** The project, against which a local folder is shown. */
35
+ cwd: string;
28
36
  note: string | undefined;
29
37
  }) {
30
- if (!item) return null;
38
+ if (!item || item.kind === "local-heading") return null;
39
+ if (item.kind === "local") {
40
+ const local = item.item;
41
+ return (
42
+ <DetailSection title={local.name}>
43
+ <KeyValueLine label="kind" value={localKindWords(local)} />
44
+ <KeyValueLine
45
+ label="scope"
46
+ value={
47
+ <ScopeBadge scope={local.scope === "user" ? "USER" : "PROJECT"} />
48
+ }
49
+ />
50
+ <KeyValueLine
51
+ label="folder"
52
+ value={<MetaText text={displayPath(local.path, cwd)} />}
53
+ />
54
+ <ToneLine>
55
+ {local.kind === "plugin"
56
+ ? "A plugin folder of your own, in no company marketplace."
57
+ : "A skill of your own, in no company plugin."}
58
+ </ToneLine>
59
+ {canAdd ? (
60
+ <ToneLine tone="muted">
61
+ {local.kind === "plugin"
62
+ ? "a adds it to a company marketplace as a new plugin; the folder stays."
63
+ : "a adds it to a plugin of a company marketplace, or to a new one."}
64
+ </ToneLine>
65
+ ) : (
66
+ <ToneLine tone="muted">
67
+ To add it, first create a company marketplace (c) or connect one
68
+ (o).
69
+ </ToneLine>
70
+ )}
71
+ </DetailSection>
72
+ );
73
+ }
31
74
  if (item.kind === "create") {
32
75
  return (
33
76
  <DetailSection title="Create a company marketplace">
@@ -36,8 +79,8 @@ export function MarketplacesDetail({
36
79
  to this machine at USER scope.
37
80
  </ToneLine>
38
81
  <ToneLine tone="muted">
39
- Enter or c asks for the owner, the name and who can see it, then shows
40
- what it will do.
82
+ Enter or c asks for the owner, the company name magus shows it by, the
83
+ repository name and who can see it, then shows what it will do.
41
84
  </ToneLine>
42
85
  </DetailSection>
43
86
  );
@@ -55,10 +98,13 @@ export function MarketplacesDetail({
55
98
  </DetailSection>
56
99
  );
57
100
  }
58
- const { entry } = item;
101
+ const { entry, toPush } = item;
59
102
  return (
60
103
  <box flexDirection="column">
61
104
  <DetailSection title={entry.name}>
105
+ {entry.company ? (
106
+ <KeyValueLine label="company" value={entry.company} />
107
+ ) : null}
62
108
  <KeyValueLine
63
109
  label="repository"
64
110
  value={<MetaText text={entry.repo} tone="info" />}
@@ -83,6 +129,16 @@ export function MarketplacesDetail({
83
129
  </ToneLine>
84
130
  ) : null}
85
131
  </DetailSection>
132
+ {toPush.length > 0 ? (
133
+ <DetailSection title="To push">
134
+ <ToneLine tone="warning">
135
+ ● {toPush.join(", ")} — skills edited here
136
+ </ToneLine>
137
+ <ToneLine tone="muted">
138
+ Enter opens the marketplace; p on a plugin pushes a new version.
139
+ </ToneLine>
140
+ </DetailSection>
141
+ ) : null}
86
142
  {entry.plugins.length > 0 ? (
87
143
  <DetailSection title="Plugins">
88
144
  {entry.plugins.map((p) => (