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
@@ -1,8 +1,12 @@
1
- import type React from "react";
1
+ import React from "react";
2
2
  import {
3
3
  type MarketplacesItem,
4
+ fitRow,
5
+ localKindWords,
4
6
  marketplacesItems,
7
+ nextSelectable,
5
8
  plural,
9
+ selectable,
6
10
  } from "../adapters/marketAdapter.js";
7
11
  import { ScrollableList } from "../components/ScrollableList.js";
8
12
  import { ScreenLayout } from "../components/layout/index.js";
@@ -18,6 +22,7 @@ import { type KeyHandler, moveDirection, runBindings } from "../keymap.js";
18
22
  import { useModal } from "../state/AppContext.js";
19
23
  import { useDimensions } from "../state/DimensionsContext.js";
20
24
  import { asyncValue } from "../state/types.js";
25
+ import { truncate } from "../text-width.js";
21
26
  import { MarketplacesDetail } from "./MarketplacesDetail.snowflake.js";
22
27
  import {
23
28
  MARKETPLACES_APP_KEYS,
@@ -31,37 +36,99 @@ import type { MarketState } from "./useMarketState.js";
31
36
  /** Rows above the list: the heading. */
32
37
  const HEADING_ROWS = 1;
33
38
 
34
- function renderRow(
39
+ /** Cells a row starts in, the lead cell included. */
40
+ const ROW_INDENT = 2;
41
+
42
+ type Part = { text: string; tone: "muted" | "info" | "warning" };
43
+
44
+ /** The secondary texts after a row's name and badge, cut first when narrow. */
45
+ function Secondary({
46
+ parts,
47
+ selected,
48
+ }: {
49
+ parts: readonly Part[];
50
+ selected: boolean;
51
+ }) {
52
+ return (
53
+ <>
54
+ {parts.map((p) => (
55
+ <React.Fragment key={p.text}>
56
+ {" "}
57
+ <MetaText text={p.text} tone={p.tone} selected={selected} />
58
+ </React.Fragment>
59
+ ))}
60
+ </>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * One row of screen 1 in `width` columns, never wrapped: the name and the
66
+ * scope badge always show, the rest is cut or dropped from the end.
67
+ */
68
+ export function renderMarketplacesRow(
35
69
  item: MarketplacesItem,
36
- _index: number,
37
70
  selected: boolean,
71
+ width: number,
38
72
  ): React.ReactNode {
73
+ const room = Math.max(1, width - ROW_INDENT);
39
74
  if (item.kind === "create" || item.kind === "connect") {
40
75
  return (
41
- <SelectableRow selected={selected} variant="action" indent={2}>
42
- {item.kind === "create" ? "+ Create…" : "+ Connect…"}
76
+ <SelectableRow selected={selected} variant="action" indent={ROW_INDENT}>
77
+ {truncate(item.kind === "create" ? "+ Create…" : "+ Connect…", room)}
43
78
  </SelectableRow>
44
79
  );
45
80
  }
46
- const { entry } = item;
47
- return (
48
- <SelectableRow selected={selected} indent={2}>
49
- <strong>{entry.name}</strong>
50
- {" "}
51
- <MetaText text={entry.repo} tone="info" selected={selected} />
52
- {" "}
53
- <MetaText
54
- text={plural(entry.plugins.length, "plugin")}
55
- selected={selected}
81
+ if (item.kind === "local-heading") {
82
+ return (
83
+ <ListCategoryRow
84
+ title="Here, in no marketplace"
85
+ count={item.count}
86
+ selected={false}
87
+ expandable={false}
88
+ width={width}
56
89
  />
90
+ );
91
+ }
92
+ if (item.kind === "local") {
93
+ const scope = item.item.scope === "user" ? "USER" : "PROJECT";
94
+ const fit = fitRow<Part>(room, item.item.name, scope.length + 4, [
95
+ { text: localKindWords(item.item), tone: "muted" },
96
+ ]);
97
+ return (
98
+ <SelectableRow selected={selected} indent={ROW_INDENT}>
99
+ <strong>{fit.name}</strong>
100
+ {" "}
101
+ <ScopeBadge scope={scope} selected={selected} />
102
+ <Secondary parts={fit.secondary} selected={selected} />
103
+ </SelectableRow>
104
+ );
105
+ }
106
+ const { entry, toPush } = item;
107
+ const parts: Part[] = [
108
+ ...(entry.company
109
+ ? [{ text: `by ${entry.company}`, tone: "muted" as const }]
110
+ : []),
111
+ ...(toPush.length > 0
112
+ ? [
113
+ {
114
+ text: `● ${plural(toPush.length, "plugin")} to push`,
115
+ tone: "warning" as const,
116
+ },
117
+ ]
118
+ : []),
119
+ ...(entry.freshness === "stale"
120
+ ? [{ text: "⚠ not current", tone: "warning" as const }]
121
+ : []),
122
+ { text: plural(entry.plugins.length, "plugin"), tone: "muted" },
123
+ { text: entry.repo, tone: "info" },
124
+ ];
125
+ const fit = fitRow(room, entry.name, "USER".length + 4, parts);
126
+ return (
127
+ <SelectableRow selected={selected} indent={ROW_INDENT}>
128
+ <strong>{fit.name}</strong>
57
129
  {" "}
58
130
  <ScopeBadge scope="USER" selected={selected} />
59
- {entry.freshness === "stale" ? (
60
- <>
61
- {" "}
62
- <MetaText text="⚠ not current" tone="warning" selected={selected} />
63
- </>
64
- ) : null}
131
+ <Secondary parts={fit.secondary} selected={selected} />
65
132
  </SelectableRow>
66
133
  );
67
134
  }
@@ -77,8 +144,15 @@ export function MarketplacesScreen({
77
144
  const modal = useModal();
78
145
  const dimensions = useDimensions();
79
146
  const value = asyncValue(market.data);
80
- const items = marketplacesItems(value?.marketplaces ?? []);
81
- const index = Math.min(market.selected.marketplaces, items.length - 1);
147
+ const items = marketplacesItems(
148
+ value?.marketplaces ?? [],
149
+ value?.status ?? [],
150
+ value?.local ?? [],
151
+ );
152
+ const clamped = Math.min(market.selected.marketplaces, items.length - 1);
153
+ const index = selectable(items[clamped])
154
+ ? clamped
155
+ : nextSelectable(items, clamped, -1);
82
156
  const item = items[index];
83
157
 
84
158
  const select = (next: number) =>
@@ -92,6 +166,10 @@ export function MarketplacesScreen({
92
166
  if (item?.kind === "marketplace") market.open(item.entry.name);
93
167
  else if (item?.kind === "create") void market.create();
94
168
  else if (item?.kind === "connect") void market.connect();
169
+ else if (item?.kind === "local") void market.addLocal(item.item);
170
+ },
171
+ add: () => {
172
+ if (item?.kind === "local") void market.addLocal(item.item);
95
173
  },
96
174
  create: () => void market.create(),
97
175
  connect: () => void market.connect(),
@@ -99,7 +177,7 @@ export function MarketplacesScreen({
99
177
  if (item?.kind === "marketplace") void market.remove(item.entry.name);
100
178
  },
101
179
  fetch: () => void market.fetch(),
102
- move: (key) => select(index + moveDirection(key)),
180
+ move: (key) => select(nextSelectable(items, index, moveDirection(key))),
103
181
  };
104
182
  useKeyLayer("screen", true, (key) =>
105
183
  runBindings(MARKETPLACES_KEYS, handlers, key),
@@ -122,13 +200,13 @@ export function MarketplacesScreen({
122
200
 
123
201
  return (
124
202
  <ScreenLayout
125
- title="magus market"
203
+ title="magus edit"
126
204
  subtitle={loading ? "reading…" : "company plugin marketplaces"}
127
205
  keymap={{
128
206
  bindings: [...MARKETPLACES_KEYS, ...MARKETPLACES_APP_KEYS],
129
207
  pinned: [MARKET_HELP],
130
208
  }}
131
- listPanel={
209
+ listPanel={(width: number) =>
132
210
  failed ? (
133
211
  <ErrorNotice
134
212
  title="Could not read the company marketplaces."
@@ -142,14 +220,21 @@ export function MarketplacesScreen({
142
220
  count={count}
143
221
  selected={false}
144
222
  expandable={false}
223
+ width={width}
145
224
  />
146
225
  <ScrollableList
147
226
  items={items}
148
227
  selectedIndex={index}
149
- renderItem={renderRow}
228
+ renderItem={(it, _i, selected) =>
229
+ renderMarketplacesRow(it, selected, width)
230
+ }
150
231
  maxHeight={Math.max(1, dimensions.listPanelHeight - HEADING_ROWS)}
151
232
  getKey={(it) =>
152
- it.kind === "marketplace" ? `m:${it.entry.name}` : it.kind
233
+ it.kind === "marketplace"
234
+ ? `m:${it.entry.name}`
235
+ : it.kind === "local"
236
+ ? `l:${it.item.scope}:${it.item.name}`
237
+ : it.kind
153
238
  }
154
239
  />
155
240
  </box>
@@ -158,6 +243,8 @@ export function MarketplacesScreen({
158
243
  detailPanel={
159
244
  <MarketplacesDetail
160
245
  item={item}
246
+ canAdd={count > 0}
247
+ cwd={market.cwd}
161
248
  note={
162
249
  item?.kind === "marketplace"
163
250
  ? market.notes[item.entry.name]
@@ -166,7 +253,11 @@ export function MarketplacesScreen({
166
253
  />
167
254
  }
168
255
  detailKey={
169
- item?.kind === "marketplace" ? `m:${item.entry.name}` : item?.kind
256
+ item?.kind === "marketplace"
257
+ ? `m:${item.entry.name}`
258
+ : item?.kind === "local"
259
+ ? `l:${item.item.scope}:${item.item.name}`
260
+ : item?.kind
170
261
  }
171
262
  />
172
263
  );
@@ -3,6 +3,7 @@ import {
3
3
  DetailSection,
4
4
  KeyValueLine,
5
5
  ScopeVersionLine,
6
+ ToneLine,
6
7
  } from "../components/primitives/index.js";
7
8
 
8
9
  const SCOPE_WORDS = [
@@ -12,7 +13,8 @@ const SCOPE_WORDS = [
12
13
  ] as const;
13
14
 
14
15
  /**
15
- * The detail panel for a company plugin's row: its version, skills and installs.
16
+ * The detail panel for a company plugin's row: its version, skills, the
17
+ * skills edited here and its installs.
16
18
  *
17
19
  * A snowflake: the Marketplace screen's own panel, over the market adapter's
18
20
  * row type and that screen's words. It is composed only from library
@@ -36,6 +38,15 @@ export function PluginDetail({
36
38
  }
37
39
  />
38
40
  </DetailSection>
41
+ {item.edited.length > 0 ? (
42
+ <DetailSection title="Edited here">
43
+ <ToneLine tone="warning">● {item.edited.join(", ")}</ToneLine>
44
+ <ToneLine>p push a new version</ToneLine>
45
+ <ToneLine tone="muted">
46
+ Every edited skill goes out together, as one new version.
47
+ </ToneLine>
48
+ </DetailSection>
49
+ ) : null}
39
50
  <DetailSection title="Installed">
40
51
  {SCOPE_WORDS.map(([scope, word]) => (
41
52
  <ScopeVersionLine
@@ -33,6 +33,13 @@ export const MARKETPLACES_KEYS = [
33
33
  "Connect an existing repository as a company marketplace",
34
34
  75,
35
35
  ),
36
+ bind(
37
+ "add",
38
+ ["a"],
39
+ "add",
40
+ "Add the skill or plugin folder here to a company marketplace",
41
+ 70,
42
+ ),
36
43
  bind(
37
44
  "remove",
38
45
  ["d"],
@@ -59,7 +66,13 @@ export const MARKETPLACE_KEYS = [
59
66
  "Show or hide the plugin's skills",
60
67
  90,
61
68
  ),
62
- bind("publish", ["p"], "publish", "Publish this project's edits", 88),
69
+ bind(
70
+ "publish",
71
+ ["p"],
72
+ "publish",
73
+ "Publish edits: the skill's, or on a plugin every edited skill as one new version",
74
+ 88,
75
+ ),
63
76
  bind(
64
77
  "add",
65
78
  ["a"],
@@ -134,6 +147,16 @@ export const MARKETPLACE_APP_KEYS = [
134
147
 
135
148
  export type AppKeyId = (typeof MARKETPLACES_APP_KEYS)[number]["id"];
136
149
 
150
+ /** What the marks on screen 1 mean. */
151
+ const MARKETPLACES_LEGEND = [
152
+ { symbol: "●", meaning: "plugins with skills edited here, ready to push" },
153
+ {
154
+ symbol: "USER",
155
+ meaning:
156
+ "under Here, in no marketplace: your USER skills; PROJECT is this project's",
157
+ },
158
+ ] as const;
159
+
137
160
  /** What the glyphs on screen 2 mean. */
138
161
  const MARKETPLACE_LEGEND = [
139
162
  {
@@ -141,7 +164,10 @@ const MARKETPLACE_LEGEND = [
141
164
  meaning: "installed at that scope; the squares are user, project, local",
142
165
  },
143
166
  { symbol: "□", meaning: "not installed at that scope" },
144
- { symbol: "●", meaning: "edited here, ready to publish" },
167
+ {
168
+ symbol: "●",
169
+ meaning: "edited here, ready to publish; on a plugin, how many skills",
170
+ },
145
171
  { symbol: "◇", meaning: "edited here, origin unknown; publish asks twice" },
146
172
  { symbol: "⚠", meaning: "changed here and on GitHub; publish asks twice" },
147
173
  { symbol: "↓", meaning: "the plugin has a newer version than this copy" },
@@ -155,6 +181,7 @@ export function marketHelpSections(
155
181
  const one: HelpSection = {
156
182
  title: "Company marketplaces",
157
183
  bindings: MARKETPLACES_KEYS,
184
+ legend: MARKETPLACES_LEGEND,
158
185
  };
159
186
  const two: HelpSection = {
160
187
  title: "A marketplace",
@@ -2,7 +2,8 @@
2
2
  * The market screens' state and actions.
3
3
  *
4
4
  * Reading is read-only: the overview (`marketOverview`), this project's skill
5
- * copies (`marketStatus`) and the installs (`installedVersionAt`)
5
+ * copies (`marketStatus`), the installs (`installedVersionAt`) and the
6
+ * skills and plugin folders here that no marketplace holds (`localItems`)
6
7
  * write nothing, so opening the screens changes nothing on the machine.
7
8
  *
8
9
  * Every change is a planner from the market services, shown in a confirm
@@ -14,13 +15,16 @@
14
15
 
15
16
  import { useCallback, useEffect, useRef, useState } from "react";
16
17
  import { viewerLogin, viewerOrgs } from "../../services/github-cli.js";
18
+ import { localItems } from "../../services/market-local.js";
17
19
  import {
20
+ planPluginAdd,
18
21
  planPluginCreate,
19
22
  planPluginDelete,
20
23
  } from "../../services/market-ops-plugin.js";
21
24
  import {
22
25
  localSkillFolders,
23
26
  marketStatus,
27
+ planPluginPublish,
24
28
  planPublish,
25
29
  } from "../../services/market-ops-publish.js";
26
30
  import {
@@ -36,6 +40,8 @@ import {
36
40
  import {
37
41
  type MarketOverviewEntry,
38
42
  applyMarketPlan,
43
+ companyPluginNames,
44
+ companySkillNames,
39
45
  errorResult,
40
46
  marketFetch,
41
47
  marketOverview,
@@ -44,6 +50,7 @@ import {
44
50
  import { installedVersionAt } from "../../services/market-publish.js";
45
51
  import type {
46
52
  InstallScope,
53
+ LocalItem,
47
54
  MarketResult,
48
55
  PlanAnswer,
49
56
  SkillStatusRow,
@@ -51,10 +58,11 @@ import type {
51
58
  import {
52
59
  type ScopeVersions,
53
60
  marketFailureReport,
61
+ plural,
54
62
  resultNote,
55
63
  } from "../adapters/marketAdapter.js";
56
64
  import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
57
- import type { AsyncData } from "../state/types.js";
65
+ import { type AsyncData, asyncValue } from "../state/types.js";
58
66
 
59
67
  export type MarketRoute =
60
68
  | { screen: "marketplaces" }
@@ -66,6 +74,8 @@ export interface MarketData {
66
74
  status: SkillStatusRow[];
67
75
  /** Installed versions by `plugin@marketplace`. */
68
76
  installs: Record<string, ScopeVersions>;
77
+ /** Skills and plugin folders here that no company marketplace holds. */
78
+ local: LocalItem[];
69
79
  }
70
80
 
71
81
  const SCOPES: readonly InstallScope[] = ["user", "project", "local"];
@@ -95,11 +105,16 @@ async function readInstalls(
95
105
  /** Everything the screens draw, read without writing anything. */
96
106
  async function readMarketData(cwd: string): Promise<MarketData> {
97
107
  const marketplaces = await marketOverview();
98
- const [status, installs] = await Promise.all([
108
+ const [status, installs, local] = await Promise.all([
99
109
  marketStatus(undefined, cwd).then((r) => r.skills ?? []),
100
110
  readInstalls(marketplaces, cwd),
111
+ localItems(
112
+ cwd,
113
+ companySkillNames(marketplaces),
114
+ companyPluginNames(marketplaces),
115
+ ),
101
116
  ]);
102
- return { marketplaces, status, installs };
117
+ return { marketplaces, status, installs, local };
103
118
  }
104
119
 
105
120
  /** What a confirmed action is, for its dialog and its failure popup. */
@@ -302,6 +317,14 @@ export function useMarketState() {
302
317
  0,
303
318
  );
304
319
  if (!owner) return;
320
+ const company = (
321
+ await modal.input(
322
+ "Create a company marketplace",
323
+ 'Company name (shown as "by <name>" in magus):',
324
+ owner,
325
+ )
326
+ )?.trim();
327
+ if (!company) return;
305
328
  const repo = (
306
329
  await modal.input(
307
330
  "Create a company marketplace",
@@ -325,7 +348,7 @@ export function useMarketState() {
325
348
  title: `Create ${owner}/${repo}?`,
326
349
  subject: `${owner}/${repo}`,
327
350
  },
328
- () => planCreate({ repo: `${owner}/${repo}`, visibility }),
351
+ () => planCreate({ repo: `${owner}/${repo}`, visibility, company }),
329
352
  );
330
353
  }, [modal, runPlanned, showFailures]);
331
354
 
@@ -352,8 +375,124 @@ export function useMarketState() {
352
375
  [cwd, runPlanned],
353
376
  );
354
377
 
378
+ /**
379
+ * Add a local skill or plugin folder to a company marketplace: pick the
380
+ * marketplace (when there is more than one), then for a skill the plugin
381
+ * it goes into, a new one included. One plan, one consent.
382
+ */
383
+ const addLocal = useCallback(
384
+ async (item: LocalItem) => {
385
+ const marketplaces = asyncValue(data)?.marketplaces ?? [];
386
+ if (marketplaces.length === 0) {
387
+ await showFailures({
388
+ headline: `No company marketplace to add ${item.name} to`,
389
+ rows: [
390
+ {
391
+ subject: item.name,
392
+ reason: "this machine has no company marketplace yet",
393
+ },
394
+ ],
395
+ fix: "Press c to create one, or o to connect one, then press a again",
396
+ });
397
+ return;
398
+ }
399
+ const title = `Add ${item.name} to a company marketplace`;
400
+ const marketplace =
401
+ marketplaces.length === 1
402
+ ? marketplaces[0]?.name
403
+ : await modal.select(
404
+ title,
405
+ "Which marketplace?",
406
+ marketplaces.map((m) => ({
407
+ label: m.name,
408
+ value: m.name,
409
+ ...(m.company ? { meta: `by ${m.company}` } : {}),
410
+ })),
411
+ 0,
412
+ );
413
+ if (!marketplace) return;
414
+ if (item.kind === "plugin") {
415
+ await runPlanned(
416
+ {
417
+ title: `Add the plugin ${item.name} to ${marketplace}?`,
418
+ subject: item.name,
419
+ marketplace,
420
+ },
421
+ () => planPluginAdd({ path: item.path, to: marketplace }, cwd),
422
+ );
423
+ return;
424
+ }
425
+ const entry = marketplaces.find((m) => m.name === marketplace);
426
+ const NEW = "+new";
427
+ const choice = await modal.select(
428
+ title,
429
+ `Which plugin in ${marketplace}?`,
430
+ [
431
+ ...(entry?.plugins ?? []).map((p) => ({
432
+ label: p.name,
433
+ value: p.name,
434
+ meta: plural(p.skills.length, "skill"),
435
+ })),
436
+ { label: "+ New plugin", value: NEW },
437
+ ],
438
+ 0,
439
+ );
440
+ if (!choice) return;
441
+ let plugin = choice;
442
+ if (choice === NEW) {
443
+ const name = (
444
+ await modal.input(
445
+ `New plugin in ${marketplace}`,
446
+ `Plugin name (it is created with ${item.name} in it):`,
447
+ item.name,
448
+ )
449
+ )?.trim();
450
+ if (!name) return;
451
+ plugin = name;
452
+ }
453
+ const ref = `${plugin}@${marketplace}`;
454
+ await runPlanned(
455
+ {
456
+ title:
457
+ choice === NEW
458
+ ? `Create ${ref} with ${item.name}?`
459
+ : `Add ${item.name} to ${ref}?`,
460
+ subject: item.name,
461
+ marketplace,
462
+ },
463
+ () =>
464
+ planSkillAdd(
465
+ {
466
+ skill: item.name,
467
+ plugin: ref,
468
+ from: item.scope === "user" ? "global" : "project",
469
+ keepLocal: false,
470
+ ...(choice === NEW ? { createPlugin: true } : {}),
471
+ },
472
+ cwd,
473
+ ),
474
+ );
475
+ },
476
+ [cwd, data, modal, runPlanned, showFailures],
477
+ );
478
+
355
479
  // ─── Screen 2 actions ───────────────────────────────────────────────────
356
480
 
481
+ /** Push every skill of a plugin edited here, as one new version. */
482
+ const publishPlugin = useCallback(
483
+ async (ref: string, marketplace: string) => {
484
+ await runPlanned(
485
+ {
486
+ title: `Push a new version of ${ref}?`,
487
+ subject: ref,
488
+ marketplace,
489
+ },
490
+ () => planPluginPublish({ plugin: ref, keepLocal: false }, cwd),
491
+ );
492
+ },
493
+ [cwd, runPlanned],
494
+ );
495
+
357
496
  const newPlugin = useCallback(
358
497
  async (marketplace: string) => {
359
498
  const name = (
@@ -541,6 +680,8 @@ export function useMarketState() {
541
680
  create,
542
681
  connect,
543
682
  remove,
683
+ addLocal,
684
+ publishPlugin,
544
685
  newPlugin,
545
686
  deletePlugin,
546
687
  addSkill,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * display-path.ts — a folder as a person reads it: relative to the project
3
+ * when it is inside it, `~/…` when it is under the home folder, else as is.
4
+ * For display only; JSON and every file operation keep the absolute path.
5
+ */
6
+
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ /** `child` is `parent` itself or somewhere under it. */
11
+ function within(parent: string, child: string): string | null {
12
+ const rel = path.relative(parent, child);
13
+ if (rel === "") return "";
14
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
15
+ return rel;
16
+ }
17
+
18
+ /**
19
+ * `abs` shortened for display: `.claude/skills/greet` inside `cwd`,
20
+ * `~/.claude/skills/team-tools` under the home folder, otherwise unchanged.
21
+ * The home folder is `$HOME` when set, as Claude Code and the shell read it.
22
+ */
23
+ export function displayPath(
24
+ abs: string,
25
+ cwd: string,
26
+ home: string = process.env.HOME || os.homedir(),
27
+ ): string {
28
+ const inProject = within(path.resolve(cwd), path.resolve(abs));
29
+ if (inProject !== null) return inProject === "" ? "." : inProject;
30
+ const inHome = home ? within(path.resolve(home), path.resolve(abs)) : null;
31
+ if (inHome !== null) return inHome === "" ? "~" : `~/${inHome}`;
32
+ return abs;
33
+ }