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
@@ -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
+ }