magus-cli 7.5.0 → 7.5.2

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 +265 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +342 -1
  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 +45 -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 +266 -8
  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
@@ -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
  }
@@ -1984,6 +1991,24 @@ function belongsToThisRepository(
1984
1991
  return repoRootForPath(entry.projectPath, cache) === cwdRoot;
1985
1992
  }
1986
1993
 
1994
+ /**
1995
+ * Is `entry` a row of the repository `projectPath` is in — one
1996
+ * {@link repairStaleLoad} may rewrite? The same predicate, for callers that
1997
+ * judge one row at a time.
1998
+ */
1999
+ export function rowBelongsToRepository(
2000
+ entry: InstalledPluginEntry,
2001
+ projectPath: string,
2002
+ ): boolean {
2003
+ const cache: RepoRootCache = new Map();
2004
+ return belongsToThisRepository(
2005
+ entry,
2006
+ projectPath,
2007
+ repoRootForPath(projectPath, cache),
2008
+ cache,
2009
+ );
2010
+ }
2011
+
1987
2012
  /** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
1988
2013
  export interface StaleLoad {
1989
2014
  pluginId: string;
@@ -2143,6 +2168,26 @@ export async function repairAllStaleLoads(
2143
2168
  return out;
2144
2169
  }
2145
2170
 
2171
+ /**
2172
+ * The version Claude Code's loader resolves for `pluginId` in `projectPath`:
2173
+ * the first relevant registry row whose installPath exists (`pickLoadedEntry`).
2174
+ * Null when no row resolves, or the registry cannot be read.
2175
+ */
2176
+ export async function loadedVersionHere(
2177
+ pluginId: string,
2178
+ projectPath: string,
2179
+ root: string = pluginsDir(),
2180
+ ): Promise<string | null> {
2181
+ try {
2182
+ const registry = await readInstalledPluginsRegistry(root);
2183
+ const rows = registry.plugins?.[pluginId];
2184
+ if (!Array.isArray(rows) || rows.length === 0) return null;
2185
+ return pickLoadedEntry(rows, projectPath)?.version ?? null;
2186
+ } catch {
2187
+ return null;
2188
+ }
2189
+ }
2190
+
2146
2191
  /**
2147
2192
  * What one {@link repairStaleLoad} attempt did, and whether it worked.
2148
2193
  *
@@ -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.
@@ -9,6 +9,17 @@
9
9
 
10
10
  import path from "node:path";
11
11
  import fs from "fs-extra";
12
+ import {
13
+ type FormattingOptions,
14
+ type JSONPath,
15
+ type Node as JsonNode,
16
+ applyEdits,
17
+ findNodeAtLocation,
18
+ format,
19
+ getNodeValue,
20
+ modify,
21
+ parseTree,
22
+ } from "jsonc-parser";
12
23
  import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
13
24
  import { validateModelsConfig } from "./models-core.js";
14
25
  import {
@@ -160,10 +171,216 @@ export async function writeManifest(
160
171
  projectPath?: string,
161
172
  ): Promise<void> {
162
173
  const filePath = getManifestPath(projectPath);
163
- // Read for the throw alone; the contents are about to be replaced.
164
- await readProfilesFile(filePath);
174
+ // The refusal, and the view every caller read: `readManifest`'s. Only what
175
+ // the caller changed in that view is written (see `writeJsonMinimal`).
176
+ // `version` is the file's own, so a v1 file is recorded as v2 on write.
177
+ const current = await readProfilesFile(filePath);
178
+ await writeJsonMinimal(filePath, manifest, {
179
+ ...migrateToV2(current),
180
+ version: current.version,
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Write `next` to a JSON file, changing only the values that differ from
186
+ * `view` — every other byte stays exactly as it was: formatting, key order,
187
+ * inline arrays, the trailing newline, and keys the caller never saw.
188
+ *
189
+ * `profiles.json` is committed and reviewed. Re-serialising the whole file on
190
+ * every write turned one absorbed plugin into a 63-line diff that expanded
191
+ * every inline array and dropped `"strictVersions": false` — a key the
192
+ * manifest type models only as `true`. So the write is an edit, not a dump.
193
+ *
194
+ * `view` is what the caller READ — the same projection of the file it then
195
+ * mutated (for `readManifest`, `migrateToV2` of the raw file, which omits
196
+ * `strictVersions: false` and unmodelled keys). Diffing `next` against that
197
+ * view, not against the raw file, is what makes a key the caller never saw
198
+ * "unchanged" rather than "deleted". The changed paths are then applied to the
199
+ * file's own text: objects key by key, anything else (arrays included) as a
200
+ * whole value, so a changed array is re-written and an unchanged one is not.
201
+ * Each edit keeps the style around it — see {@link editJsonText}.
202
+ *
203
+ * A file that does not exist yet (or is empty) is written as 2-space JSON.
204
+ */
205
+ export async function writeJsonMinimal(
206
+ filePath: string,
207
+ next: unknown,
208
+ view: unknown,
209
+ ): Promise<void> {
210
+ let text = "";
211
+ try {
212
+ text = await fs.readFile(filePath, "utf8");
213
+ } catch {
214
+ // Absent: written fresh below.
215
+ }
165
216
  await fs.ensureDir(path.dirname(filePath));
166
- await fs.writeJson(filePath, manifest, { spaces: 2 });
217
+ if (text.trim() === "") {
218
+ await fs.writeFile(filePath, `${JSON.stringify(next, null, 2)}\n`);
219
+ return;
220
+ }
221
+ const edits: Array<{ path: JSONPath; value: unknown }> = [];
222
+ diffJson(view, next, [], edits);
223
+ if (edits.length === 0) return;
224
+ const options: FormattingOptions = {
225
+ insertSpaces: !/^\t/m.test(text),
226
+ tabSize: detectIndent(text),
227
+ eol: text.includes("\r\n") ? "\r\n" : "\n",
228
+ };
229
+ for (const edit of edits) {
230
+ text = editJsonText(text, edit.path, edit.value, options);
231
+ }
232
+ await fs.writeFile(filePath, text);
233
+ }
234
+
235
+ const MULTILINE = /[\r\n]/;
236
+
237
+ /**
238
+ * Set (or, for `undefined`, remove) the value at `at` in JSON `text`, changing
239
+ * as few bytes as possible and keeping the style around the change:
240
+ *
241
+ * - a container written on one line (`{ "a": "x" }`) stays on one line — it
242
+ * is re-written inline, whole;
243
+ * - a value written on one line is replaced on that line;
244
+ * - anything else is jsonc-parser's edit, formatted only across the bytes it
245
+ * inserted plus the join on either side.
246
+ *
247
+ * jsonc-parser's own formatting option is not used: it re-formats every line
248
+ * an insertion or removal touches, so adding a key after an inline array
249
+ * expands that array — a line the write did not change.
250
+ */
251
+ function editJsonText(
252
+ text: string,
253
+ at: JSONPath,
254
+ value: unknown,
255
+ options: FormattingOptions,
256
+ ): string {
257
+ const root = parseTree(text);
258
+ const node = root ? findNodeAtLocation(root, at) : undefined;
259
+ const parent =
260
+ root && at.length > 0
261
+ ? findNodeAtLocation(root, at.slice(0, -1))
262
+ : undefined;
263
+ const source = (n: JsonNode) => text.slice(n.offset, n.offset + n.length);
264
+ const replace = (n: JsonNode, content: string) =>
265
+ text.slice(0, n.offset) + content + text.slice(n.offset + n.length);
266
+ const key = at[at.length - 1];
267
+
268
+ if (
269
+ parent?.type === "object" &&
270
+ typeof key === "string" &&
271
+ (parent.children?.length ?? 0) > 0 &&
272
+ !MULTILINE.test(source(parent))
273
+ ) {
274
+ const updated = { ...(getNodeValue(parent) as Record<string, unknown>) };
275
+ if (value === undefined) delete updated[key];
276
+ else updated[key] = value;
277
+ return replace(parent, inlineJson(updated));
278
+ }
279
+
280
+ if (
281
+ node &&
282
+ value !== undefined &&
283
+ !MULTILINE.test(source(node)) &&
284
+ (!isContainer(value) || isNonEmptyContainerNode(node))
285
+ ) {
286
+ return replace(node, inlineJson(value));
287
+ }
288
+
289
+ const [edit] = modify(text, at, value, {});
290
+ if (!edit) return text;
291
+ const next = applyEdits(text, [edit]);
292
+ const from = Math.max(0, edit.offset - 1);
293
+ const to = Math.min(next.length, edit.offset + edit.content.length + 1);
294
+ return applyEdits(
295
+ next,
296
+ format(
297
+ next,
298
+ { offset: from, length: to - from },
299
+ {
300
+ ...options,
301
+ keepLines: false,
302
+ },
303
+ ),
304
+ );
305
+ }
306
+
307
+ function isContainer(value: unknown): boolean {
308
+ return (
309
+ (Array.isArray(value) && value.length > 0) ||
310
+ (isPlainObject(value) && Object.keys(value).length > 0)
311
+ );
312
+ }
313
+
314
+ function isNonEmptyContainerNode(node: JsonNode): boolean {
315
+ return (
316
+ (node.type === "object" || node.type === "array") &&
317
+ (node.children?.length ?? 0) > 0
318
+ );
319
+ }
320
+
321
+ /** One-line JSON in the common inline style: `{ "a": 1 }`, `["x", "y"]`. */
322
+ function inlineJson(value: unknown): string {
323
+ if (Array.isArray(value)) return `[${value.map(inlineJson).join(", ")}]`;
324
+ if (isPlainObject(value)) {
325
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
326
+ if (entries.length === 0) return "{}";
327
+ return `{ ${entries
328
+ .map(([k, v]) => `${JSON.stringify(k)}: ${inlineJson(v)}`)
329
+ .join(", ")} }`;
330
+ }
331
+ return JSON.stringify(value);
332
+ }
333
+
334
+ /** The indent width of the first indented line, or 2. */
335
+ function detectIndent(text: string): number {
336
+ const match = /^( +)\S/m.exec(text);
337
+ return match ? (match[1] as string).length : 2;
338
+ }
339
+
340
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
341
+ return typeof value === "object" && value !== null && !Array.isArray(value);
342
+ }
343
+
344
+ /** Same meaning, whatever the key order. */
345
+ function sameJson(a: unknown, b: unknown): boolean {
346
+ const sort = (v: unknown): unknown => {
347
+ if (Array.isArray(v)) return v.map(sort);
348
+ if (isPlainObject(v)) {
349
+ const out: Record<string, unknown> = {};
350
+ for (const key of Object.keys(v).sort()) out[key] = sort(v[key]);
351
+ return out;
352
+ }
353
+ return v;
354
+ };
355
+ return JSON.stringify(sort(a)) === JSON.stringify(sort(b));
356
+ }
357
+
358
+ /**
359
+ * The edits that turn `before` into `after`: objects are walked key by key; a
360
+ * removed key is an edit to `undefined`; every other difference replaces the
361
+ * value at that path whole. Key order alone is not a difference.
362
+ */
363
+ function diffJson(
364
+ before: unknown,
365
+ after: unknown,
366
+ at: JSONPath,
367
+ out: Array<{ path: JSONPath; value: unknown }>,
368
+ ): void {
369
+ if (sameJson(before, after)) return;
370
+ if (isPlainObject(before) && isPlainObject(after)) {
371
+ for (const key of Object.keys(before)) {
372
+ if (!(key in after) || after[key] === undefined) {
373
+ out.push({ path: [...at, key], value: undefined });
374
+ }
375
+ }
376
+ for (const [key, value] of Object.entries(after)) {
377
+ if (value === undefined) continue;
378
+ if (key in before) diffJson(before[key], value, [...at, key], out);
379
+ else out.push({ path: [...at, key], value });
380
+ }
381
+ return;
382
+ }
383
+ out.push({ path: at, value: after });
167
384
  }
168
385
 
169
386
  export interface ManifestValidationError {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which plugins a plugin depends on, as its own plugin.json declares them.
3
+ *
4
+ * A plugin's `dependencies` must be ENABLED for it to load — Claude Code's
5
+ * loader refuses it otherwise ("Dependency "claudish@magus" is disabled —
6
+ * enable it or remove the dependency"). A profile renders every other
7
+ * profile's plugins as `false`, so without reading these a profile that
8
+ * enables dev@magus but not claudish@magus switched dev off in effect.
9
+ *
10
+ * The reading itself lives beside the other plugin.json readers
11
+ * (`resolvePluginDependencies` in `plugin-requires.ts`). This module is only
12
+ * the entry point, kept free of imports: the render reaches it on every
13
+ * command, and a test preload replaces it so no test ever reads the plugin
14
+ * files installed on the machine running it.
15
+ */
16
+
17
+ /**
18
+ * The plugin ids `pluginId` declares as dependencies, or null when no
19
+ * plugin.json for it can be read on this machine. `projectPath` picks the
20
+ * installed copy Claude Code loads there (default: the current directory).
21
+ */
22
+ export type PluginDependencyReader = (
23
+ pluginId: string,
24
+ projectPath?: string,
25
+ ) => Promise<string[] | null>;
26
+
27
+ let replacement: PluginDependencyReader | null = null;
28
+
29
+ /**
30
+ * Replace where dependencies are read from, or restore the real reader with
31
+ * null. For tests: the real one reads `~/.claude/plugins`, which differs on
32
+ * every machine.
33
+ */
34
+ export function setPluginDependencyReader(
35
+ reader: PluginDependencyReader | null,
36
+ ): void {
37
+ replacement = reader;
38
+ }
39
+
40
+ /** Read `pluginId`'s declared dependencies — see {@link PluginDependencyReader}. */
41
+ export async function readPluginDependencies(
42
+ pluginId: string,
43
+ projectPath?: string,
44
+ ): Promise<string[] | null> {
45
+ if (replacement) return replacement(pluginId, projectPath);
46
+ const { resolvePluginDependencies } = await import("./plugin-requires.js");
47
+ return resolvePluginDependencies(pluginId, projectPath);
48
+ }
@@ -17,7 +17,9 @@ import type {
17
17
  } from "../types/index.js";
18
18
  import { goBinaryName } from "../utils/go-module.js";
19
19
  import { parsePluginId } from "../utils/string-utils.js";
20
+ import { readInstalledPluginsRegistry } from "./claude-settings.js";
20
21
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
22
+ import { pickLoadedEntry } from "./plugin-resolution.js";
21
23
 
22
24
  const MARKETPLACES_DIR = (): string =>
23
25
  path.join(os.homedir(), ".claude", "plugins", "marketplaces");
@@ -160,28 +162,149 @@ export function parsePluginRequires(pluginJson: unknown): PluginRequires {
160
162
  return bin.length > 0 ? { bin } : {};
161
163
  }
162
164
 
165
+ /**
166
+ * The plugin.json at a plugin directory, parsed — null when there is none or
167
+ * it does not parse.
168
+ *
169
+ * `.claude-plugin/plugin.json` FIRST — that is the only location Claude Code's
170
+ * runtime loader reads, and the only one its documentation describes. The root
171
+ * fallback stays for third-party plugins whose authors put it there; such a
172
+ * manifest is inert as far as Claude Code is concerned, but magus can still
173
+ * read metadata out of it rather than reporting nothing.
174
+ */
175
+ export async function readPluginManifest(
176
+ pluginPath: string,
177
+ ): Promise<unknown | null> {
178
+ const candidates = [
179
+ path.join(pluginPath, ".claude-plugin", "plugin.json"),
180
+ path.join(pluginPath, "plugin.json"),
181
+ ];
182
+ for (const manifestPath of candidates) {
183
+ try {
184
+ if (await fs.pathExists(manifestPath)) {
185
+ return await fs.readJson(manifestPath);
186
+ }
187
+ } catch {
188
+ return null; // Present but malformed: never fall through to another.
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
163
194
  /** Read `requires` from a plugin.json at a known plugin directory. */
164
195
  export async function readPluginRequires(
165
196
  pluginPath: string,
166
197
  ): Promise<PluginRequires> {
167
- // `.claude-plugin/plugin.json` FIRST — that is the only location Claude Code's
168
- // runtime loader reads, and the only one its documentation describes. The root
169
- // fallback stays for third-party plugins whose authors put it there; such a manifest
170
- // is inert as far as Claude Code is concerned, but magus can still read metadata
171
- // out of it rather than reporting nothing.
172
- const manifestPath = path.join(pluginPath, ".claude-plugin", "plugin.json");
173
- try {
174
- if (await fs.pathExists(manifestPath)) {
175
- return parsePluginRequires(await fs.readJson(manifestPath));
198
+ // Unreadable/malformed — treat as no requirements.
199
+ return parsePluginRequires(await readPluginManifest(pluginPath));
200
+ }
201
+
202
+ /**
203
+ * The plugin ids a plugin.json's `dependencies` names, resolved the way
204
+ * Claude Code resolves them: an entry that names a marketplace
205
+ * (`"claudish@magus"`, or `{ "name": "claudish", "marketplace": "magus" }`)
206
+ * is taken as written; a bare name (`"claudish"`, `{ "name": "claudish" }`)
207
+ * is looked up in the DEPENDENT's marketplace — `claudish` declared by
208
+ * dev@magus is claudish@magus. Version constraints are not modelled here: a
209
+ * dependency is enabled, and installed at latest.
210
+ */
211
+ export function parsePluginDependencies(
212
+ pluginJson: unknown,
213
+ dependentId: string,
214
+ ): string[] {
215
+ if (!pluginJson || typeof pluginJson !== "object") return [];
216
+ const raw = (pluginJson as Record<string, unknown>).dependencies;
217
+ if (!Array.isArray(raw)) return [];
218
+ const home = parsePluginId(dependentId)?.marketplace;
219
+ const out = new Set<string>();
220
+ for (const item of raw) {
221
+ let name = "";
222
+ let marketplace: string | undefined;
223
+ if (typeof item === "string") {
224
+ name = item.trim();
225
+ } else if (item && typeof item === "object") {
226
+ const o = item as Record<string, unknown>;
227
+ if (typeof o.name === "string") name = o.name.trim();
228
+ if (typeof o.marketplace === "string" && o.marketplace.trim()) {
229
+ marketplace = o.marketplace.trim();
230
+ }
231
+ }
232
+ if (!name) continue;
233
+ if (name.lastIndexOf("@") > 0) {
234
+ out.add(name);
235
+ continue;
176
236
  }
177
- const rootLevel = path.join(pluginPath, "plugin.json");
178
- if (await fs.pathExists(rootLevel)) {
179
- return parsePluginRequires(await fs.readJson(rootLevel));
237
+ const at = marketplace ?? home;
238
+ // A bare id cannot be named in enabledPlugins, so it is not one to render.
239
+ if (at) out.add(`${name}@${at}`);
240
+ }
241
+ return [...out];
242
+ }
243
+
244
+ /** Where {@link resolvePluginDependencies} looks; replaced in tests. */
245
+ export interface PluginManifestSources {
246
+ /** The plugins directory holding the registry — see `pluginsDir`. */
247
+ root?: string;
248
+ /** The plugin's source in the local marketplace clone. */
249
+ clone?: (pluginId: string) => Promise<string | null>;
250
+ }
251
+
252
+ /**
253
+ * The directories that may hold `pluginId`'s plugin.json on this machine, in
254
+ * the order they are trusted:
255
+ *
256
+ * 1. the installed copy Claude Code LOADS in `projectPath` — the registry
257
+ * row its loader resolves there (`pickLoadedEntry`). Claude Code checks
258
+ * the dependencies of the version it loads, which may be older than the
259
+ * marketplace's latest;
260
+ * 2. its source in the local marketplace clone (the reader `requires.bin`
261
+ * uses), for a plugin not installed yet;
262
+ * 3. any other installed copy — where a plugin whose marketplace entry
263
+ * points at another repository lives.
264
+ */
265
+ async function pluginManifestDirs(
266
+ pluginId: string,
267
+ projectPath: string,
268
+ sources: PluginManifestSources,
269
+ ): Promise<string[]> {
270
+ const dirs: string[] = [];
271
+ const installed: string[] = [];
272
+ try {
273
+ const registry = await readInstalledPluginsRegistry(sources.root);
274
+ const rows = registry.plugins?.[pluginId];
275
+ if (Array.isArray(rows)) {
276
+ const loaded = pickLoadedEntry(rows, projectPath)?.installPath;
277
+ if (loaded) dirs.push(loaded);
278
+ for (const row of rows) {
279
+ if (typeof row.installPath === "string" && row.installPath) {
280
+ installed.push(row.installPath);
281
+ }
282
+ }
180
283
  }
181
284
  } catch {
182
- // Unreadable/malformed — treat as no requirements.
285
+ // No readable registry: the clone alone decides.
286
+ }
287
+ const clone = await (sources.clone ?? resolvePluginPath)(pluginId);
288
+ if (clone) dirs.push(clone);
289
+ return [...new Set([...dirs, ...installed])];
290
+ }
291
+
292
+ /**
293
+ * The plugin ids `pluginId` declares as dependencies (see
294
+ * {@link parsePluginDependencies}) in the copy Claude Code loads in
295
+ * `projectPath`, or null when no plugin.json for it can be read on this
296
+ * machine — reported by the caller, never guessed.
297
+ */
298
+ export async function resolvePluginDependencies(
299
+ pluginId: string,
300
+ projectPath: string = process.cwd(),
301
+ sources: PluginManifestSources = {},
302
+ ): Promise<string[] | null> {
303
+ for (const dir of await pluginManifestDirs(pluginId, projectPath, sources)) {
304
+ const manifest = await readPluginManifest(dir);
305
+ if (manifest !== null) return parsePluginDependencies(manifest, pluginId);
183
306
  }
184
- return {};
307
+ return null;
185
308
  }
186
309
 
187
310
  /**