magus-cli 7.5.2 → 7.5.4

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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -37,7 +37,6 @@ import {
37
37
  writableList,
38
38
  writableMap,
39
39
  } from "./settings-file.js";
40
- import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
41
40
 
42
41
  /**
43
42
  * What every reader below throws for a file that exists but does not parse.
@@ -149,11 +148,6 @@ export async function writeSettings(
149
148
  settings: ClaudeSettings,
150
149
  projectPath?: string,
151
150
  ): Promise<void> {
152
- // In a project with profiles this file is generated: never written where
153
- // git tracks it (see `tracked-live-files.ts`).
154
- await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [
155
- getSettingsPath(projectPath),
156
- ]);
157
151
  await refuseUnwritableFile(getSettingsPath(projectPath));
158
152
  const claudeDir = await ensureClaudeDir(projectPath);
159
153
  const settingsPath = path.join(claudeDir, SETTINGS_FILE);
@@ -197,7 +191,6 @@ export async function writeMcpConfig(
197
191
  projectPath?: string,
198
192
  ): Promise<void> {
199
193
  const mcpPath = getMcpConfigPath(projectPath);
200
- await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [mcpPath]);
201
194
  await refuseUnwritableFile(mcpPath);
202
195
  await fs.writeJson(mcpPath, config, { spaces: 2 });
203
196
  }
@@ -413,6 +406,34 @@ export function pickRegistryEntry(
413
406
  return entries.find((e) => e.scope === "user");
414
407
  }
415
408
 
409
+ /**
410
+ * The row a `claude plugin … --scope <scope>` run from `projectPath` owns: the
411
+ * user row for user scope; for project or local scope, the row of exactly that
412
+ * scope pinned to exactly this path.
413
+ *
414
+ * NOT {@link pickRegistryEntry}, which answers "what version applies here" and
415
+ * so falls back to inherited rows and then to the user row. That fallback asked
416
+ * the apply engine's "does this project hold a row?" guard made a plugin
417
+ * installed only at user scope, and turned on in project settings, look like it
418
+ * held a project row — so `claude plugin update --scope project` ran from a
419
+ * folder with none, and Claude Code updated some other project's row instead.
420
+ */
421
+ export function ownRegistryEntry(
422
+ entries: InstalledPluginEntry[] | undefined,
423
+ scope: "user" | "project" | "local",
424
+ projectPath: string,
425
+ ): InstalledPluginEntry | undefined {
426
+ if (!Array.isArray(entries)) return undefined;
427
+ if (scope === "user") return entries.find((e) => e.scope === "user");
428
+ const target = path.resolve(projectPath);
429
+ return entries.find(
430
+ (e) =>
431
+ e.scope === scope &&
432
+ e.projectPath !== undefined &&
433
+ path.resolve(e.projectPath) === target,
434
+ );
435
+ }
436
+
416
437
  async function overlayRegistryVersions(
417
438
  base: Record<string, string>,
418
439
  scope: "user" | "project" | "local",
@@ -1,63 +1,51 @@
1
1
  /**
2
- * Read-only gitignore check for the gitignore manager.
2
+ * The TUI's startup gitignore question.
3
3
  *
4
- * Called from the TUI on startup and from `doctor`. Resolves the manifest, runs
5
- * detection, and returns a concise warning when violations exist. Does NOT
6
- * auto-apply fixes — the user must open the Git State tab and confirm. This
7
- * mirrors the plugin-version-mismatch flow, where the check only *reports* and
8
- * the TUI provides the interactive fix dialog.
4
+ * Asked on every open (and again after a first adoption). Resolves the
5
+ * manifest — including, in a project with profiles, the files magus generates
6
+ * from `.claude/profiles.json` — runs detection, and reports. Nothing here
7
+ * applies a fix: the startup question applies the in-place fix only when the
8
+ * user picks it (`applyStartupFixes`), and every other destructive fix is
9
+ * confirmed per item in the Git State tab.
9
10
  */
10
11
 
11
12
  import type { Violation } from "../types/gitignore.js";
12
- import { detectViolations } from "./gitignore-detector.js";
13
- import { formatViolationSummary } from "./gitignore-fixer.js";
14
- import { resolveGitignore } from "./gitignore-resolver.js";
13
+ import { loadGitignoreState } from "./gitignore-service.js";
15
14
 
16
- export interface GitignoreCheckResult {
15
+ /** What the TUI's startup gitignore question needs to show. */
16
+ export interface StartupGitignoreNeed {
17
17
  violationCount: number;
18
- warnings: string[];
19
- }
20
-
21
- export async function checkGitignore(
22
- cwd: string,
23
- ): Promise<GitignoreCheckResult> {
24
- const warnings: string[] = [];
25
- try {
26
- const resolved = await resolveGitignore(cwd, {
27
- logger: (m) => warnings.push(m),
28
- });
29
- const violations = await detectViolations(cwd, resolved);
30
- return {
31
- violationCount: violations.length,
32
- warnings,
33
- };
34
- } catch (err) {
35
- warnings.push(
36
- `[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`,
37
- );
38
- return { violationCount: 0, warnings };
39
- }
18
+ /** Missing `.gitignore` entries — the additive fix. */
19
+ safeCount: number;
20
+ /**
21
+ * Files magus generates from `.claude/profiles.json` that git tracks — the
22
+ * ones the startup fix untracks in place.
23
+ */
24
+ generated: string[];
40
25
  }
41
26
 
42
27
  /**
43
- * One-line summary suitable for stderr. Returns null when there's
44
- * nothing to warn about.
28
+ * The startup gitignore question, or null when there is nothing to ask.
29
+ *
30
+ * Asked fresh on every call — the TUI calls it on every open, and again after
31
+ * a first adoption — and nothing remembers an earlier answer: a tracked
32
+ * generated file is asked about until it is fixed.
45
33
  */
46
- export function formatGitignoreWarning(
47
- result: GitignoreCheckResult,
48
- ): string | null {
49
- if (result.violationCount === 0) return null;
50
- return `⚠ ${result.violationCount} git state issue(s) detected. Run: magus → Git State tab to review and fix`;
34
+ export async function startupGitignoreNeed(
35
+ cwd: string,
36
+ ): Promise<StartupGitignoreNeed | null> {
37
+ const { violations } = await loadGitignoreState(cwd);
38
+ if (violations.length === 0) return null;
39
+ return {
40
+ violationCount: violations.length,
41
+ safeCount: violations.filter((v) => v.severity === "safe").length,
42
+ generated: generatedTracked(violations).map((v) => v.path),
43
+ };
51
44
  }
52
45
 
53
- /**
54
- * Verbose multi-line summary, used by the Git State tab when the user wants
55
- * to know what's wrong before deciding what to do.
56
- */
57
- export async function describeViolations(
58
- cwd: string,
59
- violations: Violation[],
60
- ): Promise<string> {
61
- void cwd;
62
- return formatViolationSummary(violations);
46
+ /** The violations the startup fix handles in place: tracked generated files. */
47
+ export function generatedTracked(violations: Violation[]): Violation[] {
48
+ return violations.filter(
49
+ (v) => v.kind === "tracked-but-should-ignore" && v.source === "profile",
50
+ );
63
51
  }
@@ -5,6 +5,10 @@
5
5
  * 1. Built-in defaults (shipped with magus)
6
6
  * 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
7
7
  * 3. Project manifest at <cwd>/.claude/gitignore.json
8
+ * 4. Profile: in a project with profiles, the files magus generates from
9
+ * `.claude/profiles.json` (passed in as `opts.profile`). Highest, because
10
+ * those files are rewritten per developer — tracking one cannot be right
11
+ * whatever another manifest says.
8
12
  *
9
13
  * `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
10
14
  * tier, the same pattern cannot appear in both `ignore` and `track` — that
@@ -33,9 +37,11 @@ export interface ResolveOptions {
33
37
  homeDir?: string;
34
38
  /** Optional logger for non-fatal warnings (malformed JSON, missing template). */
35
39
  logger?: (msg: string) => void;
40
+ /** The profile tier — see `profileGitignoreManifest`. */
41
+ profile?: GitignoreManifest | null;
36
42
  }
37
43
 
38
- const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
44
+ const TIER_ORDER: Tier[] = ["builtin", "global", "project", "profile"];
39
45
 
40
46
  export async function resolveGitignore(
41
47
  projectCwd: string,
@@ -59,6 +65,9 @@ export async function resolveGitignore(
59
65
  const projectManifest = await readManifest(projectPath, log);
60
66
  if (projectManifest) tierManifests.set("project", projectManifest);
61
67
 
68
+ // 4. The files the profile model generates
69
+ if (opts.profile) tierManifests.set("profile", opts.profile);
70
+
62
71
  // Apply `extends` for each tier (templates count as part of their tier)
63
72
  for (const tier of TIER_ORDER) {
64
73
  const m = tierManifests.get(tier);
@@ -28,6 +28,7 @@ import {
28
28
  } from "./gitignore-fixer.js";
29
29
  import { resolveGitignore } from "./gitignore-resolver.js";
30
30
  import { readSettingsFile, writableList } from "./settings-file.js";
31
+ import { profileGitignoreManifest } from "./tracked-live-files.js";
31
32
 
32
33
  export interface GitignoreState {
33
34
  resolved: ResolvedManifest;
@@ -40,11 +41,77 @@ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
40
41
  const warnings: string[] = [];
41
42
  const resolved = await resolveGitignore(cwd, {
42
43
  logger: (m) => warnings.push(m),
44
+ profile: await profileGitignoreManifest(cwd),
43
45
  });
44
46
  const violations = await detectViolations(cwd, resolved);
45
47
  return { resolved, violations, warnings };
46
48
  }
47
49
 
50
+ export interface StartupFixResult {
51
+ /** Generated files removed from the index — staged, not committed. */
52
+ untracked: string[];
53
+ /** `.gitignore` entries added. */
54
+ appended: string[];
55
+ /** Files that could not be untracked, with git's reason. */
56
+ failed: Array<{ path: string; reason: string }>;
57
+ }
58
+
59
+ /**
60
+ * The startup question's fix, applied in place: every file magus generates
61
+ * from `.claude/profiles.json` that git tracks is removed from the index
62
+ * (`git rm --cached`, the existing tracked-but-should-ignore fix, which also
63
+ * makes sure `.gitignore` ignores it), and the additive `.gitignore` fixes
64
+ * are applied too. Every other violation is left for the Git State tab.
65
+ */
66
+ export async function applyStartupFixes(
67
+ cwd: string,
68
+ ): Promise<StartupFixResult> {
69
+ const { resolved, violations } = await loadGitignoreState(cwd);
70
+ const generatedRules = resolved.rules
71
+ .filter((r) => r.source === "profile" && r.action === "ignore")
72
+ .map((r) => r.pattern);
73
+ const result: StartupFixResult = { untracked: [], appended: [], failed: [] };
74
+ for (const v of violations) {
75
+ if (v.kind !== "tracked-but-should-ignore" || v.source !== "profile") {
76
+ continue;
77
+ }
78
+ const rule =
79
+ generatedRules.find(
80
+ (p) => p === v.path || (p.endsWith("/") && v.path.startsWith(p)),
81
+ ) ?? v.path;
82
+ try {
83
+ await coreApplyDestructiveFix(cwd, v, rule);
84
+ result.untracked.push(v.path);
85
+ } catch (error) {
86
+ result.failed.push({
87
+ path: v.path,
88
+ reason: error instanceof Error ? error.message : String(error),
89
+ });
90
+ }
91
+ }
92
+ result.appended = (await coreApplySafeFixes(cwd, violations)).appended;
93
+ return result;
94
+ }
95
+
96
+ /** What {@link applyStartupFixes} did, for the message the TUI shows. */
97
+ export function describeStartupFix(result: StartupFixResult): string {
98
+ const lines: string[] = [];
99
+ if (result.untracked.length > 0) {
100
+ lines.push(
101
+ "Stopped tracking (the removal is staged — commit it):",
102
+ ...result.untracked.map((p) => ` ${p}`),
103
+ ' git commit -m "Stop tracking generated Claude Code config"',
104
+ );
105
+ }
106
+ if (result.appended.length > 0) {
107
+ lines.push("Added to .gitignore:", ...result.appended.map((p) => ` ${p}`));
108
+ }
109
+ for (const f of result.failed) {
110
+ lines.push(`Could not untrack ${f.path}: ${f.reason}`);
111
+ }
112
+ return lines.length > 0 ? lines.join("\n") : "Nothing needed changing.";
113
+ }
114
+
48
115
  export async function applyAllSafeFixes(
49
116
  cwd: string,
50
117
  violations: Violation[],
@@ -7,6 +7,7 @@
7
7
  * structural subset of a v2 manifest entry, so migration is a version bump.
8
8
  */
9
9
 
10
+ import os from "node:os";
10
11
  import path from "node:path";
11
12
  import fs from "fs-extra";
12
13
  import {
@@ -41,6 +42,36 @@ export function getManifestPath(projectPath?: string): string {
41
42
  return path.join(base, MANIFEST_FILE);
42
43
  }
43
44
 
45
+ /**
46
+ * The nearest ANCESTOR directory holding a `.claude/profiles.json`, searched no
47
+ * higher than the repository root — so magus can say so when it runs in a
48
+ * subdirectory.
49
+ *
50
+ * magus manages the directory it runs in because that is the project Claude Code
51
+ * reads: launched in `tools/magus`, it watches `tools/magus/.claude/settings.json`
52
+ * and never the root's (MEASURED with `--debug-file`, Claude Code 2.1.281). A
53
+ * root profiles.json is therefore not this directory's — but someone who meant
54
+ * the root has to hear that it exists before creating a second one here.
55
+ *
56
+ * A directory holding `.git` (file or folder) is a repository root, a worktree's
57
+ * included: nothing above it is searched, or a worktree under
58
+ * `<repo>/.claude/worktrees/` would find the main checkout's file. The home
59
+ * directory is never checked — its `.claude/` is Claude Code's user config.
60
+ */
61
+ export async function findEnclosingManifest(
62
+ projectPath: string,
63
+ ): Promise<string | null> {
64
+ const home = os.homedir();
65
+ let dir = path.resolve(projectPath);
66
+ for (;;) {
67
+ if (await fs.pathExists(path.join(dir, ".git"))) return null;
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir || parent === home) return null;
70
+ dir = parent;
71
+ if (await fs.pathExists(path.join(dir, MANIFEST_FILE))) return dir;
72
+ }
73
+ }
74
+
44
75
  /** An empty, valid v2 manifest. */
45
76
  export function emptyManifest(): ProfileManifest {
46
77
  return { version: 2, profiles: {} };
@@ -28,15 +28,17 @@
28
28
  * silently. `getAvailablePlugins` resolves all three scopes and hands back both facts per
29
29
  * scope, which is all this module takes from it.
30
30
  *
31
- * The test on those facts is `servesAMate`, below, and it is deliberately NOT
32
- * `plugin-manager`'s `isInstalledInScope` — that one answers a narrower question. See the
33
- * comment on the predicate for why the two must not share a helper.
31
+ * The test on those facts is `servesAMate`, below, and it is built from `plugin-manager`'s
32
+ * own helpers rather than reading either fact itself, so a mate row and the plugin list
33
+ * cannot disagree about what "installed" means.
34
34
  */
35
35
 
36
36
  import {
37
37
  type PluginInfo,
38
38
  type ScopeStatus,
39
39
  getAvailablePlugins,
40
+ isInstalledInScope,
41
+ scopeFilesMissing,
40
42
  } from "./plugin-manager.js";
41
43
 
42
44
  /** The plugin that puts claudish's model tools in reach. */
@@ -71,11 +73,23 @@ export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
71
73
  }
72
74
 
73
75
  /**
74
- * True when `multimodel@magus` is installed in ANY scope.
76
+ * Can this scope actually serve a mate? Installed there, with its files present.
77
+ *
78
+ * Installed alone is not enough. Files missing is installed with no version: Claude Code
79
+ * wants to load the plugin and has nothing to load, so claudish is not reachable through it,
80
+ * and a row drawn on the strength of it promises routing that cannot happen — the one thing
81
+ * these rows must never claim.
82
+ */
83
+ function servesAMate(scope: ScopeStatus | undefined): boolean {
84
+ return isInstalledInScope(scope) && !scopeFilesMissing(scope);
85
+ }
86
+
87
+ /**
88
+ * True when `multimodel@magus` is installed, with its files, in ANY scope.
75
89
  *
76
90
  * Any scope, because a mate is served by whatever Claude Code has loaded when the agent is
77
91
  * spawned, and that is the union of the three — a user-scope install serves a project that
78
- * enables nothing of its own.
92
+ * installs nothing of its own.
79
93
  *
80
94
  * FAILS CLOSED. This reaches the marketplace resolution path, which does network work and can
81
95
  * time out; a failure means "we do not know", and the honest rendering of not knowing is the
@@ -83,31 +97,11 @@ export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
83
97
  * rows on screen on the strength of an error.
84
98
  *
85
99
  * `listPlugins` is injected the same way `buildSettingsPatch` takes its `resolveFullId`: the
86
- * DECISION here — which scopes count, what an enabled-but-not-installed plugin means, what a
87
- * thrown error means — is the part worth testing, and it cannot be tested through a function
88
- * that reaches the network and a real machine's registry. The default is the real one, so no
89
- * caller has to know the seam exists.
100
+ * DECISION here — which scopes count, what files missing means, what a thrown error means —
101
+ * is the part worth testing, and it cannot be tested through a function that reaches the
102
+ * network and a real machine's registry. The default is the real one, so no caller has to
103
+ * know the seam exists.
90
104
  */
91
- /**
92
- * Can this scope actually serve a mate? Both facts, not either one.
93
- *
94
- * Deliberately NOT `isInstalledInScope`, which asks a narrower question: that one means the
95
- * files are on disk, and says so — it dropped `enabled` from its test on purpose, so the
96
- * plugin list would stop offering to install what was already there.
97
- *
98
- * Presence on disk is not what this module needs. A plugin the user has switched off is not
99
- * loaded, so claudish is not reachable through it, so the routing a mate row advertises
100
- * cannot happen — which is the one thing these rows must never claim. The other half matters
101
- * for the mirror-image state: a settings flag with no registry version is the broken
102
- * enabled-but-not-installed case, and a row drawn on the strength of it promises a plugin
103
- * that never loaded.
104
- *
105
- * So both, and locally, rather than reaching for a shared helper whose meaning is owned by a
106
- * different question.
107
- */
108
- function servesAMate(scope: ScopeStatus | undefined): boolean {
109
- return !!scope?.enabled && !!scope.version;
110
- }
111
105
 
112
106
  export async function areMatesAvailable(
113
107
  projectPath?: string,