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.
- package/README.md +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- 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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|