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,142 @@
1
+ /**
2
+ * What every magus TUI shares before its first screen: the renderer, the one
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` (`magus
5
+ * edit`) each hand `mountTui` the React tree they draw.
6
+ *
7
+ * Like those two, this module is only ever imported dynamically from
8
+ * `main.tsx`, so nothing on a non-interactive path loads `@opentui/*`.
9
+ */
10
+
11
+ import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
12
+ import { createRoot } from "@opentui/react";
13
+ import type { ReactNode } from "react";
14
+ import { installShutdown } from "./ui/runtime/shutdown.js";
15
+ import { setThemeMode } from "./ui/theme-mode.js";
16
+ import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
17
+
18
+ export interface MountTuiOptions {
19
+ /** From `--theme`, already validated by the router. */
20
+ theme?: ThemeMode;
21
+ /** The theme variables, snapshotted in main.tsx BEFORE any .env merge. */
22
+ env: ThemeEnv;
23
+ /** stdin AND stdout are TTYs. Decided by the caller. */
24
+ isInteractive: boolean;
25
+ }
26
+
27
+ /** How long the terminal may take to answer the light-or-dark probe. */
28
+ const THEME_PROBE_MS = 250;
29
+
30
+ /**
31
+ * Start a TUI drawing `render(quit)`. Never resolves — the app runs until the
32
+ * user exits, and every exit goes through the one shutdown routine.
33
+ */
34
+ export async function mountTui(
35
+ { theme, env, isInteractive }: MountTuiOptions,
36
+ render: (quit: () => void) => ReactNode,
37
+ ): Promise<void> {
38
+ // Create OpenTUI renderer (handles alternate screen buffer automatically).
39
+ //
40
+ // The background is the terminal's own, not a colour of ours: painting an
41
+ // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
42
+ // built-in default for unstyled cells is truecolor white, which is why every
43
+ // element now names an adaptive colour explicitly — see src/ui/theme.ts.
44
+ // The mouse is ON, everywhere — it carries real features now: the wheel
45
+ // scrolls the pane under the cursor, and a drag selects text from ONE pane
46
+ // at a time (terminal-native selection is a screen-wide rectangle that
47
+ // grabs both columns), OSC 52-copied on release below.
48
+ //
49
+ // This deliberately includes tmux. Two earlier revisions got this wrong in
50
+ // opposite directions: one disabled the mouse everywhere on the theory that
51
+ // capture breaks tmux-level pane clicking, the next kept it off only inside
52
+ // tmux. Both dated from when magus had no mouse features, so disabling
53
+ // cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
54
+ // to a pane application that asks for it (htop in the same session took
55
+ // clicks and wheel fine while magus sat inert), and tmux's own `mouse`
56
+ // option keeps governing pane management. Terminal-native selection stays
57
+ // reachable via Shift+drag in most terminals.
58
+ //
59
+ // enableMouseMovement (mode-1003 "report all motion") stays off — selection
60
+ // needs only button-held drag events, which button reporting delivers.
61
+ const renderer = await createCliRenderer({
62
+ backgroundColor: RGBA.defaultBackground(),
63
+ useMouse: true,
64
+ enableMouseMovement: false,
65
+ // BOTH, so the renderer registers no signal handler and never quits on
66
+ // Ctrl+C by itself: `installShutdown` below is the ONE teardown owner.
67
+ // Left at their defaults the renderer is a second owner — it destroys
68
+ // itself first on a signal and the React tree is torn down after the
69
+ // terminal was already restored (measured; see ui/runtime/shutdown.ts).
70
+ exitSignals: [],
71
+ exitOnCtrlC: false,
72
+ // OFF, so a click never gives a renderable keyboard focus. With it on, one
73
+ // click in a scrollbox made that box a second key receiver: OpenTUI hands
74
+ // every key to the focused element as well as to the KeyRouter, so arrows
75
+ // and PgUp/PgDn acted twice (measured on the test renderer: scrollTop 0 → 2
76
+ // after a click, 0 → 0 with this off). The router is the one key owner;
77
+ // InputModal focuses its own <input>, which is the only focus magus needs.
78
+ autoFocus: false,
79
+ });
80
+
81
+ // Wired immediately, before anything that can wait: theme resolution below
82
+ // can probe the terminal, and a signal or a throw landing in that window
83
+ // must restore the terminal too.
84
+ //
85
+ // SIGINT/SIGTERM/SIGHUP/… restore the terminal and exit 128 + signum; an
86
+ // uncaught exception or unhandled rejection restores it, prints the error,
87
+ // and exits 1. `shutdown(0)` is the clean quit.
88
+ const root = createRoot(renderer);
89
+ const shutdown = installShutdown(renderer, root);
90
+ const quit = (): void => {
91
+ void shutdown(0);
92
+ };
93
+
94
+ // Ctrl+C is a KEYPRESS here, not a signal: raw mode clears ISIG, so the tty
95
+ // delivers 0x03 and SIGINT is never raised. Without this listener
96
+ // (`exitOnCtrlC: false` above) Ctrl+C would do nothing at all.
97
+ //
98
+ // It is registered on the renderer before React mounts, so it runs ahead of
99
+ // every `useKeyboard` handler — and stopping propagation means no screen or
100
+ // modal ever sees the key. That is what makes Ctrl+C quit from under any
101
+ // modal, including the undismissable profile gate, and from a screen whose
102
+ // render crashed.
103
+ renderer.keyInput.on("keypress", (key) => {
104
+ if (key.ctrl && key.name === "c") {
105
+ key.stopPropagation();
106
+ quit();
107
+ }
108
+ });
109
+
110
+ // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
111
+ // terminals that block it simply ignore the sequence. Never fires where the
112
+ // mouse is off, so the tmux path pays nothing.
113
+ renderer.on("selection", (selection: { getSelectedText(): string }) => {
114
+ const text = selection?.getSelectedText() ?? "";
115
+ if (text.length > 0) renderer.copyToClipboardOSC52(text);
116
+ });
117
+
118
+ // Resolve light vs dark once: --theme, MAGUS_THEME, TERM_THEME, the OSC
119
+ // probe, COLORFGBG, dark — see src/ui/theme-resolve.ts. Only the disabled-row
120
+ // tint and the chips need this (src/ui/theme-mode.ts explains why that one
121
+ // case cannot be solved the way the rest of the palette is).
122
+ //
123
+ // The renderer sends OSC 10/11 at creation regardless: @opentui/core 0.1.107
124
+ // has no option to suppress it. So the rule "TERM_THEME skips the probe" is
125
+ // satisfied by NOT AWAITING — when steps 1-3 answer, `probe` is never called,
126
+ // `waitForThemeMode` never runs, first paint is not delayed, and
127
+ // `renderer.themeMode` is never read anywhere in magus. Any late reply the
128
+ // terminal sends is consumed by OpenTUI's own handleSequence and ignored here.
129
+ const { mode } = await resolveTheme({
130
+ flag: theme,
131
+ env,
132
+ isInteractive,
133
+ probe: () => renderer.waitForThemeMode(THEME_PROBE_MS).catch(() => null),
134
+ });
135
+ setThemeMode(mode);
136
+
137
+ root.render(render(quit));
138
+
139
+ // Wait indefinitely: every exit goes through `shutdown`, which ends the
140
+ // process itself.
141
+ await new Promise(() => {});
142
+ }
package/src/tui.tsx CHANGED
@@ -1,139 +1,22 @@
1
1
  /**
2
- * The interactive TUI: renderer creation, theme resolution, and the React root.
2
+ * The interactive TUI behind bare `magus`.
3
3
  *
4
4
  * Split out of `main.tsx` so that nothing on a non-interactive path — above all
5
5
  * `magus hook …`, which Claude Code runs on every matching tool call — has
6
6
  * to load `@opentui/*` or `src/ui/` to reach its own code. `main.tsx` imports
7
- * this module dynamically, at the last possible moment.
7
+ * this module dynamically, at the last possible moment. The renderer, the
8
+ * shutdown, Ctrl+C and the theme are `mountTui`'s (src/tui-runtime.tsx).
8
9
  */
9
10
 
10
- import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
11
- import { createRoot } from "@opentui/react";
11
+ import { type MountTuiOptions, mountTui } from "./tui-runtime.js";
12
12
  import { App } from "./ui/App.js";
13
- import { installShutdown } from "./ui/runtime/shutdown.js";
14
- import { setThemeMode } from "./ui/theme-mode.js";
15
- import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
16
13
 
17
- export interface StartTuiOptions {
18
- /** From `--theme`, already validated by the router. */
19
- theme?: ThemeMode;
20
- /** The theme variables, snapshotted in main.tsx BEFORE any .env merge. */
21
- env: ThemeEnv;
22
- /** stdin AND stdout are TTYs. Decided by the caller. */
23
- isInteractive: boolean;
24
- }
14
+ export type StartTuiOptions = MountTuiOptions;
25
15
 
26
16
  /**
27
17
  * Start the TUI. Never resolves — the app runs until the user exits.
28
18
  */
29
- export async function startTui({
30
- theme,
31
- env,
32
- isInteractive,
33
- }: StartTuiOptions): Promise<void> {
34
- // Create OpenTUI renderer (handles alternate screen buffer automatically).
35
- //
36
- // The background is the terminal's own, not a colour of ours: painting an
37
- // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
38
- // built-in default for unstyled cells is truecolor white, which is why every
39
- // element now names an adaptive colour explicitly — see src/ui/theme.ts.
40
- // The mouse is ON, everywhere — it carries real features now: the wheel
41
- // scrolls the pane under the cursor, and a drag selects text from ONE pane
42
- // at a time (terminal-native selection is a screen-wide rectangle that
43
- // grabs both columns), OSC 52-copied on release below.
44
- //
45
- // This deliberately includes tmux. Two earlier revisions got this wrong in
46
- // opposite directions: one disabled the mouse everywhere on the theory that
47
- // capture breaks tmux-level pane clicking, the next kept it off only inside
48
- // tmux. Both dated from when magus had no mouse features, so disabling
49
- // cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
50
- // to a pane application that asks for it (htop in the same session took
51
- // clicks and wheel fine while magus sat inert), and tmux's own `mouse`
52
- // option keeps governing pane management. Terminal-native selection stays
53
- // reachable via Shift+drag in most terminals.
54
- //
55
- // enableMouseMovement (mode-1003 "report all motion") stays off — selection
56
- // needs only button-held drag events, which button reporting delivers.
57
- const renderer = await createCliRenderer({
58
- backgroundColor: RGBA.defaultBackground(),
59
- useMouse: true,
60
- enableMouseMovement: false,
61
- // BOTH, so the renderer registers no signal handler and never quits on
62
- // Ctrl+C by itself: `installShutdown` below is the ONE teardown owner.
63
- // Left at their defaults the renderer is a second owner — it destroys
64
- // itself first on a signal and the React tree is torn down after the
65
- // terminal was already restored (measured; see ui/runtime/shutdown.ts).
66
- exitSignals: [],
67
- exitOnCtrlC: false,
68
- // OFF, so a click never gives a renderable keyboard focus. With it on, one
69
- // click in a scrollbox made that box a second key receiver: OpenTUI hands
70
- // every key to the focused element as well as to the KeyRouter, so arrows
71
- // and PgUp/PgDn acted twice (measured on the test renderer: scrollTop 0 → 2
72
- // after a click, 0 → 0 with this off). The router is the one key owner;
73
- // InputModal focuses its own <input>, which is the only focus magus needs.
74
- autoFocus: false,
75
- });
76
-
77
- // Wired immediately, before anything that can wait: theme resolution below
78
- // can probe the terminal for 250ms, and a signal or a throw landing in that
79
- // window must restore the terminal too.
80
- //
81
- // SIGINT/SIGTERM/SIGHUP/… restore the terminal and exit 128 + signum; an
82
- // uncaught exception or unhandled rejection restores it, prints the error,
83
- // and exits 1. `shutdown(0)` is the clean quit.
84
- const root = createRoot(renderer);
85
- const shutdown = installShutdown(renderer, root);
86
- const quit = (): void => {
87
- void shutdown(0);
88
- };
89
-
90
- // Ctrl+C is a KEYPRESS here, not a signal: raw mode clears ISIG, so the tty
91
- // delivers 0x03 and SIGINT is never raised. Without this listener
92
- // (`exitOnCtrlC: false` above) Ctrl+C would do nothing at all.
93
- //
94
- // It is registered on the renderer before React mounts, so it runs ahead of
95
- // every `useKeyboard` handler — and stopping propagation means no screen or
96
- // modal ever sees the key. That is what makes Ctrl+C quit from under any
97
- // modal, including the undismissable profile gate, and from a screen whose
98
- // render crashed.
99
- renderer.keyInput.on("keypress", (key) => {
100
- if (key.ctrl && key.name === "c") {
101
- key.stopPropagation();
102
- quit();
103
- }
104
- });
105
-
106
- // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
107
- // terminals that block it simply ignore the sequence. Never fires where the
108
- // mouse is off, so the tmux path pays nothing.
109
- renderer.on("selection", (selection: { getSelectedText(): string }) => {
110
- const text = selection?.getSelectedText() ?? "";
111
- if (text.length > 0) renderer.copyToClipboardOSC52(text);
112
- });
113
-
114
- // Resolve light vs dark once: --theme, MAGUS_THEME, TERM_THEME, the OSC
115
- // probe, COLORFGBG, dark — see src/ui/theme-resolve.ts. Only the disabled-row
116
- // tint and the chips need this (src/ui/theme-mode.ts explains why that one
117
- // case cannot be solved the way the rest of the palette is).
118
- //
119
- // The renderer sends OSC 10/11 at creation regardless: @opentui/core 0.1.107
120
- // has no option to suppress it. So the rule "TERM_THEME skips the probe" is
121
- // satisfied by NOT AWAITING — when steps 1-3 answer, `probe` is never called,
122
- // `waitForThemeMode(250)` never runs, first paint is not delayed, and
123
- // `renderer.themeMode` is never read anywhere in magus. Any late reply the
124
- // terminal sends is consumed by OpenTUI's own handleSequence and ignored here.
125
- const { mode } = await resolveTheme({
126
- flag: theme,
127
- env,
128
- isInteractive,
129
- probe: () => renderer.waitForThemeMode(250).catch(() => null),
130
- });
131
- setThemeMode(mode);
132
-
19
+ export async function startTui(opts: StartTuiOptions): Promise<void> {
133
20
  // `q`, Escape on the home tab and the profile gate's quit all end here.
134
- root.render(<App onExit={quit} />);
135
-
136
- // Wait indefinitely: every exit goes through `shutdown`, which ends the
137
- // process itself.
138
- await new Promise(() => {});
21
+ await mountTui(opts, (quit) => <App onExit={quit} />);
139
22
  }
@@ -50,6 +50,13 @@ export interface Marketplace {
50
50
  featured?: boolean; // Featured marketplaces have plugins fetched by default (like official)
51
51
  deprecated?: boolean; // Deprecated marketplaces are sorted to the bottom of the list
52
52
  owned?: boolean; // Our own marketplaces (magus*): always listed even when uninstalled, and sorted to the top
53
+ /**
54
+ * A company marketplace on this machine: its manifest carries the company
55
+ * marker and this machine trusts it. Listed above every other marketplace.
56
+ */
57
+ company?: boolean;
58
+ /** `owner.name` from the marketplace's own manifest, when it names one. */
59
+ ownerName?: string;
53
60
  }
54
61
 
55
62
  export interface DiscoveredMarketplace {
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The market screens' rows and words, built from what the market services
3
+ * read: the overview of each company marketplace, the installs of its
4
+ * plugins, and how this project's skill copies stand. Pure, so the rows are
5
+ * tested without a renderer.
6
+ */
7
+
8
+ import type {
9
+ MarketOverviewEntry,
10
+ OverviewFreshness,
11
+ } from "../../services/market-ops.js";
12
+ import type {
13
+ InstallScope,
14
+ LocalItem,
15
+ MarketResult,
16
+ SkillStatusRow,
17
+ } from "../../services/market-types.js";
18
+ import type { FailureReport, FailureRow } from "../state/types.js";
19
+ import { displayWidth, truncate } from "../text-width.js";
20
+ import type { theme } from "../theme.js";
21
+
22
+ // ─── Screen 1 ────────────────────────────────────────────────────────────────
23
+
24
+ export type MarketplacesItem =
25
+ | {
26
+ kind: "marketplace";
27
+ entry: MarketOverviewEntry;
28
+ /** Its plugins with skills edited here, ready to push. */
29
+ toPush: readonly string[];
30
+ }
31
+ | { kind: "create" }
32
+ | { kind: "connect" }
33
+ /** The heading over the local rows; never selected. */
34
+ | { kind: "local-heading"; count: number }
35
+ | { kind: "local"; item: LocalItem };
36
+
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
+ */
41
+ export function marketplacesItems(
42
+ entries: readonly MarketOverviewEntry[],
43
+ status: readonly SkillStatusRow[] = [],
44
+ local: readonly LocalItem[] = [],
45
+ ): MarketplacesItem[] {
46
+ return [
47
+ ...entries.map((entry) => ({
48
+ kind: "marketplace" as const,
49
+ entry,
50
+ toPush: pluginsToPush(entry.name, status),
51
+ })),
52
+ { kind: "create" },
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
+ : []),
60
+ ];
61
+ }
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
+
154
+ /** "1 plugin", "3 plugins". */
155
+ export function plural(n: number, word: string): string {
156
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
157
+ }
158
+
159
+ /** How current a marketplace's rows are, in one short phrase. */
160
+ export function freshnessWords(freshness: OverviewFreshness): string {
161
+ return freshness === "fresh" ? "up to date" : "not current";
162
+ }
163
+
164
+ /** The tone {@link freshnessWords} are shown in. */
165
+ export function freshnessTone(
166
+ freshness: OverviewFreshness,
167
+ ): "success" | "warning" {
168
+ return freshness === "fresh" ? "success" : "warning";
169
+ }
170
+
171
+ // ─── Screen 2 ────────────────────────────────────────────────────────────────
172
+
173
+ /** Installed versions of one plugin, by scope; null where it is not installed. */
174
+ export type ScopeVersions = Record<InstallScope, string | null>;
175
+
176
+ export const NO_INSTALLS: ScopeVersions = {
177
+ user: null,
178
+ project: null,
179
+ local: null,
180
+ };
181
+
182
+ export type MarketplaceItem =
183
+ | {
184
+ kind: "plugin";
185
+ /** `plugin@marketplace`. */
186
+ ref: string;
187
+ name: string;
188
+ version: string;
189
+ skills: readonly string[];
190
+ installs: ScopeVersions;
191
+ expanded: boolean;
192
+ /** Its skills edited here, which `p` pushes as one new version. */
193
+ edited: readonly string[];
194
+ }
195
+ | {
196
+ kind: "skill";
197
+ ref: string;
198
+ plugin: string;
199
+ skill: string;
200
+ /** This project's or your USER copy of the skill, when there is one. */
201
+ local: SkillStatusRow | null;
202
+ };
203
+
204
+ /**
205
+ * The local copy a skill row speaks for: this project's when there is one,
206
+ * else your USER copy. Only rows the plugin `ref` owns count, including a
207
+ * row several plugins hold a skill of that name for: the row names the plugin
208
+ * explicitly, so a publish from it goes to `ref`.
209
+ */
210
+ export function localCopyFor(
211
+ rows: readonly SkillStatusRow[],
212
+ ref: string,
213
+ skill: string,
214
+ ): SkillStatusRow | null {
215
+ const mine = rows.filter(
216
+ (r) => r.skill === skill && (r.owner === ref || !!r.owners?.includes(ref)),
217
+ );
218
+ return mine.find((r) => r.from === "project") ?? mine[0] ?? null;
219
+ }
220
+
221
+ /** Plugin rows, each followed by its skill rows while expanded. */
222
+ export function marketplaceItems(
223
+ entry: MarketOverviewEntry,
224
+ installs: Readonly<Record<string, ScopeVersions>>,
225
+ status: readonly SkillStatusRow[],
226
+ expanded: ReadonlySet<string>,
227
+ ): MarketplaceItem[] {
228
+ const items: MarketplaceItem[] = [];
229
+ for (const p of entry.plugins) {
230
+ const ref = `${p.name}@${entry.name}`;
231
+ const open = expanded.has(ref);
232
+ items.push({
233
+ kind: "plugin",
234
+ ref,
235
+ name: p.name,
236
+ version: p.version,
237
+ skills: p.skills,
238
+ installs: installs[ref] ?? NO_INSTALLS,
239
+ expanded: open,
240
+ edited: editedSkills(status, ref),
241
+ });
242
+ if (!open) continue;
243
+ for (const skill of p.skills) {
244
+ items.push({
245
+ kind: "skill",
246
+ ref,
247
+ plugin: p.name,
248
+ skill,
249
+ local: localCopyFor(status, ref, skill),
250
+ });
251
+ }
252
+ }
253
+ return items;
254
+ }
255
+
256
+ type MetaTone = keyof typeof theme.meta;
257
+
258
+ export interface SkillMark {
259
+ glyph: string;
260
+ words: string;
261
+ tone: MetaTone;
262
+ }
263
+
264
+ /** How a local copy stands, as a skill row draws it; null when it needs nothing. */
265
+ export function skillMark(row: SkillStatusRow | null): SkillMark | null {
266
+ switch (row?.state) {
267
+ case "edited":
268
+ return { glyph: "●", words: "edited here", tone: "warning" };
269
+ case "no-base":
270
+ return {
271
+ glyph: "◇",
272
+ words: "edited here, origin unknown",
273
+ tone: "warning",
274
+ };
275
+ case "conflict":
276
+ return {
277
+ glyph: "⚠",
278
+ words: "changed here and on GitHub",
279
+ tone: "danger",
280
+ };
281
+ case "plugin-newer":
282
+ return {
283
+ glyph: "↓",
284
+ words: "plugin has a newer version",
285
+ tone: "info",
286
+ };
287
+ case "not-checked":
288
+ return { glyph: "?", words: "not checked — r to fetch", tone: "muted" };
289
+ case "ambiguous":
290
+ return { glyph: "?", words: "in more than one plugin", tone: "muted" };
291
+ default:
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /** Whether `p` has anything to publish on this row. */
297
+ export function publishable(row: SkillStatusRow | null): boolean {
298
+ return (
299
+ row?.state === "edited" ||
300
+ row?.state === "no-base" ||
301
+ row?.state === "conflict"
302
+ );
303
+ }
304
+
305
+ /** Whose copy a local row is, in the user's words. */
306
+ export function copyWords(row: SkillStatusRow): string {
307
+ return row.from === "user" ? "your USER copy" : "this project's copy";
308
+ }
309
+
310
+ // ─── Results ─────────────────────────────────────────────────────────────────
311
+
312
+ const SETTLED: ReadonlySet<MarketResult["outcome"]> = new Set([
313
+ "ok",
314
+ "unchanged",
315
+ "plugin-newer",
316
+ "pull-request",
317
+ ]);
318
+
319
+ /** The line a finished change leaves on its marketplace, or null for none. */
320
+ export function resultNote(result: MarketResult): string | null {
321
+ if (result.outcome === "pull-request") {
322
+ return result.pullRequest
323
+ ? `Pull request opened: ${result.pullRequest}`
324
+ : result.message;
325
+ }
326
+ if (!SETTLED.has(result.outcome)) return null;
327
+ return result.message;
328
+ }
329
+
330
+ /**
331
+ * The one failure popup for a result, or null when nothing failed. A change
332
+ * that landed but left installs behind (`partial`) names each install.
333
+ */
334
+ export function marketFailureReport(
335
+ result: MarketResult,
336
+ subject: string,
337
+ ): FailureReport | null {
338
+ const failedInstalls = (result.installs ?? []).filter(
339
+ (i) => i.status === "failed",
340
+ );
341
+ if (SETTLED.has(result.outcome) && failedInstalls.length === 0) return null;
342
+ const rows: FailureRow[] = [];
343
+ for (const i of failedInstalls) {
344
+ rows.push({
345
+ subject: `${result.plugin ?? subject} at ${i.scope.toUpperCase()}`,
346
+ reason: i.message ?? "the install did not move",
347
+ ...(i.fix ? { fix: i.fix } : {}),
348
+ });
349
+ }
350
+ const failedStep = result.steps.find((s) => s.status === "failed");
351
+ if (rows.length === 0) {
352
+ rows.push({
353
+ subject,
354
+ reason:
355
+ result.error?.message ?? failedStep?.detail ?? "it did not complete",
356
+ });
357
+ }
358
+ const fixes = new Set(rows.map((r) => r.fix).filter(Boolean));
359
+ const fix =
360
+ result.error?.fix ?? (fixes.size === 1 ? ([...fixes][0] as string) : null);
361
+ return {
362
+ headline: result.message,
363
+ rows: fix ? rows.map(({ fix: _drop, ...r }) => r) : rows,
364
+ fix,
365
+ };
366
+ }