magus-cli 7.3.0 → 7.4.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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -13,15 +13,162 @@
13
13
  * mid-edit. Treating it as `{}` and writing our one key back is how a
14
14
  * user's whole configuration disappears. We refuse and say so.
15
15
  *
16
+ * "Unparseable" includes a file that parses into something other than an
17
+ * object — `[]`, `null`, `5`, `"text"`. Every caller indexes the result as a
18
+ * map, so an array swallowed an enabled plugin as a property `JSON.stringify`
19
+ * never writes (the enable reported success and was gone), and `null` or a
20
+ * number crashed with a raw `TypeError`. Neither is a file we understand.
21
+ *
16
22
  * A MISSING file is different from an unparseable one: nothing is lost by
17
23
  * starting from `{}`, so that is what a missing (or empty) file reads as.
24
+ *
25
+ * Every Claude Code settings reader goes through `readSettingsFile` —
26
+ * `claude-settings.ts` delegates its four readers here, and the profiles
27
+ * readers in `manifest.ts` build on it — so there is one refusal, one wording
28
+ * and one error class for all of them.
18
29
  */
19
30
 
20
31
  import path from "node:path";
21
32
  import fs from "fs-extra";
22
33
 
23
34
  /**
24
- * Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws.
35
+ * A settings file that exists and cannot be used: it does not parse, or it
36
+ * parses into the wrong shape (see {@link SettingsShapeError}).
37
+ *
38
+ * Its own class so a caller can tell "this file is damaged" apart from every
39
+ * other failure and name the file to the user. The message is complete on its
40
+ * own — the path, what is wrong, and that magus refused — so a caller that
41
+ * prints `message` needs nothing else. `problem` and `detail` are its parts,
42
+ * for a caller that lays the file out on its own line instead.
43
+ */
44
+ export class SettingsParseError extends Error {
45
+ readonly path: string;
46
+ /** What is wrong, as the rest of a sentence that starts with the file: "is not valid JSON". */
47
+ readonly problem: string;
48
+ /** The specifics: the parser's own words, or which part has the wrong shape. */
49
+ readonly detail: string;
50
+
51
+ constructor(
52
+ settingsPath: string,
53
+ cause: unknown,
54
+ problem = "is not valid JSON",
55
+ ) {
56
+ const detail = cause instanceof Error ? cause.message : String(cause);
57
+ super(`${settingsPath} ${problem}, refusing to overwrite it: ${detail}`, {
58
+ cause,
59
+ });
60
+ this.name = "SettingsParseError";
61
+ this.path = settingsPath;
62
+ this.problem = problem;
63
+ this.detail = detail;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A settings or profiles file that is valid JSON, but not the shape the file
69
+ * must have — a top level that is not an object, or (for a profiles file) a
70
+ * `profiles` key that is not a map of objects.
71
+ *
72
+ * Refused exactly like a file that does not parse, and for the same reason:
73
+ * reading it as "empty" is what lets the next write replace it. A subclass so
74
+ * every existing `instanceof SettingsParseError` catch handles it unchanged.
75
+ */
76
+ export class SettingsShapeError extends SettingsParseError {
77
+ constructor(settingsPath: string, problem: string, detail: string) {
78
+ super(settingsPath, detail, problem);
79
+ this.name = "SettingsShapeError";
80
+ }
81
+ }
82
+
83
+ /** True for a JSON object: not `null`, not an array, not a primitive. */
84
+ export function isJsonObject(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ /** A parsed JSON value's kind with its article, for a refusal: "an array", "null". */
89
+ export function describeJsonKind(value: unknown): string {
90
+ if (value === null) return "null";
91
+ if (Array.isArray(value)) return "an array";
92
+ if (typeof value === "object") return "an object";
93
+ return `a ${typeof value}`;
94
+ }
95
+
96
+ /**
97
+ * The map at `settings[key]`, checked before the caller writes an entry into
98
+ * it: the map itself, or `undefined` when the key is absent. Present but not a
99
+ * JSON object THROWS {@link SettingsShapeError} naming the file and the key —
100
+ * so nothing reaches the write, and the file stays byte-for-byte as it was.
101
+ *
102
+ * The top-level check in {@link readSettingsFile} does not reach this far: the
103
+ * file IS an object, one of its maps is not. `settings.enabledPlugins || {}`
104
+ * kept an array, a plugin id set on an array is a property `JSON.stringify`
105
+ * never writes, and the enable reported success with nothing saved. On a
106
+ * string or a number the same assignment is a raw `TypeError`; on `null`,
107
+ * `|| {}` silently replaced the user's value.
108
+ *
109
+ * Every read-modify-write that writes into a map comes through here.
110
+ * `keyPath` names a nested key in the refusal ("permissions.allow"); it
111
+ * defaults to `key`.
112
+ */
113
+ export function writableMap<S extends object, K extends keyof S & string>(
114
+ settings: S,
115
+ key: K,
116
+ settingsPath: string,
117
+ keyPath: string = key,
118
+ ): CheckedMap<S[K]> {
119
+ return checkedEntry(
120
+ settings,
121
+ key,
122
+ settingsPath,
123
+ keyPath,
124
+ "object",
125
+ ) as CheckedMap<S[K]>;
126
+ }
127
+
128
+ /**
129
+ * A typed key keeps its type; an untyped one (`unknown`, from an open settings
130
+ * record) comes back as the object the check has just proved it is.
131
+ */
132
+ type CheckedMap<V> = unknown extends V
133
+ ? Record<string, unknown> | undefined
134
+ : V;
135
+
136
+ /**
137
+ * {@link writableMap} for a list the caller pushes into or filters —
138
+ * `enabledMcpjsonServers`. Present but not an array throws the same way.
139
+ */
140
+ export function writableList<S extends object, K extends keyof S & string>(
141
+ settings: S,
142
+ key: K,
143
+ settingsPath: string,
144
+ ): S[K] {
145
+ return checkedEntry(settings, key, settingsPath, key, "array");
146
+ }
147
+
148
+ function checkedEntry<S extends object, K extends keyof S & string>(
149
+ settings: S,
150
+ key: K,
151
+ settingsPath: string,
152
+ keyPath: string,
153
+ shape: "object" | "array",
154
+ ): S[K] {
155
+ const value = settings[key];
156
+ if (value === undefined) return value;
157
+ const fits = shape === "object" ? isJsonObject(value) : Array.isArray(value);
158
+ if (!fits) {
159
+ throw new SettingsShapeError(
160
+ settingsPath,
161
+ `has a malformed "${keyPath}"`,
162
+ `it is ${describeJsonKind(value)}, not a JSON ${shape}`,
163
+ );
164
+ }
165
+ return value;
166
+ }
167
+
168
+ /**
169
+ * Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws
170
+ * {@link SettingsParseError}, and a top level that is not an object throws
171
+ * {@link SettingsShapeError}.
25
172
  *
26
173
  * Exported for callers that only need the read half — the refusal wording is
27
174
  * the same either way, so nothing has to reimplement it.
@@ -34,13 +181,20 @@ export async function readSettingsFile(
34
181
  const raw = (await fs.readFile(settingsPath, "utf8")).trim();
35
182
  if (!raw) return {};
36
183
 
184
+ let parsed: unknown;
37
185
  try {
38
- return JSON.parse(raw) as Record<string, unknown>;
186
+ parsed = JSON.parse(raw);
39
187
  } catch (error) {
40
- throw new Error(
41
- `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
188
+ throw new SettingsParseError(settingsPath, error);
189
+ }
190
+ if (!isJsonObject(parsed)) {
191
+ throw new SettingsShapeError(
192
+ settingsPath,
193
+ "is not a JSON object",
194
+ `its top level is ${describeJsonKind(parsed)}`,
42
195
  );
43
196
  }
197
+ return parsed;
44
198
  }
45
199
 
46
200
  /**
@@ -3,11 +3,14 @@ import * as path from "node:path";
3
3
  import type { SettingDefinition } from "../data/settings-catalog.js";
4
4
  import type { ClaudeSettings } from "../types/index.js";
5
5
  import {
6
+ getGlobalSettingsPath,
7
+ getSettingsPath,
6
8
  readGlobalSettings,
7
9
  readSettings,
8
10
  writeGlobalSettings,
9
11
  writeSettings,
10
12
  } from "./claude-settings.js";
13
+ import { writableMap } from "./settings-file.js";
11
14
 
12
15
  export type SettingScope = "user" | "project";
13
16
 
@@ -103,6 +106,9 @@ export async function writeSettingValue(
103
106
  projectPath?: string,
104
107
  ): Promise<void> {
105
108
  const settings = await loadSettings(scope, projectPath);
109
+ // Named in the refusal when a map this writes into has the wrong shape.
110
+ const settingsPath =
111
+ scope === "user" ? getGlobalSettingsPath() : getSettingsPath(projectPath);
106
112
 
107
113
  if (setting.storage.type === "attribution") {
108
114
  // Boolean toggle: "false" -> write { commit: "", pr: "" }, "true"/undefined -> delete key
@@ -125,8 +131,10 @@ export async function writeSettingValue(
125
131
  delete settings.attribution;
126
132
  }
127
133
  } else if (setting.storage.type === "env") {
128
- // Write to the "env" block in settings.json
129
- const env = isRecord(settings.env) ? settings.env : {};
134
+ // Write to the "env" block in settings.json. One that is present but not
135
+ // an object is refused, never replaced — replacing it deleted the user's
136
+ // value to make room for ours.
137
+ const env = writableMap(settings, "env", settingsPath) ?? {};
130
138
  settings.env = env;
131
139
  if (value === undefined || value === "" || value === setting.defaultValue) {
132
140
  delete env[setting.storage.key];
@@ -149,11 +157,17 @@ export async function writeSettingValue(
149
157
  settings[keys[0]] = parseSettingValue(value, setting.type);
150
158
  }
151
159
  } else {
152
- // Nested key like "permissions.defaultMode"
160
+ // Nested key like "permissions.defaultMode". Each parent on the way is
161
+ // a map this writes into: absent is created, the wrong shape refused.
153
162
  let obj: Record<string, unknown> = settings;
154
163
  for (let i = 0; i < keys.length - 1; i++) {
155
- const existing = obj[keys[i]];
156
- const next = isRecord(existing) ? existing : {};
164
+ const next =
165
+ writableMap(
166
+ obj,
167
+ keys[i],
168
+ settingsPath,
169
+ keys.slice(0, i + 1).join("."),
170
+ ) ?? {};
157
171
  obj[keys[i]] = next;
158
172
  obj = next;
159
173
  }
@@ -664,6 +664,26 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
664
664
  if (!frontmatterMatch) return {};
665
665
 
666
666
  const yaml = frontmatterMatch[1];
667
+
668
+ // Real YAML first: a description is routinely a block scalar (`description: >-`
669
+ // with the text on the lines below), which the line reading further down returns
670
+ // as the literal ">-". Many community SKILL.md files are not valid YAML, though —
671
+ // an unquoted ": " inside a description is common — so a parse failure falls
672
+ // back to the line reading those files have always had.
673
+ try {
674
+ // `Bun.YAML` ships in the runtime (1.2.21+) but not in the Bun types this
675
+ // package resolves. A runtime without it throws here and takes the fallback.
676
+ const { YAML } = Bun as unknown as {
677
+ YAML: { parse(text: string): unknown };
678
+ };
679
+ const parsed: unknown = YAML.parse(yaml);
680
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
681
+ return parsed as Partial<SkillFrontmatter>;
682
+ }
683
+ } catch {
684
+ // Not valid YAML — read it line by line below.
685
+ }
686
+
667
687
  const result: Record<string, unknown> = {};
668
688
 
669
689
  for (const line of yaml.split("\n")) {
@@ -692,6 +712,32 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
692
712
  return result as Partial<SkillFrontmatter>;
693
713
  }
694
714
 
715
+ /**
716
+ * The frontmatter of an INSTALLED skill, read from its SKILL.md on disk.
717
+ *
718
+ * A local skill has no remote to fetch from, so this is the only place its
719
+ * description can come from. `name` falls back to the directory name and
720
+ * `description` to "" — the caller decides how to say "none". Never throws: an
721
+ * unreadable file answers `null`.
722
+ */
723
+ export async function readLocalSkillFrontmatter(
724
+ skillMdPath: string,
725
+ fallbackName: string,
726
+ ): Promise<SkillFrontmatter | null> {
727
+ let content: string;
728
+ try {
729
+ content = await fs.readFile(skillMdPath, "utf8");
730
+ } catch {
731
+ return null;
732
+ }
733
+ const parsed = parseYamlFrontmatter(content);
734
+ return {
735
+ ...parsed,
736
+ name: parsed.name || fallbackName,
737
+ description: parsed.description || "",
738
+ };
739
+ }
740
+
695
741
  export async function fetchSkillFrontmatter(
696
742
  skill: SkillInfo,
697
743
  ): Promise<SkillFrontmatter> {