magus-cli 7.4.1 → 7.5.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 (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * ## One source of truth, not two
5
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.
6
+ * A profiled project's `.claude/settings.json` is rendered from the manifest,
7
+ * so the manifest is the truth and `enabledPlugins` is its output. (Claude
8
+ * Code may since have written to it; that is a change to the active profile,
9
+ * absorbed into the manifest before anything reads it — see
10
+ * `profile-absorb.ts` — not a question asked here.)
9
11
  *
10
12
  * An earlier version of this file compared the two — the manifest against the
11
13
  * plugins `enabledPlugins` lists — and offered to create a new profile when
@@ -20,11 +22,13 @@
20
22
  * a set of declared plugins to a set of installed ones — they are different
21
23
  * kinds of thing and a comparison between them cannot be acted on.
22
24
  *
23
- * ## What is left is a three-state question, and one state with no question
25
+ * ## What is left is a small set of states
24
26
  *
25
27
  * ok a profile is active; the manifest is in effect; ask nothing
26
28
  * unmigrated no manifest — the project is not using profiles at all
27
- * inactive a manifest with profiles, none of them linked into place
29
+ * inactive a manifest with profiles, none of them activated
30
+ * unknown the active-profile marker names a profile the manifest does
31
+ * not define; the only way on is activating one that exists
28
32
  * unparseable a manifest that exists and is not valid JSON, or is valid
29
33
  * JSON in the wrong shape (see `readProfilesFile`)
30
34
  *
@@ -38,8 +42,12 @@
38
42
  * manifest over whatever the file held. So it offers nothing but the way out,
39
43
  * and names the file to fix.
40
44
  *
41
- * Detection only — nothing here writes. That is what makes it callable from the
42
- * TUI's startup effect, and testable without a renderer.
45
+ * `detectProfileStartupNeed` is detection only — nothing it does writes, which
46
+ * is what makes it testable without a renderer. `prepareProfileStartup` is
47
+ * the TUI's entry point: it runs the same gate as every CLI command first
48
+ * (`prepareProfileState` — restore a lost marker, convert an older layout,
49
+ * absorb Claude Code's edits, regenerate from a changed manifest), then
50
+ * detects.
43
51
  */
44
52
 
45
53
  import type { ProfileManifest } from "../types/index.js";
@@ -51,14 +59,20 @@ import type { SelectOption } from "../ui/state/types.js";
51
59
  import { MANIFEST_FILE, emptyManifest, readManifest } from "./manifest.js";
52
60
  import type { ProfileChoice } from "./profile-activate.js";
53
61
  import { freeProfileId } from "./profile-adopt.js";
62
+ import {
63
+ activeProfile,
64
+ displaceableArtifacts,
65
+ namedActiveProfile,
66
+ } from "./profile-live.js";
67
+ import { prepareProfileState } from "./profile-state.js";
54
68
  import { SettingsParseError } from "./settings-file.js";
55
- import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
56
69
 
57
70
  /** Whether the project can be worked on, and if not, why not. */
58
71
  export type ProfileStartupState =
59
72
  | "ok"
60
73
  | "unmigrated"
61
74
  | "inactive"
75
+ | "unknown"
62
76
  | "unparseable";
63
77
 
64
78
  /** A profile the user could activate. */
@@ -81,8 +95,13 @@ export interface ProfileStartupNeed {
81
95
  * Null when a profile is already active. Never an id already in use.
82
96
  */
83
97
  adopt: string | null;
84
- /** Profiles that could be activated. Empty unless the state is `inactive`. */
98
+ /**
99
+ * Profiles that could be activated. Empty unless the state is `inactive` or
100
+ * `unknown`.
101
+ */
85
102
  activate: ActivatableProfile[];
103
+ /** For `unknown`: the profile the marker names. */
104
+ unknownProfile?: string;
86
105
  /**
87
106
  * The profile this project declares it wants, if the manifest names one that
88
107
  * exists. Used to preselect the row so Enter does the intended thing.
@@ -159,8 +178,8 @@ export async function detectProfileStartupNeed(
159
178
  }
160
179
 
161
180
  // The ONE question that decides whether the manifest is in effect. Not a
162
- // comparison, not a heuristic: either settings.json is a link into a
163
- // profile or it is not.
181
+ // comparison, not a heuristic: either the active-profile marker names a
182
+ // materialized profile or it does not (see `activeProfile`).
164
183
  if ((await activeProfile(projectPath)) !== null) {
165
184
  return {
166
185
  state: "ok",
@@ -172,6 +191,28 @@ export async function detectProfileStartupNeed(
172
191
  };
173
192
  }
174
193
 
194
+ const activate = ids.map((id) => ({
195
+ id,
196
+ name: manifest.profiles[id]?.name ?? id,
197
+ pluginCount: Object.keys(manifest.profiles[id]?.plugins ?? {}).length,
198
+ }));
199
+
200
+ // A marker naming a profile the manifest does not define: magus must not act
201
+ // on it, and saving "what you have" would capture the output of a profile
202
+ // that no longer exists. The only way on is activating one that does.
203
+ const named = await namedActiveProfile(projectPath);
204
+ if (named !== null) {
205
+ return {
206
+ state: "unknown",
207
+ adopt: null,
208
+ activate,
209
+ declaredDefault: null,
210
+ autoActivate: false,
211
+ unknownProfile: named.profile,
212
+ manifest,
213
+ };
214
+ }
215
+
175
216
  // Validated, not trusted: a `default` naming a profile that was renamed or
176
217
  // removed must fall back to asking rather than throw somewhere downstream.
177
218
  // `validateManifest` reports the typo; this keeps magus usable meanwhile.
@@ -196,15 +237,39 @@ export async function detectProfileStartupNeed(
196
237
  // magus can no longer form an opinion on it without the comparison that
197
238
  // used to be here.
198
239
  adopt: freeProfileId(manifest),
199
- activate: ids.map((id) => ({
200
- id,
201
- name: manifest.profiles[id]?.name ?? id,
202
- pluginCount: Object.keys(manifest.profiles[id]?.plugins ?? {}).length,
203
- })),
240
+ activate,
204
241
  manifest,
205
242
  };
206
243
  }
207
244
 
245
+ /** What the TUI shows at startup, beyond the question (if any). */
246
+ export interface ProfileStartup {
247
+ need: ProfileStartupNeed;
248
+ /** One line per thing the gate did: absorbed edits, regeneration, repairs. */
249
+ notices: string[];
250
+ }
251
+
252
+ /**
253
+ * The TUI's startup: the same gate every CLI command runs, then detection.
254
+ *
255
+ * A failure in the gate never blocks the TUI — its message becomes a notice
256
+ * and detection still runs, so the user is asked whatever is left to ask.
257
+ */
258
+ export async function prepareProfileStartup(
259
+ projectPath: string,
260
+ ): Promise<ProfileStartup> {
261
+ const notices: string[] = [];
262
+ try {
263
+ const prep = await prepareProfileState(projectPath);
264
+ notices.push(...prep.lines, ...prep.problems);
265
+ } catch (error) {
266
+ if (!(error instanceof SettingsParseError)) {
267
+ notices.push(error instanceof Error ? error.message : String(error));
268
+ }
269
+ }
270
+ return { need: await detectProfileStartupNeed(projectPath), notices };
271
+ }
272
+
208
273
  // ─── What to offer, as data both surfaces render ─────────────────────────────
209
274
 
210
275
  /** Option values are parsed back, so they are data, not prose. */
@@ -284,6 +349,13 @@ export function describeNeed(need: ProfileStartupNeed): {
284
349
  };
285
350
  }
286
351
 
352
+ if (need.state === "unknown") {
353
+ return {
354
+ title: "Active profile no longer exists",
355
+ message: `This project was running the profile "${need.unknownProfile ?? "?"}", which the team's profiles file no longer defines.\n\nActivate one of the profiles below. It replaces the plugins, MCP servers and settings turned on here with that profile's.`,
356
+ };
357
+ }
358
+
287
359
  if (need.state === "unmigrated") {
288
360
  return {
289
361
  title: "magus uses profiles",