magus-cli 7.12.2 → 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 (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,694 @@
1
+ /**
2
+ * The market screens' state and actions.
3
+ *
4
+ * Reading is read-only: the overview (`marketOverview`), this project's skill
5
+ * copies (`marketStatus`), the installs (`installedVersionAt`) and the
6
+ * skills and plugin folders here that no marketplace holds (`localItems`)
7
+ * write nothing, so opening the screens changes nothing on the machine.
8
+ *
9
+ * Every change is a planner from the market services, shown in a confirm
10
+ * dialog and applied by `applyMarketPlan` — the functions `magus market`'s
11
+ * verbs run, so the TUI and the CLI cannot do different things. `r` runs
12
+ * `marketFetch`, the one action that writes without a plan, and it writes
13
+ * only magus's own copies of the marketplaces.
14
+ */
15
+
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+ import { viewerLogin, viewerOrgs } from "../../services/github-cli.js";
18
+ import { localItems } from "../../services/market-local.js";
19
+ import {
20
+ planPluginAdd,
21
+ planPluginCreate,
22
+ planPluginDelete,
23
+ } from "../../services/market-ops-plugin.js";
24
+ import {
25
+ localSkillFolders,
26
+ marketStatus,
27
+ planPluginPublish,
28
+ planPublish,
29
+ } from "../../services/market-ops-publish.js";
30
+ import {
31
+ planConnect,
32
+ planCreate,
33
+ planRemove,
34
+ } from "../../services/market-ops-repo.js";
35
+ import {
36
+ planSkillAdd,
37
+ planSkillEdit,
38
+ planSkillRemove,
39
+ } from "../../services/market-ops-skill.js";
40
+ import {
41
+ type MarketOverviewEntry,
42
+ applyMarketPlan,
43
+ companyPluginNames,
44
+ companySkillNames,
45
+ errorResult,
46
+ marketFetch,
47
+ marketOverview,
48
+ planError,
49
+ } from "../../services/market-ops.js";
50
+ import { installedVersionAt } from "../../services/market-publish.js";
51
+ import type {
52
+ InstallScope,
53
+ LocalItem,
54
+ MarketResult,
55
+ PlanAnswer,
56
+ SkillStatusRow,
57
+ } from "../../services/market-types.js";
58
+ import {
59
+ type ScopeVersions,
60
+ marketFailureReport,
61
+ plural,
62
+ resultNote,
63
+ } from "../adapters/marketAdapter.js";
64
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
65
+ import { type AsyncData, asyncValue } from "../state/types.js";
66
+
67
+ export type MarketRoute =
68
+ | { screen: "marketplaces" }
69
+ | { screen: "marketplace"; name: string };
70
+
71
+ export interface MarketData {
72
+ marketplaces: MarketOverviewEntry[];
73
+ /** This project's and your USER skill copies that company plugins hold. */
74
+ status: SkillStatusRow[];
75
+ /** Installed versions by `plugin@marketplace`. */
76
+ installs: Record<string, ScopeVersions>;
77
+ /** Skills and plugin folders here that no company marketplace holds. */
78
+ local: LocalItem[];
79
+ }
80
+
81
+ const SCOPES: readonly InstallScope[] = ["user", "project", "local"];
82
+
83
+ async function readInstalls(
84
+ marketplaces: readonly MarketOverviewEntry[],
85
+ cwd: string,
86
+ ): Promise<Record<string, ScopeVersions>> {
87
+ const out: Record<string, ScopeVersions> = {};
88
+ for (const m of marketplaces) {
89
+ for (const p of m.plugins) {
90
+ const ref = `${p.name}@${m.name}`;
91
+ const versions: ScopeVersions = {
92
+ user: null,
93
+ project: null,
94
+ local: null,
95
+ };
96
+ for (const scope of SCOPES) {
97
+ versions[scope] = await installedVersionAt(ref, scope, cwd);
98
+ }
99
+ out[ref] = versions;
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Everything the screens draw, read without writing anything. */
106
+ async function readMarketData(cwd: string): Promise<MarketData> {
107
+ const marketplaces = await marketOverview();
108
+ const [status, installs, local] = await Promise.all([
109
+ marketStatus(undefined, cwd).then((r) => r.skills ?? []),
110
+ readInstalls(marketplaces, cwd),
111
+ localItems(
112
+ cwd,
113
+ companySkillNames(marketplaces),
114
+ companyPluginNames(marketplaces),
115
+ ),
116
+ ]);
117
+ return { marketplaces, status, installs, local };
118
+ }
119
+
120
+ /** What a confirmed action is, for its dialog and its failure popup. */
121
+ interface ActionWords {
122
+ /** The confirm dialog's title: "Publish review to review-kit@acme?". */
123
+ title: string;
124
+ /** What failed, when it does: the marketplace, plugin or skill. */
125
+ subject: string;
126
+ /** The marketplace whose rows show the result. */
127
+ marketplace?: string;
128
+ }
129
+
130
+ export function useMarketState() {
131
+ const { state } = useApp();
132
+ const cwd = state.projectPath;
133
+ const modal = useModal();
134
+ const showFailures = useFailurePopup();
135
+
136
+ const [route, setRoute] = useState<MarketRoute>({ screen: "marketplaces" });
137
+ const [data, setData] = useState<AsyncData<MarketData>>({
138
+ status: "loading",
139
+ });
140
+ const [selected, setSelected] = useState({ marketplaces: 0, marketplace: 0 });
141
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
142
+ /** The last finished change on each marketplace, as one line. */
143
+ const [notes, setNotes] = useState<Readonly<Record<string, string>>>({});
144
+ const loadSeq = useRef(0);
145
+
146
+ const reload = useCallback(async () => {
147
+ const seq = ++loadSeq.current;
148
+ setData((d) =>
149
+ d.status === "success"
150
+ ? { status: "loading", previous: d.data }
151
+ : { status: "loading" },
152
+ );
153
+ try {
154
+ const next = await readMarketData(cwd);
155
+ if (seq === loadSeq.current) setData({ status: "success", data: next });
156
+ } catch (error) {
157
+ if (seq === loadSeq.current) {
158
+ setData({
159
+ status: "error",
160
+ error: error instanceof Error ? error : new Error(String(error)),
161
+ });
162
+ }
163
+ }
164
+ }, [cwd]);
165
+
166
+ useEffect(() => {
167
+ void reload();
168
+ }, [reload]);
169
+
170
+ const note = useCallback(
171
+ (marketplace: string | undefined, line: string | null) => {
172
+ if (!marketplace || !line) return;
173
+ setNotes((n) => ({ ...n, [marketplace]: line }));
174
+ },
175
+ [],
176
+ );
177
+
178
+ /** `market fetch`, with each marketplace's progress in the loading dialog. */
179
+ const fetchAll = useCallback(
180
+ async (name?: string | readonly string[]): Promise<MarketResult> => {
181
+ modal.loading("Fetching from GitHub…");
182
+ const result = await marketFetch(name, (_step, label) =>
183
+ modal.loading(`Fetching from GitHub…\n${label}`),
184
+ );
185
+ modal.hideModal();
186
+ return result;
187
+ },
188
+ [modal],
189
+ );
190
+
191
+ /**
192
+ * Plan, confirm, apply — the TUI's face of the CLI's consent loop. A stale
193
+ * read offers to fetch and plans again; a plan that changed before it was
194
+ * applied is shown again; a forced publish asks its second question with
195
+ * No as the default. Success shows in the rows; a failure is one popup.
196
+ */
197
+ const runPlanned = useCallback(
198
+ async (words: ActionWords, plan: () => Promise<PlanAnswer>) => {
199
+ let fetched = false;
200
+ let changed = false;
201
+ for (;;) {
202
+ modal.loading("Reading the marketplace…");
203
+ let answer: PlanAnswer;
204
+ try {
205
+ answer = await plan();
206
+ } catch (error) {
207
+ answer = planError("market ?", error);
208
+ }
209
+ modal.hideModal();
210
+
211
+ if (answer.kind === "error") {
212
+ if (answer.error.code === "stale" && !fetched) {
213
+ const fetch = await modal.confirm(
214
+ "GitHub has newer commits",
215
+ `${answer.error.message}\n\nFetch them now?`,
216
+ );
217
+ if (!fetch) return;
218
+ fetched = true;
219
+ const result = await fetchAll(answer.error.marketplaces);
220
+ if (result.outcome !== "ok") {
221
+ await showFailures(marketFailureReport(result, words.subject));
222
+ await reload();
223
+ return;
224
+ }
225
+ continue;
226
+ }
227
+ await showFailures(
228
+ marketFailureReport(
229
+ errorResult(answer.command, answer.error),
230
+ words.subject,
231
+ ),
232
+ );
233
+ return;
234
+ }
235
+
236
+ if (answer.kind === "result") {
237
+ note(words.marketplace, resultNote(answer.result));
238
+ await showFailures(marketFailureReport(answer.result, words.subject));
239
+ await reload();
240
+ return;
241
+ }
242
+
243
+ const p = answer.plan;
244
+ const yes = await modal.confirm(
245
+ changed ? `The plan changed — ${words.title}` : words.title,
246
+ changed
247
+ ? "GitHub moved while this was open. This is the plan now:"
248
+ : "This is what it does:",
249
+ p.lines,
250
+ );
251
+ if (!yes) return;
252
+ if (p.secondConfirm !== undefined) {
253
+ const replace = await modal.select(
254
+ p.secondConfirm,
255
+ "The plugin's version on GitHub is replaced by this copy.",
256
+ [
257
+ { label: "No", value: "no" },
258
+ { label: "Yes, replace it", value: "yes" },
259
+ ],
260
+ 0,
261
+ );
262
+ if (replace !== "yes") return;
263
+ }
264
+
265
+ modal.loading(words.title);
266
+ const result = await applyMarketPlan(p, {
267
+ approved: p.planId,
268
+ cwd,
269
+ onStep: (_step, label) => modal.loading(`${words.title}\n${label}`),
270
+ });
271
+ modal.hideModal();
272
+ if (result.error?.code === "plan-changed") {
273
+ changed = true;
274
+ continue;
275
+ }
276
+ note(words.marketplace ?? result.marketplace, resultNote(result));
277
+ await showFailures(marketFailureReport(result, words.subject));
278
+ await reload();
279
+ return;
280
+ }
281
+ },
282
+ [cwd, fetchAll, modal, note, reload, showFailures],
283
+ );
284
+
285
+ // ─── Screen 1 actions ───────────────────────────────────────────────────
286
+
287
+ const fetch = useCallback(async () => {
288
+ const result = await fetchAll();
289
+ await showFailures(marketFailureReport(result, "company marketplaces"));
290
+ await reload();
291
+ }, [fetchAll, reload, showFailures]);
292
+
293
+ const create = useCallback(async () => {
294
+ modal.loading("Asking GitHub who you are…");
295
+ const [login, orgs] = await Promise.all([viewerLogin(), viewerOrgs()]);
296
+ modal.hideModal();
297
+ if (!login.ok) {
298
+ await showFailures({
299
+ headline: "Could not create a company marketplace",
300
+ rows: [{ subject: "GitHub", reason: login.message }],
301
+ fix:
302
+ login.failure === "gh-missing"
303
+ ? "Install the GitHub CLI (gh), then press c again"
304
+ : "Run gh auth login, then press c again",
305
+ });
306
+ return;
307
+ }
308
+ const owners = [login.value, ...(orgs.ok ? orgs.value : [])];
309
+ const owner = await modal.select(
310
+ "Create a company marketplace",
311
+ "Which GitHub account owns the repository?",
312
+ owners.map((o, i) => ({
313
+ label: o,
314
+ value: o,
315
+ ...(i === 0 ? { meta: "you" } : {}),
316
+ })),
317
+ 0,
318
+ );
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;
328
+ const repo = (
329
+ await modal.input(
330
+ "Create a company marketplace",
331
+ `Repository name under ${owner}:`,
332
+ "claude-market",
333
+ )
334
+ )?.trim();
335
+ if (!repo) return;
336
+ const visibility = await modal.select(
337
+ "Create a company marketplace",
338
+ `Who can see ${owner}/${repo}?`,
339
+ [
340
+ { label: "Private", value: "private", meta: "you and who you add" },
341
+ { label: "Public", value: "public", meta: "everyone" },
342
+ ],
343
+ 0,
344
+ );
345
+ if (visibility !== "private" && visibility !== "public") return;
346
+ await runPlanned(
347
+ {
348
+ title: `Create ${owner}/${repo}?`,
349
+ subject: `${owner}/${repo}`,
350
+ },
351
+ () => planCreate({ repo: `${owner}/${repo}`, visibility, company }),
352
+ );
353
+ }, [modal, runPlanned, showFailures]);
354
+
355
+ const connect = useCallback(async () => {
356
+ const source = (
357
+ await modal.input(
358
+ "Connect a company marketplace",
359
+ "Repository (owner/repo, a git URL, or a folder):",
360
+ )
361
+ )?.trim();
362
+ if (!source) return;
363
+ await runPlanned({ title: `Connect ${source}?`, subject: source }, () =>
364
+ planConnect({ source }, cwd),
365
+ );
366
+ }, [cwd, modal, runPlanned]);
367
+
368
+ const remove = useCallback(
369
+ async (name: string) => {
370
+ await runPlanned(
371
+ { title: `Remove ${name} from this machine?`, subject: name },
372
+ () => planRemove(name, cwd),
373
+ );
374
+ },
375
+ [cwd, runPlanned],
376
+ );
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
+
479
+ // ─── Screen 2 actions ───────────────────────────────────────────────────
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
+
496
+ const newPlugin = useCallback(
497
+ async (marketplace: string) => {
498
+ const name = (
499
+ await modal.input(`New plugin in ${marketplace}`, "Plugin name:")
500
+ )?.trim();
501
+ if (!name) return;
502
+ const description = (
503
+ await modal.input(
504
+ `New plugin in ${marketplace}`,
505
+ "What it does (Enter to leave it out):",
506
+ )
507
+ )?.trim();
508
+ if (description === undefined) return;
509
+ const ref = `${name}@${marketplace}`;
510
+ await runPlanned(
511
+ { title: `Create ${ref}?`, subject: ref, marketplace },
512
+ () => planPluginCreate(ref, description || undefined),
513
+ );
514
+ },
515
+ [modal, runPlanned],
516
+ );
517
+
518
+ const deletePlugin = useCallback(
519
+ async (ref: string, marketplace: string) => {
520
+ await runPlanned(
521
+ { title: `Delete ${ref}?`, subject: ref, marketplace },
522
+ () => planPluginDelete(ref, cwd),
523
+ );
524
+ },
525
+ [cwd, runPlanned],
526
+ );
527
+
528
+ const addSkill = useCallback(
529
+ async (ref: string, marketplace: string) => {
530
+ const folders = localSkillFolders(cwd);
531
+ if (folders.length === 0) {
532
+ await showFailures({
533
+ headline: `No skill to add to ${ref}`,
534
+ rows: [
535
+ {
536
+ subject: "skills",
537
+ reason: "this project and your USER skills hold none",
538
+ },
539
+ ],
540
+ fix: "Create the skill in this project first, then press a again",
541
+ });
542
+ return;
543
+ }
544
+ const choice = await modal.select(
545
+ `Add a skill to ${ref}`,
546
+ "Which skill?",
547
+ folders.map((f) => ({
548
+ label: f.skill,
549
+ value: `${f.from}:${f.skill}`,
550
+ meta: f.from === "user" ? "USER" : "PROJECT",
551
+ })),
552
+ 0,
553
+ );
554
+ if (!choice) return;
555
+ const at = choice.indexOf(":");
556
+ const from = choice.slice(0, at) === "user" ? "global" : "project";
557
+ const skill = choice.slice(at + 1);
558
+ await runPlanned(
559
+ { title: `Add ${skill} to ${ref}?`, subject: skill, marketplace },
560
+ () => planSkillAdd({ skill, plugin: ref, from, keepLocal: false }, cwd),
561
+ );
562
+ },
563
+ [cwd, modal, runPlanned, showFailures],
564
+ );
565
+
566
+ const editSkill = useCallback(
567
+ async (skill: string, ref: string, marketplace: string) => {
568
+ await runPlanned(
569
+ {
570
+ title: `Copy ${skill} into this project to edit?`,
571
+ subject: skill,
572
+ marketplace,
573
+ },
574
+ () => planSkillEdit({ skill, plugin: ref, to: "project" }, cwd),
575
+ );
576
+ },
577
+ [cwd, runPlanned],
578
+ );
579
+
580
+ const removeSkill = useCallback(
581
+ async (skill: string, ref: string, marketplace: string) => {
582
+ await runPlanned(
583
+ { title: `Remove ${skill} from ${ref}?`, subject: skill, marketplace },
584
+ () => planSkillRemove({ skill, plugin: ref }, cwd),
585
+ );
586
+ },
587
+ [cwd, runPlanned],
588
+ );
589
+
590
+ const publish = useCallback(
591
+ async (
592
+ skill: string,
593
+ ref: string,
594
+ marketplace: string,
595
+ local: SkillStatusRow,
596
+ ) => {
597
+ if (local.state === "not-checked") {
598
+ const result = await fetchAll(marketplace);
599
+ if (result.outcome !== "ok") {
600
+ await showFailures(marketFailureReport(result, marketplace));
601
+ }
602
+ await reload();
603
+ return;
604
+ }
605
+ const plan = (force: boolean) =>
606
+ planPublish(
607
+ {
608
+ skill,
609
+ plugin: ref,
610
+ from: local.from === "user" ? "global" : "project",
611
+ keepLocal: false,
612
+ force,
613
+ },
614
+ cwd,
615
+ );
616
+ const force = local.state === "no-base" || local.state === "conflict";
617
+ await runPlanned(
618
+ { title: `Publish ${skill} to ${ref}?`, subject: skill, marketplace },
619
+ async () => {
620
+ if (force) return plan(true);
621
+ // A row several plugins claim has no comparison state until a
622
+ // plugin is chosen: compared with this one it can need the
623
+ // replacing publish, which asks twice. The planner answers that
624
+ // refusal as a failed result, not as an error.
625
+ const answer = await plan(false);
626
+ const refused =
627
+ answer.kind === "result" ? answer.result.error?.code : undefined;
628
+ if (
629
+ local.state === "ambiguous" &&
630
+ (refused === "no-base" || refused === "conflict")
631
+ ) {
632
+ return plan(true);
633
+ }
634
+ return answer;
635
+ },
636
+ );
637
+ },
638
+ [cwd, fetchAll, reload, runPlanned, showFailures],
639
+ );
640
+
641
+ const fetchOne = useCallback(
642
+ async (marketplace: string) => {
643
+ const result = await fetchAll(marketplace);
644
+ await showFailures(marketFailureReport(result, marketplace));
645
+ await reload();
646
+ },
647
+ [fetchAll, reload, showFailures],
648
+ );
649
+
650
+ const toggle = useCallback((ref: string) => {
651
+ setExpanded((open) => {
652
+ const next = new Set(open);
653
+ if (next.has(ref)) next.delete(ref);
654
+ else next.add(ref);
655
+ return next;
656
+ });
657
+ }, []);
658
+
659
+ const open = useCallback((name: string) => {
660
+ setRoute({ screen: "marketplace", name });
661
+ setSelected((s) => ({ ...s, marketplace: 0 }));
662
+ }, []);
663
+
664
+ const back = useCallback(() => setRoute({ screen: "marketplaces" }), []);
665
+
666
+ return {
667
+ cwd,
668
+ route,
669
+ data,
670
+ selected,
671
+ setSelected,
672
+ expanded,
673
+ notes,
674
+ reload,
675
+ open,
676
+ back,
677
+ toggle,
678
+ fetch,
679
+ fetchOne,
680
+ create,
681
+ connect,
682
+ remove,
683
+ addLocal,
684
+ publishPlugin,
685
+ newPlugin,
686
+ deletePlugin,
687
+ addSkill,
688
+ editSkill,
689
+ removeSkill,
690
+ publish,
691
+ };
692
+ }
693
+
694
+ export type MarketState = ReturnType<typeof useMarketState>;