magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
package/src/utils/run.ts CHANGED
@@ -1,67 +1,138 @@
1
1
  /**
2
- * Running an installer, with the shell split out from the argv.
2
+ * The one way magus runs an installer or any other child process it waits on.
3
3
  *
4
- * Lives in `utils/` rather than `cli/prompt.ts` because the update engine needs
5
- * it and a service must not import from the CLI layer — `cli/` and `ui/` are
6
- * sibling front ends over `services/`, and an arrow from a service back up into
7
- * one of them is how the two front ends ended up with different update logic in
8
- * the first place.
4
+ * Lives in `utils/` rather than `cli/` because services need it and a service
5
+ * must not import from a front end — `cli/` and `ui/` are sibling front ends
6
+ * over `services/`.
9
7
  *
10
- * Both functions live in ONE file on purpose: {@link runShellScript}'s doc only
11
- * makes sense next to {@link runCommand}, because most of what it has to say is
12
- * "you almost certainly want the other one".
8
+ * Contract:
9
+ *
10
+ * - **argv only.** No `shell` option: `cmd` is resolved on PATH as an
11
+ * executable, and every argument stays exactly one argument. Package names
12
+ * reach here from `.claude/profiles.json` and plugin `requires` — committed
13
+ * by teammates, fetched from marketplaces — so under a shell a space or a
14
+ * `$(…)` in one of them would execute.
15
+ * - **Never writes to the terminal.** stdin is ignored and stdout/stderr are
16
+ * piped and captured; what (if anything) is shown, and where, is the
17
+ * caller's decision through `onOutput`. The guarantee is exactly this: a
18
+ * child cannot read the terminal through its stdin. A child that opens
19
+ * `/dev/tty` itself still can; it keeps magus's controlling terminal and
20
+ * session on purpose, so a `sudo -v` ticket stays usable by `sudo -n`.
21
+ * - **Bounded.** At `timeoutMs` the child gets SIGTERM, then SIGKILL after
22
+ * `killGraceMs` (5 s by default; a `sudo` step passes more, because SIGKILL
23
+ * to `sudo` cannot be relayed and would orphan its root child).
13
24
  */
14
25
 
15
26
  import { spawn } from "node:child_process";
16
27
  import type { Command } from "./command-utils.js";
28
+ import { stripAnsi } from "./string-utils.js";
17
29
 
18
- /**
19
- * Run a built command as argv, streaming its output; true on exit 0.
20
- *
21
- * **This is the one every installer uses.** No `shell` option, so:
22
- *
23
- * - `cmd` is resolved on PATH as an executable. There is no profile to source
24
- * and therefore no way for a shell alias to appear to work — the reason the
25
- * shipped `pip install` bug could exist at all was that `/bin/sh` was asked
26
- * to resolve a word that only interactive zsh knew.
27
- * - Every argument stays exactly one argument. Package names reach here from
28
- * `.claude/profiles.json` and plugin `requires.bin` — hand-authored data
29
- * committed by teammates and fetched from marketplaces — and under a shell a
30
- * space or a `$(…)` in one of them would have been executed. Nothing
31
- * validates those names, and after this nothing has to.
32
- *
33
- * stdio is inherited: the installer owns the terminal while it runs, which is
34
- * what makes brew's and bun's own progress output visible. A caller painting an
35
- * animated region must pause it around this (see `ApplyReporter.detach`).
36
- */
37
- export async function runCommand(c: Command): Promise<boolean> {
30
+ /** How much combined output a {@link RunResult} keeps: the last 16 KiB. */
31
+ export const OUTPUT_TAIL_BYTES = 16 * 1024;
32
+
33
+ const DEFAULT_KILL_GRACE_MS = 5_000;
34
+
35
+ export interface RunResult {
36
+ /** null when killed by a signal or never spawned. */
37
+ exitCode: number | null;
38
+ signal: string | null;
39
+ timedOut: boolean;
40
+ /** Set when the process could not be started (ENOENT, EACCES, …). */
41
+ spawnError?: string;
42
+ /** Combined stdout + stderr, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. */
43
+ outputTail: string;
44
+ durationMs: number;
45
+ }
46
+
47
+ export interface RunOptions {
48
+ timeoutMs: number;
49
+ /** Replaces the child's environment when given. */
50
+ env?: Record<string, string | undefined>;
51
+ cwd?: string;
52
+ /** Between SIGTERM and SIGKILL once `timeoutMs` passes. */
53
+ killGraceMs?: number;
54
+ /** Each output chunk as it arrives, ANSI intact. Never called after return. */
55
+ onOutput?: (chunk: string) => void;
56
+ }
57
+
58
+ /** Keep the last {@link OUTPUT_TAIL_BYTES} of a growing buffer. */
59
+ function tail(text: string): string {
60
+ const bytes = Buffer.from(text, "utf8");
61
+ if (bytes.length <= OUTPUT_TAIL_BYTES) return text;
62
+ return bytes.subarray(bytes.length - OUTPUT_TAIL_BYTES).toString("utf8");
63
+ }
64
+
65
+ /** Run `c` as argv and capture it; resolves when it exits or is killed. */
66
+ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
67
+ const started = Date.now();
38
68
  return new Promise((resolve) => {
39
- const child = spawn(c.cmd, [...c.args], { stdio: "inherit" });
40
- child.on("exit", (code) => resolve(code === 0));
41
- child.on("error", () => resolve(false));
69
+ let output = "";
70
+ let timedOut = false;
71
+ let settled = false;
72
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
73
+ const finish = (result: Omit<RunResult, "outputTail" | "durationMs">) => {
74
+ if (settled) return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ if (killTimer) clearTimeout(killTimer);
78
+ resolve({
79
+ ...result,
80
+ outputTail: stripAnsi(tail(output)),
81
+ durationMs: Date.now() - started,
82
+ });
83
+ };
84
+ const child = spawn(c.cmd, [...c.args], {
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ ...(opts.env ? { env: opts.env } : {}),
87
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
88
+ });
89
+ const onData = (data: Buffer) => {
90
+ const chunk = data.toString("utf8");
91
+ output = tail(output + chunk);
92
+ if (!settled) opts.onOutput?.(chunk);
93
+ };
94
+ child.stdout?.on("data", onData);
95
+ child.stderr?.on("data", onData);
96
+ const timer = setTimeout(() => {
97
+ timedOut = true;
98
+ child.kill("SIGTERM");
99
+ killTimer = setTimeout(
100
+ () => child.kill("SIGKILL"),
101
+ opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS,
102
+ );
103
+ }, opts.timeoutMs);
104
+ child.on("error", (error) =>
105
+ finish({
106
+ exitCode: null,
107
+ signal: null,
108
+ timedOut,
109
+ spawnError: error.message,
110
+ }),
111
+ );
112
+ child.on("close", (code, signal) =>
113
+ finish({ exitCode: code, signal, timedOut }),
114
+ );
42
115
  });
43
116
  }
44
117
 
118
+ /** True when the run exited 0 (and was neither killed nor unstartable). */
119
+ export function succeeded(r: RunResult): boolean {
120
+ return r.exitCode === 0 && !r.timedOut && r.spawnError === undefined;
121
+ }
122
+
45
123
  /**
46
- * Run a shell SCRIPT through `/bin/sh`, streaming its output; true on exit 0.
47
- *
48
- * **Not for installer commands — use {@link runCommand}.** This exists for
49
- * exactly one input: the values of `TOOLCHAIN_BOOTSTRAP`, which are the
50
- * upstream-published one-liners for bun and Homebrew. Those genuinely are shell
51
- * text — one contains a pipe, the other a command substitution — so they cannot
52
- * be expressed as argv without wrapping them in a fake `/bin/sh -c` at the one
53
- * place where a shell is actually correct.
54
- *
55
- * The distinction is who wrote the string. A bootstrap line is a constant in
56
- * this repo, copied from bun.sh and brew.sh. An installer command is assembled
57
- * from names that arrive with a profile. Passing the second kind here is the
58
- * defect that motivated splitting the two, so
59
- * `src/__tests__/shell-script-callers.test.ts` pins the caller set to one file.
124
+ * One line saying why a run did not succeed, for a failure report. The output
125
+ * tail's last non-empty line is the installer's own reason more often than not.
60
126
  */
61
- export async function runShellScript(script: string): Promise<boolean> {
62
- return new Promise((resolve) => {
63
- const child = spawn(script, { stdio: "inherit", shell: true });
64
- child.on("exit", (code) => resolve(code === 0));
65
- child.on("error", () => resolve(false));
66
- });
127
+ export function runFailureReason(r: RunResult): string {
128
+ if (r.spawnError) return r.spawnError;
129
+ if (r.timedOut) return `timed out after ${Math.round(r.durationMs / 1000)}s`;
130
+ const last = r.outputTail
131
+ .split("\n")
132
+ .map((l) => l.trim())
133
+ .filter(Boolean)
134
+ .pop();
135
+ const code =
136
+ r.exitCode !== null ? `exit ${r.exitCode}` : `killed by ${r.signal}`;
137
+ return last ? `${code}: ${last}` : code;
67
138
  }
@@ -86,3 +86,12 @@ export function parsePluginId(pluginId: string): ParsedPluginId | null {
86
86
  raw: trimmed,
87
87
  };
88
88
  }
89
+
90
+ /** Every CSI sequence, not only SGR — the live region emits cursor moves too. */
91
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
92
+ export const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
93
+
94
+ /** `text` without its ANSI CSI sequences. */
95
+ export function stripAnsi(text: string): string {
96
+ return text.replace(CSI, "");
97
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A file as the user names it, never as an absolute path.
3
+ *
4
+ * magus names a file only when the user must act on it (fix it by hand,
5
+ * commit it), and then in the words they would type: `~/.claude/settings.json`
6
+ * for their own settings, `.claude/settings.json` for this project's. The
7
+ * absolute path is where this machine keeps it — the mechanism, and noise
8
+ * that pushes the actual problem off the end of the line.
9
+ */
10
+
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ /** `true` when `file` is `dir` itself or inside it. */
15
+ function isInside(file: string, dir: string): boolean {
16
+ const rel = path.relative(dir, file);
17
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
18
+ }
19
+
20
+ /**
21
+ * `file` in the user's terms:
22
+ *
23
+ * 1. Under the user's own `~/.claude/` → `~/.claude/…` (their USER settings),
24
+ * checked first so a project opened at the home folder still names it so.
25
+ * 2. Under the project folder → relative to it: `.claude/settings.json`.
26
+ * 3. Anywhere else with a `.claude/` folder in its path → from that folder
27
+ * on, which is how the user knows a project's settings file.
28
+ * 4. Under the home folder → `~/…`.
29
+ * 5. Otherwise its name alone.
30
+ */
31
+ export function userFacingPath(
32
+ file: string,
33
+ projectDir: string = process.cwd(),
34
+ home: string = os.homedir(),
35
+ ): string {
36
+ const abs = path.resolve(file);
37
+ const userClaude = home ? path.join(home, ".claude") : "";
38
+ if (userClaude && isInside(abs, userClaude)) {
39
+ return `~/${path.relative(home, abs)}`;
40
+ }
41
+ if (projectDir && isInside(abs, path.resolve(projectDir))) {
42
+ const rel = path.relative(path.resolve(projectDir), abs);
43
+ if (rel) return rel;
44
+ }
45
+ const marker = `${path.sep}.claude${path.sep}`;
46
+ const at = abs.lastIndexOf(marker);
47
+ if (at >= 0) return abs.slice(at + 1);
48
+ if (home && isInside(abs, home)) return `~/${path.relative(home, abs)}`;
49
+ return path.basename(abs);
50
+ }
51
+
52
+ /** Every occurrence of `file`'s absolute path in `text`, in the user's terms. */
53
+ export function withUserFacingPath(
54
+ text: string,
55
+ file: string,
56
+ projectDir?: string,
57
+ home?: string,
58
+ ): string {
59
+ return text.split(file).join(userFacingPath(file, projectDir, home));
60
+ }
@@ -1,43 +0,0 @@
1
- /**
2
- * goBinaryName: the one rule for turning a Go module path into the binary
3
- * `go install` produces. Both consumers (plugin-setup's Go path and
4
- * plugin-requires' `setup.go` branch) are tested through their own surfaces;
5
- * this file pins the rule itself.
6
- */
7
-
8
- import { describe, expect, test } from "bun:test";
9
- import { goBinaryName } from "../utils/go-module.js";
10
-
11
- describe("goBinaryName", () => {
12
- test("takes the last segment of a v0/v1 module path", () => {
13
- expect(goBinaryName("github.com/MadAppGang/tmux-mcp")).toBe("tmux-mcp");
14
- });
15
-
16
- test("strips a trailing /v2 major-version suffix before taking the name", () => {
17
- expect(goBinaryName("github.com/MadAppGang/tmux-mcp/v2")).toBe("tmux-mcp");
18
- });
19
-
20
- test("strips multi-digit suffixes (/v10, /v42)", () => {
21
- expect(goBinaryName("github.com/x/tool/v10")).toBe("tool");
22
- expect(goBinaryName("github.com/x/tool/v42")).toBe("tool");
23
- });
24
-
25
- test("a segment that merely starts with v2 is a name, not a suffix", () => {
26
- expect(goBinaryName("github.com/x/v2tool")).toBe("v2tool");
27
- });
28
-
29
- test("v0 and v1 are never path suffixes, so they stay as names", () => {
30
- // Nobody names a binary this, but the rule must not invent an exception.
31
- expect(goBinaryName("github.com/x/v1")).toBe("v1");
32
- expect(goBinaryName("github.com/x/v0")).toBe("v0");
33
- });
34
-
35
- test("a suffix in the middle of the path is not trailing and is kept", () => {
36
- expect(goBinaryName("github.com/x/repo/v2/cmd/cli")).toBe("cli");
37
- });
38
-
39
- test("single-segment and empty inputs pass through", () => {
40
- expect(goBinaryName("mytool")).toBe("mytool");
41
- expect(goBinaryName("")).toBe("");
42
- });
43
- });