magus-cli 7.5.0 → 7.5.1

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 (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +105 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +145 -0
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +27 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +87 -2
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
package/src/cli/doctor.ts CHANGED
@@ -46,16 +46,17 @@ import {
46
46
  type RepoRootCache,
47
47
  pickLoadedEntry,
48
48
  } from "../services/plugin-resolution.js";
49
- import {
50
- trackedGeneratedFiles,
51
- untrackCommand,
52
- } from "../services/profile-activate.js";
49
+ import { trackedGeneratedFiles } from "../services/profile-activate.js";
53
50
  import {
54
51
  LIVE_FILE_KEYS,
55
52
  liveKind,
56
53
  livePath,
57
54
  readActiveMarker,
58
55
  } from "../services/profile-live.js";
56
+ import {
57
+ describeDependencies,
58
+ renderProfile,
59
+ } from "../services/profile-render.js";
59
60
  import { inspectManagedSkills } from "../services/profile-skills.js";
60
61
  import {
61
62
  type ActiveProfileCheck,
@@ -66,6 +67,10 @@ import {
66
67
  switchHint,
67
68
  } from "../services/profile-state.js";
68
69
  import { resolveAllProfiles } from "../services/resolver.js";
70
+ import {
71
+ GitCheckError,
72
+ untrackCommand,
73
+ } from "../services/tracked-live-files.js";
69
74
  import type {
70
75
  ClaudeSettings,
71
76
  InstalledPluginsRegistry,
@@ -529,13 +534,41 @@ export async function reportProfileState(
529
534
  } else if (check.state === "active") {
530
535
  console.log(`✓ Active profile "${check.profile}" is in place.`);
531
536
  }
537
+ // Plugin dependencies: a dependency the profile turns off stops a plugin
538
+ // loading; a plugin.json that cannot be read leaves its dependencies
539
+ // unknown, and they were not enabled.
540
+ const manifest = await readManifest(projectPath);
541
+ const { dependencies } = manifest.profiles[check.profile]
542
+ ? await renderProfile(manifest, check.profile, projectPath)
543
+ : { dependencies: undefined };
544
+ for (const line of describeDependencies(
545
+ dependencies && { ...dependencies, unreadable: [] },
546
+ check.profile,
547
+ )) {
548
+ console.log(`✗ ${line}`);
549
+ unfixable++;
550
+ }
551
+ for (const line of describeDependencies(
552
+ dependencies && { ...dependencies, blocked: [] },
553
+ check.profile,
554
+ )) {
555
+ console.log(`⚠ ${line}`);
556
+ }
532
557
  }
533
558
 
534
559
  // A .gitignore entry does not untrack a file committed before it. Report
535
560
  // only: magus never runs git rm.
536
- for (const tracked of await trackedGeneratedFiles(projectPath)) {
561
+ let tracked: string[] = [];
562
+ try {
563
+ tracked = await trackedGeneratedFiles(projectPath);
564
+ } catch (error) {
565
+ if (!(error instanceof GitCheckError)) throw error;
566
+ console.log(`✗ ${error.message}`);
567
+ unfixable++;
568
+ }
569
+ for (const rel of tracked) {
537
570
  console.log(
538
- `✗ ${tracked} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(tracked)}\`, then commit.`,
571
+ `✗ ${rel} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(rel)}\`, then commit.`,
539
572
  );
540
573
  unfixable++;
541
574
  }
@@ -27,6 +27,10 @@ import {
27
27
  } from "../services/install-plan.js";
28
28
  import { readManifest, validateManifest } from "../services/manifest.js";
29
29
  import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
30
+ import {
31
+ clearMarketplaceCache,
32
+ readInstalledVersionForScope,
33
+ } from "../services/plugin-manager.js";
30
34
  import { reportDisplaced } from "../services/profile-activate.js";
31
35
  import { activeProfile } from "../services/profile-live.js";
32
36
  import {
@@ -40,6 +44,7 @@ import {
40
44
  binInstallCommand,
41
45
  detectToolchains,
42
46
  } from "../services/toolchain.js";
47
+ import { bringLoadForward } from "../services/update-engine.js";
43
48
  import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
44
49
  import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
45
50
  import { runCommand, runShellScript } from "../utils/run.js";
@@ -155,6 +160,41 @@ async function installPlugins(closure: ResolvedClosure): Promise<void> {
155
160
  }
156
161
  }
157
162
 
163
+ /**
164
+ * After the installs: every plugin must LOAD the version installed here, not
165
+ * an older one from a registry row this repository's other checkouts left
166
+ * first in line. Where it does not, the same stale-load repair `magus update`
167
+ * chains is run, and reported either way.
168
+ */
169
+ async function bringLoadsForward(
170
+ pluginIds: readonly string[],
171
+ projectPath: string,
172
+ ): Promise<void> {
173
+ for (const pluginId of pluginIds) {
174
+ const here = await readInstalledVersionForScope(
175
+ pluginId,
176
+ "project",
177
+ projectPath,
178
+ );
179
+ if (!here) continue;
180
+ const forward = await bringLoadForward(pluginId, here, projectPath);
181
+ if (!forward) continue;
182
+ if (forward.ok) {
183
+ console.log(
184
+ `✓ ${pluginId}: Claude Code now loads v${here} here (${forward.rewritten} older record${
185
+ forward.rewritten === 1 ? "" : "s"
186
+ } moved forward)`,
187
+ );
188
+ } else {
189
+ console.warn(
190
+ `⚠ ${pluginId}: installed v${here}, but Claude Code still loads v${
191
+ forward.loaded ?? "an older version"
192
+ } here — ${forward.error ?? "the older records could not be moved forward"}`,
193
+ );
194
+ }
195
+ }
196
+ }
197
+
158
198
  async function installBins(bins: ResolvedBin[]): Promise<void> {
159
199
  for (const bin of bins) {
160
200
  if (await resolveExecutable(bin.name)) {
@@ -261,10 +301,13 @@ export async function runInstallCommand(
261
301
  return 1;
262
302
  }
263
303
 
264
- // Closure: a single profile, or the union of all.
265
- const closure = flags.profile
266
- ? await resolveProfile(manifest, flags.profile)
267
- : await resolveAllProfiles(manifest);
304
+ // Closure: a single profile, or the union of all — with every plugin the
305
+ // enabled ones depend on.
306
+ const resolveClosure = () =>
307
+ flags.profile
308
+ ? resolveProfile(manifest, flags.profile)
309
+ : resolveAllProfiles(manifest);
310
+ let closure = await resolveClosure();
268
311
 
269
312
  if (flags.check) {
270
313
  return runCheck(closure, manifest.strictVersions === true);
@@ -295,6 +338,25 @@ export async function runInstallCommand(
295
338
 
296
339
  await ensureToolchains(closure, flags.yes);
297
340
  await registerClosureMarketplaces(closure.marketplaces);
341
+ // A marketplace added just now carries the plugin.json files that name
342
+ // each plugin's dependencies; resolve again so those are installed too.
343
+ clearMarketplaceCache();
344
+ const before = new Set(Object.keys(closure.plugins));
345
+ closure = await resolveClosure();
346
+ const found = Object.keys(closure.plugins).filter((id) => !before.has(id));
347
+ if (found.length > 0) {
348
+ console.log(
349
+ `Also installing what those plugins depend on: ${found.join(", ")}`,
350
+ );
351
+ }
352
+ const unreadable = closure.dependencies?.unreadable ?? [];
353
+ if (unreadable.length > 0) {
354
+ console.warn(
355
+ `⚠ Could not read the plugin.json of ${unreadable.join(", ")} — whatever ${
356
+ unreadable.length === 1 ? "it depends" : "they depend"
357
+ } on is not installed by this run`,
358
+ );
359
+ }
298
360
 
299
361
  const current = await activeProfile(projectPath);
300
362
  const activeId =
@@ -326,8 +388,10 @@ export async function runInstallCommand(
326
388
  // meantime is kept, only the install's own records are discarded, and a
327
389
  // Ctrl-C leaves the flag for the next command to do the same.
328
390
  await withCliBookkeeping(projectPath, () => installPlugins(closure), {
391
+ installable: Object.keys(closure.plugins),
329
392
  report: (line) => console.log(noteLine(line)),
330
393
  });
394
+ await bringLoadsForward(Object.keys(closure.plugins), projectPath);
331
395
  await installBins(closure.bins);
332
396
 
333
397
  if (activeId) {
package/src/cli/router.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { ThemeMode } from "@opentui/core";
10
10
  import { SettingsParseError } from "../services/settings-file.js";
11
+ import { LiveFileRefusal } from "../services/tracked-live-files.js";
11
12
  import { checkForUpdates } from "../services/version-check.js";
12
13
  import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
13
14
  import { profileGate, reportUnparseable } from "./bootstrap.js";
@@ -223,6 +224,12 @@ export async function route(
223
224
  reportUnparseable(error);
224
225
  return HANDLED(1);
225
226
  }
227
+ // A live file git tracks (or git could not say): nothing was written,
228
+ // and the message says what to do. A refusal, not a crash.
229
+ if (error instanceof LiveFileRefusal) {
230
+ console.error(error.message);
231
+ return HANDLED(1);
232
+ }
226
233
  throw error;
227
234
  }
228
235
  }
package/src/cli/update.ts CHANGED
@@ -607,6 +607,9 @@ export async function runUpdateCommand(
607
607
  projectPath,
608
608
  () => applyPlan(plan, projectPath, { report: skills.report }),
609
609
  {
610
+ // Only the active profile's plugins are this run's to install, so
611
+ // only their records are the CLI's.
612
+ installable: Object.keys(closure.plugins),
610
613
  beforeReconcile: () =>
611
614
  recordRefetchedSkills(projectPath, skills.fetched()),
612
615
  report: (line) => reconciledLines.push(line),
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
+ import path from "node:path";
13
14
  import { promisify } from "node:util";
14
15
  import { defaultMarketplaces } from "../data/marketplaces.js";
15
16
  import { which } from "../utils/command-utils.js";
@@ -19,6 +20,7 @@ import {
19
20
  removeLocalInstalledPluginVersion,
20
21
  } from "./claude-settings.js";
21
22
  import { removeInstalledPluginVersion } from "./plugin-manager.js";
23
+ import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
22
24
 
23
25
  const execFileAsync = promisify(execFile);
24
26
 
@@ -138,6 +140,23 @@ async function execPluginCommand(
138
140
  }
139
141
  }
140
142
 
143
+ /**
144
+ * Every `claude plugin … --scope project` command writes the project's
145
+ * `.claude/settings.json` (its enabledPlugins entry). In a project with
146
+ * profiles that file is generated; where git tracks it, refuse — before the
147
+ * CLI runs — rather than let the CLI write it (see `tracked-live-files.ts`).
148
+ * The CLI works on the current directory.
149
+ */
150
+ async function refuseProjectSettingsWrite(
151
+ scope: PluginScope,
152
+ projectPath: string = process.cwd(),
153
+ ): Promise<void> {
154
+ if (scope !== "project") return;
155
+ await refuseTrackedLiveFiles(projectPath, [
156
+ path.join(".claude", "settings.json"),
157
+ ]);
158
+ }
159
+
141
160
  /**
142
161
  * Install a plugin using claude CLI
143
162
  * Handles enabling + version tracking + cache copy in one shot.
@@ -154,6 +173,7 @@ export async function installPlugin(
154
173
  pluginId: string,
155
174
  scope: PluginScope = "user",
156
175
  ): Promise<void> {
176
+ await refuseProjectSettingsWrite(scope);
157
177
  // 60s, matching update and repair. Before the install/update split this path
158
178
  // ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
159
179
  // would have quietly halved the time a fresh install — the SLOWEST of the
@@ -172,6 +192,7 @@ export async function uninstallPlugin(
172
192
  scope: PluginScope = "user",
173
193
  projectPath?: string,
174
194
  ): Promise<void> {
195
+ await refuseProjectSettingsWrite(scope, projectPath);
175
196
  try {
176
197
  await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
177
198
  } catch {
@@ -224,6 +245,7 @@ export async function enablePlugin(
224
245
  pluginId: string,
225
246
  scope: PluginScope = "user",
226
247
  ): Promise<void> {
248
+ await refuseProjectSettingsWrite(scope);
227
249
  await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
228
250
  }
229
251
 
@@ -234,6 +256,7 @@ export async function disablePlugin(
234
256
  pluginId: string,
235
257
  scope: PluginScope = "user",
236
258
  ): Promise<void> {
259
+ await refuseProjectSettingsWrite(scope);
237
260
  await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
238
261
  }
239
262
 
@@ -276,6 +299,7 @@ export async function updatePlugin(
276
299
  pluginId: string,
277
300
  scope: PluginScope = "user",
278
301
  ): Promise<void> {
302
+ await refuseProjectSettingsWrite(scope);
279
303
  await execPluginCommand("update", pluginId, scope, 60000);
280
304
  }
281
305
 
@@ -37,6 +37,7 @@ import {
37
37
  writableList,
38
38
  writableMap,
39
39
  } from "./settings-file.js";
40
+ import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
40
41
 
41
42
  /**
42
43
  * What every reader below throws for a file that exists but does not parse.
@@ -148,6 +149,11 @@ export async function writeSettings(
148
149
  settings: ClaudeSettings,
149
150
  projectPath?: string,
150
151
  ): 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
+ ]);
151
157
  await refuseUnwritableFile(getSettingsPath(projectPath));
152
158
  const claudeDir = await ensureClaudeDir(projectPath);
153
159
  const settingsPath = path.join(claudeDir, SETTINGS_FILE);
@@ -191,6 +197,7 @@ export async function writeMcpConfig(
191
197
  projectPath?: string,
192
198
  ): Promise<void> {
193
199
  const mcpPath = getMcpConfigPath(projectPath);
200
+ await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [mcpPath]);
194
201
  await refuseUnwritableFile(mcpPath);
195
202
  await fs.writeJson(mcpPath, config, { spaces: 2 });
196
203
  }
@@ -2143,6 +2150,26 @@ export async function repairAllStaleLoads(
2143
2150
  return out;
2144
2151
  }
2145
2152
 
2153
+ /**
2154
+ * The version Claude Code's loader resolves for `pluginId` in `projectPath`:
2155
+ * the first relevant registry row whose installPath exists (`pickLoadedEntry`).
2156
+ * Null when no row resolves, or the registry cannot be read.
2157
+ */
2158
+ export async function loadedVersionHere(
2159
+ pluginId: string,
2160
+ projectPath: string,
2161
+ root: string = pluginsDir(),
2162
+ ): Promise<string | null> {
2163
+ try {
2164
+ const registry = await readInstalledPluginsRegistry(root);
2165
+ const rows = registry.plugins?.[pluginId];
2166
+ if (!Array.isArray(rows) || rows.length === 0) return null;
2167
+ return pickLoadedEntry(rows, projectPath)?.version ?? null;
2168
+ } catch {
2169
+ return null;
2170
+ }
2171
+ }
2172
+
2146
2173
  /**
2147
2174
  * What one {@link repairStaleLoad} attempt did, and whether it worked.
2148
2175
  *
@@ -24,36 +24,67 @@
24
24
  */
25
25
 
26
26
  import { spawn } from "node:child_process";
27
+ import { lstat } from "node:fs/promises";
27
28
  import path from "node:path";
28
29
 
29
30
  function git(
30
31
  cwd: string,
31
32
  args: string[],
32
- ): Promise<{ code: number; out: string }> {
33
+ ): Promise<{ code: number; out: string; err: string }> {
33
34
  return new Promise((resolve) => {
34
35
  const child = spawn("git", args, {
35
36
  cwd,
36
- stdio: ["ignore", "pipe", "ignore"],
37
+ stdio: ["ignore", "pipe", "pipe"],
37
38
  });
38
39
  let out = "";
40
+ let err = "";
39
41
  child.stdout.on("data", (d) => {
40
42
  out += String(d);
41
43
  });
44
+ child.stderr.on("data", (d) => {
45
+ err += String(d);
46
+ });
47
+ const settle = (code: number, text: string, why: string) => {
48
+ clearTimeout(timer);
49
+ resolve({ code, out: text, err: why });
50
+ };
42
51
  // Never let a wedged git block the UI.
43
52
  const timer = setTimeout(() => {
44
53
  child.kill();
45
- resolve({ code: -1, out: "" });
54
+ settle(-1, "", `git ${args[0]} did not answer within 5 seconds`);
46
55
  }, 5000);
47
56
  timer.unref?.();
48
- const settle = (code: number, text: string) => {
49
- clearTimeout(timer);
50
- resolve({ code, out: text });
51
- };
52
- child.on("error", () => settle(-1, ""));
53
- child.on("close", (code) => settle(code ?? -1, out.trim()));
57
+ child.on("error", (error) =>
58
+ settle(-1, "", `could not run git: ${error.message}`),
59
+ );
60
+ child.on("close", (code) => settle(code ?? -1, out.trim(), err.trim()));
54
61
  });
55
62
  }
56
63
 
64
+ /**
65
+ * git was asked about a work tree and gave no answer: it timed out, is not
66
+ * installed, or refused the repository. `detail` is git's own message.
67
+ */
68
+ export class GitQueryError extends Error {
69
+ constructor(readonly detail: string) {
70
+ super(detail);
71
+ this.name = "GitQueryError";
72
+ }
73
+ }
74
+
75
+ /** Is `dir`, or a directory above it, a git work tree (holds a `.git`)? */
76
+ async function insideWorkTree(dir: string): Promise<boolean> {
77
+ for (let at = path.resolve(dir); ; at = path.dirname(at)) {
78
+ try {
79
+ await lstat(path.join(at, ".git"));
80
+ return true;
81
+ } catch {
82
+ // Not here; try the parent.
83
+ }
84
+ if (path.dirname(at) === at) return false;
85
+ }
86
+ }
87
+
57
88
  /**
58
89
  * Session cache. Keyed by resolved project path — the repository layout cannot
59
90
  * change under a running magus in any way worth tracking, and the resolution
@@ -65,6 +96,58 @@ export function clearWorktreeCache(): void {
65
96
  cache.clear();
66
97
  }
67
98
 
99
+ /**
100
+ * The paths (relative to `projectPath`) that git tracks. A path in `files` is
101
+ * tracked when git has exactly that entry — a symlink is an entry too; a path
102
+ * in `dirs` when git tracks any file under it. Empty outside a work tree.
103
+ *
104
+ * Throws {@link GitQueryError} when there IS a `.git` at or above
105
+ * `projectPath` and git still fails — a timeout, git missing, or git refusing
106
+ * the repository (`detected dubious ownership`, exit 128). "Nothing is
107
+ * tracked" is not an answer git gave there, and a caller that believed it
108
+ * would write a tracked file.
109
+ *
110
+ * The distinction matters for `.claude/skills`: as a directory of committed
111
+ * custom skills it has tracked files under it, which says nothing about
112
+ * whether the path ITSELF (an older layout's symlink) was committed.
113
+ */
114
+ export async function trackedByGit(
115
+ projectPath: string,
116
+ files: readonly string[],
117
+ dirs: readonly string[] = [],
118
+ ): Promise<Set<string>> {
119
+ const out = new Set<string>();
120
+ const relPaths = [...files, ...dirs];
121
+ if (relPaths.length === 0) return out;
122
+ // One call for every path: this runs on every magus command that touches
123
+ // profile state, and a spawn per path adds up. `ls-files` prints each
124
+ // tracked FILE matching the pathspecs, so a directory shows as its files.
125
+ const {
126
+ code,
127
+ out: listed,
128
+ err,
129
+ } = await git(projectPath, ["ls-files", "-z", "--", ...relPaths]);
130
+ if (code !== 0) {
131
+ if (!(await insideWorkTree(projectPath))) return out; // Nothing to track.
132
+ throw new GitQueryError(err || `git ls-files exited with code ${code}`);
133
+ }
134
+ const entries = new Set(listed.split("\0").filter(Boolean));
135
+ const norm = (rel: string) => rel.split(path.sep).join("/");
136
+ for (const rel of files) {
137
+ if (entries.has(norm(rel))) out.add(rel);
138
+ }
139
+ for (const rel of dirs) {
140
+ const prefix = `${norm(rel)}/`;
141
+ for (const entry of entries) {
142
+ if (entry.startsWith(prefix)) {
143
+ out.add(rel);
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
68
151
  /**
69
152
  * Is `relPath` (relative to `projectPath`) tracked by git? For a directory,
70
153
  * true when any file under it is tracked.