magus-cli 7.1.0 → 7.3.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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Is this project usable, and if not, what has to happen first.
3
+ *
4
+ * ## One source of truth, not two
5
+ *
6
+ * A profiled project's `.claude/settings.json` is a SYMLINK into
7
+ * `_profiles/<id>/`, and that file is generated from the manifest. So the
8
+ * manifest is the truth and `enabledPlugins` is its output.
9
+ *
10
+ * An earlier version of this file compared the two — the manifest against the
11
+ * plugins `enabledPlugins` lists — and offered to create a new profile when
12
+ * they disagreed. That is coherent only in the profileless world, where
13
+ * `settings.json` is a real file somebody wrote. Once a profile is active it
14
+ * compares the manifest against its own generated output, and "they disagree"
15
+ * cannot mean what the offer claimed: the honest answer is to re-materialize
16
+ * the profile, never to invent a third one describing the corrupted output.
17
+ *
18
+ * Worse, it meant magus behaved as though both worlds were live at once. So the
19
+ * comparison is gone. Nothing here reads `enabledPlugins`, and nothing compares
20
+ * a set of declared plugins to a set of installed ones — they are different
21
+ * kinds of thing and a comparison between them cannot be acted on.
22
+ *
23
+ * ## What is left is a three-state question
24
+ *
25
+ * ok a profile is active; the manifest is in effect; ask nothing
26
+ * unmigrated no manifest — the project is not using profiles at all
27
+ * inactive a manifest with profiles, none of them linked into place
28
+ *
29
+ * `unmigrated` and `inactive` are both unusable, not merely untidy: with no
30
+ * active profile there is no profile state to read and nothing to write updates
31
+ * into, so magus cannot do its job. The caller must not offer to continue.
32
+ *
33
+ * Detection only — nothing here writes. That is what makes it callable from the
34
+ * TUI's startup effect, and testable without a renderer.
35
+ */
36
+
37
+ import type { ProfileManifest } from "../types/index.js";
38
+ // Type-only, so it is erased: this module stays free of the UI and of React,
39
+ // which is what lets `magus hook` and the CLI import it. The shape of a
40
+ // selectable option is presentation-neutral data, and both surfaces render the
41
+ // same list from these builders rather than each composing its own.
42
+ import type { SelectOption } from "../ui/state/types.js";
43
+ import { readManifest } from "./manifest.js";
44
+ import type { ProfileChoice } from "./profile-activate.js";
45
+ import { freeProfileId } from "./profile-adopt.js";
46
+ import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
47
+
48
+ /** Whether the project can be worked on, and if not, why not. */
49
+ export type ProfileStartupState = "ok" | "unmigrated" | "inactive";
50
+
51
+ /** A profile the user could activate. */
52
+ export interface ActivatableProfile {
53
+ id: string;
54
+ name: string;
55
+ /**
56
+ * How many plugins it declares.
57
+ *
58
+ * A property of the profile, read from the manifest — NOT a comparison
59
+ * against what is installed. It is here to tell two rows apart, nothing more.
60
+ */
61
+ pluginCount: number;
62
+ }
63
+
64
+ export interface ProfileStartupNeed {
65
+ state: ProfileStartupState;
66
+ /**
67
+ * Id to create if the user wants what is turned on captured as a profile.
68
+ * Null when a profile is already active. Never an id already in use.
69
+ */
70
+ adopt: string | null;
71
+ /** Profiles that could be activated. Empty unless the state is `inactive`. */
72
+ activate: ActivatableProfile[];
73
+ /**
74
+ * The profile this project declares it wants, if the manifest names one that
75
+ * exists. Used to preselect the row so Enter does the intended thing.
76
+ */
77
+ declaredDefault: string | null;
78
+ /**
79
+ * Activate `declaredDefault` without asking.
80
+ *
81
+ * True only when the repo answered the question AND activating would replace
82
+ * nothing a human put there — a fresh clone. Asking there is asking someone
83
+ * to re-decide something their team already decided, with nothing at stake.
84
+ *
85
+ * False the moment there is anything to lose, however clear the declared
86
+ * default is: replacing someone's working files is never automatic.
87
+ */
88
+ autoActivate: boolean;
89
+ /** The manifest as read, so the caller does not read it a second time. */
90
+ manifest: ProfileManifest;
91
+ }
92
+
93
+ /** True when the project is usable and there is nothing to ask. */
94
+ export function needsNothing(need: ProfileStartupNeed): boolean {
95
+ return need.state === "ok";
96
+ }
97
+
98
+ /** Inspect the project and decide what, if anything, must happen first. */
99
+ export async function detectProfileStartupNeed(
100
+ projectPath: string,
101
+ ): Promise<ProfileStartupNeed> {
102
+ const manifest = await readManifest(projectPath);
103
+ const ids = Object.keys(manifest.profiles);
104
+
105
+ if (ids.length === 0) {
106
+ return {
107
+ state: "unmigrated",
108
+ adopt: freeProfileId(manifest),
109
+ activate: [],
110
+ declaredDefault: null,
111
+ autoActivate: false,
112
+ manifest,
113
+ };
114
+ }
115
+
116
+ // The ONE question that decides whether the manifest is in effect. Not a
117
+ // comparison, not a heuristic: either settings.json is a link into a
118
+ // profile or it is not.
119
+ if ((await activeProfile(projectPath)) !== null) {
120
+ return {
121
+ state: "ok",
122
+ adopt: null,
123
+ activate: [],
124
+ declaredDefault: null,
125
+ autoActivate: false,
126
+ manifest,
127
+ };
128
+ }
129
+
130
+ // Validated, not trusted: a `default` naming a profile that was renamed or
131
+ // removed must fall back to asking rather than throw somewhere downstream.
132
+ // `validateManifest` reports the typo; this keeps magus usable meanwhile.
133
+ const declaredDefault =
134
+ manifest.default !== undefined && ids.includes(manifest.default)
135
+ ? manifest.default
136
+ : null;
137
+
138
+ return {
139
+ state: "inactive",
140
+ declaredDefault,
141
+ // Only when the repo answered AND there is nothing of the user's to
142
+ // replace. Both halves matter: without the first there is no answer to
143
+ // apply, and without the second applying it silently destroys work.
144
+ autoActivate:
145
+ declaredDefault !== null &&
146
+ (await displaceableArtifacts(projectPath)).length === 0,
147
+ // Still offered, because a manifest whose profiles all describe something
148
+ // this project no longer is would otherwise be a dead end — activating a
149
+ // wrong profile is the only way out. Offering it costs a row; NOT offering
150
+ // it rebuilds the trap. Which one to take is the user's call to make, and
151
+ // magus can no longer form an opinion on it without the comparison that
152
+ // used to be here.
153
+ adopt: freeProfileId(manifest),
154
+ activate: ids.map((id) => ({
155
+ id,
156
+ name: manifest.profiles[id]?.name ?? id,
157
+ pluginCount: Object.keys(manifest.profiles[id]?.plugins ?? {}).length,
158
+ })),
159
+ manifest,
160
+ };
161
+ }
162
+
163
+ // ─── What to offer, as data both surfaces render ─────────────────────────────
164
+
165
+ /** Option values are parsed back, so they are data, not prose. */
166
+ export const ADOPT_VALUE = "adopt";
167
+ export const QUIT_VALUE = "quit";
168
+ export const ACTIVATE_PREFIX = "activate:";
169
+
170
+ /**
171
+ * Title and message for the state we are in.
172
+ *
173
+ * Written about what the user has, never about magus's internals. Two rewrites
174
+ * got here, and both failures are worth keeping in mind.
175
+ *
176
+ * First it explained the machinery: "the manifest is not in effect", "there is
177
+ * no profile state to read and nowhere to write updates". Accurate, and an
178
+ * answer to a question nobody standing in front of this modal is asking.
179
+ *
180
+ * Then it traded jargon for abstraction: "the setup you are running belongs to
181
+ * no profile". "Setup" names nothing the reader can point at, and "belongs to"
182
+ * describes a relationship rather than a consequence. So the copy now names the
183
+ * concrete things — the plugins and skills turned on for this project — and
184
+ * says what happens to them: they will be overwritten.
185
+ *
186
+ * That consequence is the whole reason the save option exists. A screen offering
187
+ * "save" and "switch" without saying they are alternatives asks the reader to
188
+ * guess which one keeps their work.
189
+ *
190
+ * The `unmigrated` copy must NOT carry that warning: adopting there captures
191
+ * what is already present, so the reassurance is the point.
192
+ *
193
+ * Neither mentions the backup, deliberately. A sentence promising "nothing is
194
+ * deleted — the old files are moved to a backup" raises a question the reader
195
+ * did not have (which files? what was at risk?) and implies data loss, when what
196
+ * changes is settings. Where the displaced files went belongs in the outcome
197
+ * modal AFTER the switch, where it is something to act on rather than a worry to
198
+ * plant beforehand.
199
+ *
200
+ * No file paths in these strings, deliberately. The TUI modal soft-wraps its
201
+ * message at whatever width the terminal gives it, and OpenTUI treats `.` as a
202
+ * break opportunity — MEASURED on screen, `.claude/settings.json` wrapped as
203
+ * `.` then `claude/settings.json` on the next line. A path split across a line
204
+ * break reads as two different paths, neither of them real.
205
+ */
206
+ export function describeNeed(need: ProfileStartupNeed): {
207
+ title: string;
208
+ message: string;
209
+ } {
210
+ if (need.state === "unmigrated") {
211
+ return {
212
+ title: "magus uses profiles",
213
+ message:
214
+ "A profile is a saved set of plugins, skills and MCP servers for one project. Commit it, and everyone on the team gets the same ones.\n\nThis project has no profile yet. Saving what it uses now makes the first one, and nothing about what you run changes.",
215
+ };
216
+ }
217
+
218
+ return {
219
+ title: "No profile selected",
220
+ message:
221
+ "A profile is a saved set of plugins, skills and MCP servers for one project.\n\nThe plugins and skills turned on here are not saved in any profile. Switch to one below and they will be overwritten.\n\nTo keep them, save them as a new profile first.",
222
+ };
223
+ }
224
+
225
+ /**
226
+ * The options for a need, in the order they should be offered.
227
+ *
228
+ * There is no "Not now". Without an active profile magus has no profile state
229
+ * to read and nowhere to write updates, so "carry on anyway" is not something
230
+ * it can offer — both surfaces used to have that escape, the TUI as a list row
231
+ * and the CLI as "using X for this run only", and both let the user into a
232
+ * magus that could not do its job.
233
+ *
234
+ * "Quit magus" is last instead. A question that cannot be answered with "no"
235
+ * still has to be answerable with "not in this app", and the way out has to be
236
+ * visible rather than a keystroke the user has to guess.
237
+ */
238
+ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
239
+ const options: SelectOption[] = [];
240
+
241
+ if (need.adopt !== null) {
242
+ options.push({
243
+ // Says what it PRESERVES, not what it creates. "Create <id> from this
244
+ // project" described the mechanism; the reason to pick this row is that
245
+ // it is the only one that keeps what is turned on right now.
246
+ //
247
+ // Kept short on purpose: this is the longest label in the list, and the
248
+ // meta column is measured against the longest one. "…and use it" made it
249
+ // 41 cells and pushed every plugin count onto a line of its own.
250
+ label: `Save what you have as "${need.adopt}"`,
251
+ value: ADOPT_VALUE,
252
+ });
253
+ }
254
+
255
+ for (const profile of need.activate) {
256
+ options.push({
257
+ label: `Activate ${profile.name}`,
258
+ value: `${ACTIVATE_PREFIX}${profile.id}`,
259
+ // The profile's own plugin count, to tell two rows apart. There used to
260
+ // be a "matches now" badge here, marking the profile that declared
261
+ // exactly what `enabledPlugins` had on. It is gone with the comparison
262
+ // that produced it: it asserted that the installed set was a legitimate
263
+ // thing to judge a profile against, which is the two-worlds confusion
264
+ // this change removes.
265
+ meta: `${profile.pluginCount} plugin${profile.pluginCount === 1 ? "" : "s"}`,
266
+ });
267
+ }
268
+
269
+ options.push({ label: "Quit magus", value: QUIT_VALUE });
270
+ return options;
271
+ }
272
+
273
+ /**
274
+ * Which row to start on.
275
+ *
276
+ * The profile the repo declares, when it names one — so a developer who does
277
+ * have local work to weigh still gets their team's answer under the cursor, and
278
+ * Enter does the intended thing. Falls back to the first row, which is the save
279
+ * option: the safe default when nobody has said otherwise, because it is the
280
+ * only one that keeps what is turned on.
281
+ */
282
+ export function defaultOptionIndex(need: ProfileStartupNeed): number {
283
+ if (need.declaredDefault === null) return 0;
284
+ const at = buildProfileOptions(need).findIndex(
285
+ (o) => o.value === `${ACTIVATE_PREFIX}${need.declaredDefault}`,
286
+ );
287
+ return at === -1 ? 0 : at;
288
+ }
289
+
290
+ /**
291
+ * Turn a chosen option value into the action it means.
292
+ *
293
+ * Returns null for "quit" and for anything unrecognised — including `adopt`
294
+ * when there is nothing to adopt, so a stale value cannot reach the adopt path
295
+ * with no id.
296
+ */
297
+ export function parseProfileChoice(
298
+ value: string,
299
+ need: ProfileStartupNeed,
300
+ ): ProfileChoice | null {
301
+ if (value === ADOPT_VALUE && need.adopt !== null) {
302
+ return { kind: "adopt", id: need.adopt };
303
+ }
304
+ if (value.startsWith(ACTIVATE_PREFIX)) {
305
+ const id = value.slice(ACTIVATE_PREFIX.length);
306
+ return need.activate.some((p) => p.id === id)
307
+ ? { kind: "activate", id }
308
+ : null;
309
+ }
310
+ return null;
311
+ }
@@ -1,7 +1,8 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
- import type { Profile, ProfileEntry, ProfilesFile } from "../types/index.js";
4
+ import type { ProfileEntry, ProfileManifest } from "../types/index.js";
5
+ import { activeProfile } from "./symlink-manager.js";
5
6
 
6
7
  const PROFILES_FILE = "profiles.json";
7
8
 
@@ -21,7 +22,7 @@ export function getProjectProfilesPath(projectPath?: string): string {
21
22
  export async function readProfiles(
22
23
  scope: "user" | "project",
23
24
  projectPath?: string,
24
- ): Promise<ProfilesFile> {
25
+ ): Promise<ProfileManifest> {
25
26
  const filePath =
26
27
  scope === "user"
27
28
  ? getUserProfilesPath()
@@ -29,17 +30,23 @@ export async function readProfiles(
29
30
  try {
30
31
  if (await fs.pathExists(filePath)) {
31
32
  const data = await fs.readJson(filePath);
32
- if (data.version && data.profiles) return data as ProfilesFile;
33
+ // A v1 file differs only in what it omits, so it reads as a v2 entry
34
+ // with absent optional fields. Nothing here converts: `version: 2` is
35
+ // what the next write records, and every consumer already treats
36
+ // `createdAt`, `updatedAt` and a string plugin pin as optional.
37
+ if (data.version && data.profiles) {
38
+ return { ...data, version: 2 } as ProfileManifest;
39
+ }
33
40
  }
34
41
  } catch {
35
42
  // fall through to default
36
43
  }
37
- return { version: 1, profiles: {} };
44
+ return { version: 2, profiles: {} };
38
45
  }
39
46
 
40
47
  async function writeProfiles(
41
48
  scope: "user" | "project",
42
- data: ProfilesFile,
49
+ data: ProfileManifest,
43
50
  projectPath?: string,
44
51
  ): Promise<void> {
45
52
  const filePath =
@@ -56,21 +63,33 @@ async function writeProfiles(
56
63
  export async function listProfiles(
57
64
  projectPath?: string,
58
65
  ): Promise<ProfileEntry[]> {
59
- const [user, project] = await Promise.all([
66
+ // The active profile is read alongside the files rather than by the caller:
67
+ // every surface that lists profiles wants to mark the live one, and a flag
68
+ // each of them derives itself is a flag some of them forget.
69
+ const [user, project, active] = await Promise.all([
60
70
  readProfiles("user"),
61
71
  readProfiles("project", projectPath),
72
+ activeProfile(projectPath),
62
73
  ]);
63
74
  const entries: ProfileEntry[] = [];
64
75
  for (const [id, p] of Object.entries(user.profiles)) {
65
76
  entries.push({ id, scope: "user", ...p });
66
77
  }
67
78
  for (const [id, p] of Object.entries(project.profiles)) {
68
- entries.push({ id, scope: "project", ...p });
79
+ entries.push({ id, scope: "project", ...p, active: id === active });
69
80
  }
70
- // Sort: user profiles first, then project, each by updatedAt desc
81
+ // Sort: user profiles first, then project, each by updatedAt desc.
82
+ //
83
+ // `updatedAt` is optional — a hand-written or CLI-authored profile carries
84
+ // no timestamp, and dereferencing it here is what made this function throw
85
+ // on every v2 file. An undated profile sorts last within its scope, then by
86
+ // id so the order is stable rather than dependent on key order.
71
87
  entries.sort((a, b) => {
72
88
  if (a.scope !== b.scope) return a.scope === "user" ? -1 : 1;
73
- return b.updatedAt.localeCompare(a.updatedAt);
89
+ const at = a.updatedAt ?? "";
90
+ const bt = b.updatedAt ?? "";
91
+ if (at !== bt) return bt.localeCompare(at);
92
+ return a.id.localeCompare(b.id);
74
93
  });
75
94
  return entries;
76
95
  }
@@ -98,6 +117,25 @@ export async function saveProfile(
98
117
  return id;
99
118
  }
100
119
 
120
+ /**
121
+ * A profile's plugin map as Claude Code's `enabledPlugins` needs it.
122
+ *
123
+ * A profile pins a plugin to a version ("1.2.0"), to "latest", or to `true`.
124
+ * Claude Code's `enabledPlugins` takes booleans and nothing else, so spreading
125
+ * a profile straight in writes `"latest"` where `true` belongs and the plugin
126
+ * silently stops loading. Only an explicit `false` means disabled; every pin
127
+ * names a plugin the profile wants on.
128
+ */
129
+ export function toEnabledPlugins(
130
+ plugins: Record<string, string | boolean> | undefined,
131
+ ): Record<string, boolean> {
132
+ const out: Record<string, boolean> = {};
133
+ for (const [id, pin] of Object.entries(plugins ?? {})) {
134
+ out[id] = pin !== false;
135
+ }
136
+ return out;
137
+ }
138
+
101
139
  /**
102
140
  * Apply a profile: replaces enabledPlugins in the target settings scope.
103
141
  * targetScope is where the settings will be written, independent of where
@@ -123,12 +161,12 @@ export async function applyProfile(
123
161
 
124
162
  if (targetScope === "user") {
125
163
  const settings = await readGlobalSettings();
126
- settings.enabledPlugins = { ...profile.plugins };
164
+ settings.enabledPlugins = toEnabledPlugins(profile.plugins);
127
165
  await writeGlobalSettings(settings);
128
166
  } else {
129
167
  // project or local both write to project settings.json
130
168
  const settings = await readSettings(projectPath);
131
- settings.enabledPlugins = { ...profile.plugins };
169
+ settings.enabledPlugins = toEnabledPlugins(profile.plugins);
132
170
  await writeSettings(settings, projectPath);
133
171
  }
134
172
  }