magus-cli 7.4.1 → 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.
- package/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
package/src/services/manifest.ts
CHANGED
|
@@ -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
|
-
//
|
|
164
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -311,11 +528,10 @@ export async function updateProfileEntry(
|
|
|
311
528
|
* Merge `patch` into `profiles.<profile>.settings`, optionally removing the
|
|
312
529
|
* keys named in `unset`.
|
|
313
530
|
*
|
|
314
|
-
* Why this exists: `.claude/settings.json` is
|
|
315
|
-
* profile's
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* is the durable half of every settings write magus makes.
|
|
531
|
+
* Why this exists: `.claude/settings.json` is rendered from the active
|
|
532
|
+
* profile's entry, so a setting magus writes belongs in the entry — the next
|
|
533
|
+
* render reads it from there. The manifest is the durable half of every
|
|
534
|
+
* settings write magus makes.
|
|
319
535
|
*/
|
|
320
536
|
export async function mergeManifestSettings(
|
|
321
537
|
projectPath: string,
|
|
@@ -11,15 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The routing is a PROFILE property, committed in `.claude/profiles.json` under
|
|
13
13
|
* `profiles.<name>.models`. `.claude/models.json` is the generated view of it —
|
|
14
|
-
* a
|
|
15
|
-
*
|
|
16
|
-
* file (it walks up from the session's cwd looking for exactly that
|
|
17
|
-
* teammate gets the routing by pulling profiles.json
|
|
18
|
-
* install`.
|
|
14
|
+
* a real file rendered from the active profile when there is one, a plain
|
|
15
|
+
* hand-kept file when the project has no profiles. The hook reads the
|
|
16
|
+
* generated file (it walks up from the session's cwd looking for exactly that
|
|
17
|
+
* name); a teammate gets the routing by pulling profiles.json.
|
|
19
18
|
*
|
|
20
|
-
* Writing only the generated file would be silently temporary:
|
|
21
|
-
*
|
|
22
|
-
* disappear on the next install with nothing to explain why.
|
|
19
|
+
* Writing only the generated file would be silently temporary: every
|
|
20
|
+
* activation renders it again from the manifest.
|
|
23
21
|
*
|
|
24
22
|
* ## Why the full model id is resolved live
|
|
25
23
|
*
|
|
@@ -61,17 +59,17 @@ import {
|
|
|
61
59
|
computeModelsStatus,
|
|
62
60
|
validateModelsConfig,
|
|
63
61
|
} from "./models-core.js";
|
|
64
|
-
import {
|
|
65
|
-
import {
|
|
62
|
+
import { regenerateLiveFiles } from "./profile-activate.js";
|
|
63
|
+
import { activeProfile } from "./profile-live.js";
|
|
64
|
+
import { prepareProfileWrite } from "./profile-state.js";
|
|
66
65
|
import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
|
|
67
|
-
import { activeProfile, syncProfileLink } from "./symlink-manager.js";
|
|
68
66
|
|
|
69
67
|
/** `<projectPath>/.claude/models.json` — what the hook looks for. */
|
|
70
68
|
export function configPath(projectPath: string): string {
|
|
71
69
|
return path.join(projectPath, ".claude", "models.json");
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
/** `<projectPath>/.claude/settings.json` —
|
|
72
|
+
/** `<projectPath>/.claude/settings.json` — rendered from the profile when one is active. */
|
|
75
73
|
function settingsPathFor(projectPath: string): string {
|
|
76
74
|
return path.join(projectPath, ".claude", "settings.json");
|
|
77
75
|
}
|
|
@@ -368,8 +366,9 @@ export interface ApplyModelPresetResult {
|
|
|
368
366
|
*
|
|
369
367
|
* Validation comes first: an invalid config makes every subagent spawn fail, so
|
|
370
368
|
* it must never reach disk. After that the order of the numbered steps below is
|
|
371
|
-
* load-bearing —
|
|
372
|
-
*
|
|
369
|
+
* load-bearing — Claude Code's edits are absorbed before the manifest is
|
|
370
|
+
* written, the manifest is written before the live files because they are
|
|
371
|
+
* rendered FROM it, and the hook
|
|
373
372
|
* is registered last because a hook pointing at a config that failed to write is
|
|
374
373
|
* worse than no hook at all.
|
|
375
374
|
*/
|
|
@@ -405,10 +404,14 @@ export async function applyModelPreset(
|
|
|
405
404
|
if (Object.keys(manifest.profiles).length === 0 && args.ensureManifest) {
|
|
406
405
|
manifest = (await args.ensureManifest(projectPath)) ?? manifest;
|
|
407
406
|
}
|
|
407
|
+
// 2. The gate: anything Claude Code wrote to the live files goes into the
|
|
408
|
+
// profile first, so the regeneration below replaces nothing unrecorded —
|
|
409
|
+
// and a live file that does not parse stops everything, unwritten.
|
|
410
|
+
await prepareProfileWrite(projectPath);
|
|
408
411
|
const profile = await activeProfile(projectPath);
|
|
409
412
|
|
|
410
|
-
//
|
|
411
|
-
// implies. Without the settings half the next
|
|
413
|
+
// 3. Record BOTH halves in the manifest: the routing, and the settings it
|
|
414
|
+
// implies. Without the settings half the next activation renders
|
|
412
415
|
// settings.json from the manifest and silently reverts the main thread's
|
|
413
416
|
// model — the routing would keep working and the orchestrator would not.
|
|
414
417
|
const recordedInManifest = await updateProfileEntry(
|
|
@@ -420,38 +423,23 @@ export async function applyModelPreset(
|
|
|
420
423
|
},
|
|
421
424
|
);
|
|
422
425
|
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// effect of choosing a model.
|
|
426
|
+
// 4. The live files. With a profile active, settings.json and models.json
|
|
427
|
+
// are rendered from the manifest just written. Without one, models.json is
|
|
428
|
+
// a plain file and settings.json is edited in place.
|
|
427
429
|
if (recordedInManifest && profile) {
|
|
428
|
-
|
|
429
|
-
// materialize the OLD entry — which, having no `models`, would delete the
|
|
430
|
-
// file this call exists to create.
|
|
431
|
-
const written = await readManifest(projectPath);
|
|
432
|
-
await materializeProfile(
|
|
433
|
-
profile,
|
|
434
|
-
await resolveProfile(written, profile, { binResolver: noBins }),
|
|
435
|
-
projectPath,
|
|
436
|
-
await unionPluginIds(projectPath),
|
|
437
|
-
);
|
|
438
|
-
await syncProfileLink(profile, "models", projectPath);
|
|
430
|
+
await regenerateLiveFiles(projectPath);
|
|
439
431
|
} else {
|
|
440
|
-
// No profile: models.json is a plain committed-alongside file.
|
|
441
432
|
await fs.outputFile(
|
|
442
433
|
configPath(projectPath),
|
|
443
434
|
`${JSON.stringify(config, null, 2)}\n`,
|
|
444
435
|
);
|
|
436
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
437
|
+
for (const [key, value] of Object.entries(patch.set)) {
|
|
438
|
+
settings[key] = value;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
445
441
|
}
|
|
446
442
|
|
|
447
|
-
// 4. The live settings file (a symlink into the profile when one is active,
|
|
448
|
-
// so this lands in the same place materialization just wrote).
|
|
449
|
-
await updateSettingsFile(settingsPath, (settings) => {
|
|
450
|
-
for (const [key, value] of Object.entries(patch.set)) {
|
|
451
|
-
settings[key] = value;
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
|
|
455
443
|
// 5. Without this, nothing routes.
|
|
456
444
|
const hook = await registerAgentModelHook();
|
|
457
445
|
|
|
@@ -480,6 +468,8 @@ export async function clearModels(projectPath: string): Promise<void> {
|
|
|
480
468
|
)
|
|
481
469
|
: [];
|
|
482
470
|
|
|
471
|
+
// The gate first; a live file that does not parse stops everything.
|
|
472
|
+
await prepareProfileWrite(projectPath);
|
|
483
473
|
const profile = await activeProfile(projectPath);
|
|
484
474
|
const removed = await updateProfileEntry(projectPath, profile, (entry) => {
|
|
485
475
|
// biome-ignore lint/performance/noDelete: absence is the state, not undefined
|
|
@@ -493,21 +483,14 @@ export async function clearModels(projectPath: string): Promise<void> {
|
|
|
493
483
|
}
|
|
494
484
|
});
|
|
495
485
|
|
|
486
|
+
// With a profile active, rendering from an entry with no `models` REMOVES
|
|
487
|
+
// .claude/models.json — that is the whole mechanism by which `off` is off.
|
|
496
488
|
if (removed && profile) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// .claude/models.json. That is the whole mechanism by which `off` is off.
|
|
500
|
-
const manifest = await readManifest(projectPath);
|
|
501
|
-
await materializeProfile(
|
|
502
|
-
profile,
|
|
503
|
-
await resolveProfile(manifest, profile, { binResolver: noBins }),
|
|
504
|
-
projectPath,
|
|
505
|
-
await unionPluginIds(projectPath),
|
|
506
|
-
);
|
|
507
|
-
await syncProfileLink(profile, "models", projectPath);
|
|
489
|
+
await regenerateLiveFiles(projectPath);
|
|
490
|
+
return;
|
|
508
491
|
}
|
|
509
|
-
await fs.remove(configPath(projectPath));
|
|
510
492
|
|
|
493
|
+
await fs.remove(configPath(projectPath));
|
|
511
494
|
const settingsPath = settingsPathFor(projectPath);
|
|
512
495
|
if (await fs.pathExists(settingsPath)) {
|
|
513
496
|
await updateSettingsFile(settingsPath, (settings) => {
|
|
@@ -543,26 +526,6 @@ function unclaimModelSettings(
|
|
|
543
526
|
}
|
|
544
527
|
}
|
|
545
528
|
|
|
546
|
-
/**
|
|
547
|
-
* Materialization needs no binary requirements — it writes settings, mcp,
|
|
548
|
-
* models and skills, and reads none of `closure.bins`. Skipping the resolver's
|
|
549
|
-
* per-plugin `plugin.json` reads keeps `models use` off the marketplace cache
|
|
550
|
-
* entirely.
|
|
551
|
-
*/
|
|
552
|
-
const noBins = async () => [];
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* The manifest-wide union of plugin ids, which `materializeProfile` needs to
|
|
556
|
-
* keep writing the explicit `false` entries that make `profile switch`
|
|
557
|
-
* exclusive. Re-materializing without it would silently make switching additive
|
|
558
|
-
* again — a routing change must not have that side effect.
|
|
559
|
-
*/
|
|
560
|
-
async function unionPluginIds(projectPath: string): Promise<string[]> {
|
|
561
|
-
const manifest = await readManifest(projectPath);
|
|
562
|
-
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
563
|
-
return Object.keys(union.plugins);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
529
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
567
530
|
|
|
568
531
|
/** What `magus models status` and `magus doctor` report. */
|
|
@@ -596,7 +559,7 @@ async function readSettingsFileSafely(
|
|
|
596
559
|
}
|
|
597
560
|
}
|
|
598
561
|
|
|
599
|
-
/** Re-apply the config already on disk. Used by `doctor --fix` to
|
|
562
|
+
/** Re-apply the config already on disk. Used by `doctor --fix` to repair settings the config implies. */
|
|
600
563
|
export async function reapplyModels(
|
|
601
564
|
projectPath: string,
|
|
602
565
|
): Promise<ApplyModelPresetResult | null> {
|
|
@@ -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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
//
|
|
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
|
/**
|