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/profiles.ts
CHANGED
|
@@ -2,8 +2,8 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
4
|
import type { ProfileEntry, ProfileManifest } from "../types/index.js";
|
|
5
|
-
import { readProfilesFile } from "./manifest.js";
|
|
6
|
-
import { activeProfile } from "./
|
|
5
|
+
import { readProfilesFile, writeJsonMinimal } from "./manifest.js";
|
|
6
|
+
import { activeProfile } from "./profile-live.js";
|
|
7
7
|
|
|
8
8
|
const PROFILES_FILE = "profiles.json";
|
|
9
9
|
|
|
@@ -64,10 +64,11 @@ async function writeProfiles(
|
|
|
64
64
|
projectPath?: string,
|
|
65
65
|
): Promise<void> {
|
|
66
66
|
const filePath = profilesPath(scope, projectPath);
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
await
|
|
67
|
+
// The refusal, and the file as read: only what the caller changed is
|
|
68
|
+
// written, every other byte kept. Diffed against the file's own `version`,
|
|
69
|
+
// so a v1 file is recorded as v2.
|
|
70
|
+
const current = await readProfilesFile(filePath);
|
|
71
|
+
await writeJsonMinimal(filePath, data, current);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
// ─── CRUD ──────────────────────────────────────────────────────────────────────
|
|
@@ -150,38 +151,26 @@ export function toEnabledPlugins(
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/**
|
|
153
|
-
* Apply a profile
|
|
154
|
-
*
|
|
155
|
-
*
|
|
154
|
+
* Apply a USER-scope profile — a template in `~/.claude/profiles.json` — to
|
|
155
|
+
* `~/.claude/settings.json`, replacing its enabledPlugins.
|
|
156
|
+
*
|
|
157
|
+
* User scope only, by construction. A project's live `.claude/settings.json`
|
|
158
|
+
* is rendered from its active profile in `.claude/profiles.json`; writing
|
|
159
|
+
* enabledPlugins into it directly is what made a TUI "apply" invisible to the
|
|
160
|
+
* profile and undone by the next render. A PROJECT profile is applied by
|
|
161
|
+
* switching to it (`switchToProfile` in `profile-state.ts`), the same path as
|
|
162
|
+
* `magus profile switch`.
|
|
156
163
|
*/
|
|
157
|
-
export async function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Import settings functions dynamically to avoid circular deps
|
|
168
|
-
const {
|
|
169
|
-
readSettings,
|
|
170
|
-
writeSettings,
|
|
171
|
-
readGlobalSettings,
|
|
172
|
-
writeGlobalSettings,
|
|
173
|
-
} = await import("./claude-settings.js");
|
|
174
|
-
|
|
175
|
-
if (targetScope === "user") {
|
|
176
|
-
const settings = await readGlobalSettings();
|
|
177
|
-
settings.enabledPlugins = toEnabledPlugins(profile.plugins);
|
|
178
|
-
await writeGlobalSettings(settings);
|
|
179
|
-
} else {
|
|
180
|
-
// project or local both write to project settings.json
|
|
181
|
-
const settings = await readSettings(projectPath);
|
|
182
|
-
settings.enabledPlugins = toEnabledPlugins(profile.plugins);
|
|
183
|
-
await writeSettings(settings, projectPath);
|
|
184
|
-
}
|
|
164
|
+
export async function applyUserProfile(profile: {
|
|
165
|
+
plugins?: Record<string, string | boolean>;
|
|
166
|
+
}): Promise<void> {
|
|
167
|
+
// Dynamic import: claude-settings reaches back into this module's callers.
|
|
168
|
+
const { readGlobalSettings, writeGlobalSettings } = await import(
|
|
169
|
+
"./claude-settings.js"
|
|
170
|
+
);
|
|
171
|
+
const settings = await readGlobalSettings();
|
|
172
|
+
settings.enabledPlugins = toEnabledPlugins(profile.plugins);
|
|
173
|
+
await writeGlobalSettings(settings);
|
|
185
174
|
}
|
|
186
175
|
|
|
187
176
|
/** Rename a profile in-place (preserves id, createdAt) */
|
package/src/services/resolver.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
|
|
6
6
|
* machine must have to match the profile. Binary deps are the union of the
|
|
7
7
|
* profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
|
|
8
|
-
* deduped by executable name with the project's pin winning.
|
|
8
|
+
* deduped by executable name with the project's pin winning. Plugins include
|
|
9
|
+
* every plugin an enabled one declares as a dependency, transitively.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import semver from "semver";
|
|
@@ -14,14 +15,19 @@ import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
|
14
15
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
15
16
|
import type {
|
|
16
17
|
BinRequirement,
|
|
18
|
+
DependencyReport,
|
|
17
19
|
ProfileManifest,
|
|
18
20
|
ProfileManifestEntry,
|
|
19
21
|
ResolvedBin,
|
|
20
22
|
ResolvedClosure,
|
|
21
23
|
} from "../types/index.js";
|
|
22
24
|
import { readManifest } from "./manifest.js";
|
|
25
|
+
import {
|
|
26
|
+
type PluginDependencyReader,
|
|
27
|
+
readPluginDependencies,
|
|
28
|
+
} from "./plugin-dependencies.js";
|
|
23
29
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
24
|
-
import { activeProfile } from "./
|
|
30
|
+
import { activeProfile } from "./profile-live.js";
|
|
25
31
|
|
|
26
32
|
export interface ResolveOptions {
|
|
27
33
|
/**
|
|
@@ -29,6 +35,61 @@ export interface ResolveOptions {
|
|
|
29
35
|
* tests; defaults to reading plugin.json from the marketplace cache.
|
|
30
36
|
*/
|
|
31
37
|
binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a plugin id to the plugin ids its plugin.json declares as
|
|
40
|
+
* dependencies, or null when it cannot be read. Defaults to reading the
|
|
41
|
+
* plugin.json on this machine (`readPluginDependencies`).
|
|
42
|
+
*/
|
|
43
|
+
dependencyResolver?: PluginDependencyReader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every plugin the `enabled` plugins depend on, transitively, as their own
|
|
48
|
+
* plugin.json files declare it.
|
|
49
|
+
*
|
|
50
|
+
* Claude Code refuses to load a plugin whose dependency is not enabled, so a
|
|
51
|
+
* dependency is part of what a profile enables — derived, never written into
|
|
52
|
+
* profiles.json. A dependency the profile itself turns off (`off`) is left
|
|
53
|
+
* off and reported as `blocked`; nothing it depends on is followed. A plugin
|
|
54
|
+
* whose plugin.json cannot be read is reported as `unreadable` rather than
|
|
55
|
+
* guessed at.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveDependencies(
|
|
58
|
+
enabled: readonly string[],
|
|
59
|
+
off: ReadonlySet<string>,
|
|
60
|
+
read: PluginDependencyReader,
|
|
61
|
+
): Promise<DependencyReport> {
|
|
62
|
+
const derived: string[] = [];
|
|
63
|
+
const unreadable: string[] = [];
|
|
64
|
+
const blocked: DependencyReport["blocked"] = [];
|
|
65
|
+
const queue = [...enabled];
|
|
66
|
+
const seen = new Set(enabled);
|
|
67
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
|
|
68
|
+
const dependencies = await read(id);
|
|
69
|
+
if (dependencies === null) {
|
|
70
|
+
unreadable.push(id);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const dependency of dependencies) {
|
|
74
|
+
if (off.has(dependency)) {
|
|
75
|
+
blocked.push({ plugin: id, dependency });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (seen.has(dependency)) continue;
|
|
79
|
+
seen.add(dependency);
|
|
80
|
+
derived.push(dependency);
|
|
81
|
+
queue.push(dependency);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasDependencies(report: DependencyReport): boolean {
|
|
88
|
+
return (
|
|
89
|
+
report.derived.length > 0 ||
|
|
90
|
+
report.unreadable.length > 0 ||
|
|
91
|
+
report.blocked.length > 0
|
|
92
|
+
);
|
|
32
93
|
}
|
|
33
94
|
|
|
34
95
|
/** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
|
|
@@ -234,11 +295,25 @@ export async function resolveProfile(
|
|
|
234
295
|
const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
|
|
235
296
|
|
|
236
297
|
const plugins = normalizePlugins(entry.plugins);
|
|
298
|
+
const disabledPlugins = Object.entries(entry.plugins ?? {})
|
|
299
|
+
.filter(([, ver]) => ver === false)
|
|
300
|
+
.map(([id]) => id);
|
|
301
|
+
|
|
302
|
+
// The plugins the enabled ones need in order to load at all.
|
|
303
|
+
const dependencies = await resolveDependencies(
|
|
304
|
+
Object.keys(plugins),
|
|
305
|
+
new Set(disabledPlugins),
|
|
306
|
+
opts.dependencyResolver ?? readPluginDependencies,
|
|
307
|
+
);
|
|
308
|
+
for (const id of dependencies.derived) plugins[id] = "latest";
|
|
309
|
+
|
|
237
310
|
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
238
311
|
|
|
239
312
|
return {
|
|
240
313
|
marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
|
|
241
314
|
plugins,
|
|
315
|
+
...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
|
|
316
|
+
...(hasDependencies(dependencies) ? { dependencies } : {}),
|
|
242
317
|
mcpServers: { ...(entry.mcpServers ?? {}) },
|
|
243
318
|
bins,
|
|
244
319
|
skills: [...(entry.skills ?? [])],
|
|
@@ -328,6 +403,9 @@ function mergeClosures(
|
|
|
328
403
|
}
|
|
329
404
|
}
|
|
330
405
|
|
|
406
|
+
// Dependency reports: union, for `install` and `doctor` to report.
|
|
407
|
+
const dependencies = mergeDependencyReports(a.dependencies, b.dependencies);
|
|
408
|
+
|
|
331
409
|
// Skills: union by repo+path.
|
|
332
410
|
const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
|
|
333
411
|
const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
|
|
@@ -348,6 +426,26 @@ function mergeClosures(
|
|
|
348
426
|
optional: [...new Set([...a.env.optional, ...b.env.optional])],
|
|
349
427
|
},
|
|
350
428
|
...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
|
|
429
|
+
...(dependencies ? { dependencies } : {}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function mergeDependencyReports(
|
|
434
|
+
a: DependencyReport | undefined,
|
|
435
|
+
b: DependencyReport | undefined,
|
|
436
|
+
): DependencyReport | undefined {
|
|
437
|
+
if (!a) return b;
|
|
438
|
+
if (!b) return a;
|
|
439
|
+
const blocked = new Map(
|
|
440
|
+
[...a.blocked, ...b.blocked].map((x) => [
|
|
441
|
+
`${x.plugin}\u0000${x.dependency}`,
|
|
442
|
+
x,
|
|
443
|
+
]),
|
|
444
|
+
);
|
|
445
|
+
return {
|
|
446
|
+
derived: [...new Set([...a.derived, ...b.derived])].sort(),
|
|
447
|
+
unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
|
|
448
|
+
blocked: [...blocked.values()],
|
|
351
449
|
};
|
|
352
450
|
}
|
|
353
451
|
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two rules, and both exist because of damage they prevent:
|
|
5
5
|
*
|
|
6
|
-
* 1. **Write IN PLACE, never remove-and-recreate.**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 1. **Write IN PLACE, never remove-and-recreate.** A settings file may be a
|
|
7
|
+
* symlink somebody set up deliberately, and `writeFile` follows it — which
|
|
8
|
+
* is what we want. Unlinking the file first would break the link and leave
|
|
9
|
+
* the next read looking at a fresh, empty file. (magus's own profile files
|
|
10
|
+
* are real files; `profile-live.ts` replaces them via a rename.)
|
|
11
11
|
* 2. **Unparseable is an ERROR, not an empty object.** A settings file that
|
|
12
12
|
* fails to parse is a file we do not understand, usually because a human is
|
|
13
13
|
* mid-edit. Treating it as `{}` and writing our one key back is how a
|
|
@@ -40,6 +40,18 @@ export function getProjectSkillsDir(projectPath?: string): string {
|
|
|
40
40
|
return path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The folder a skill installs into: a slug of its display name, since display
|
|
45
|
+
* names can carry slashes and spaces. One definition, because the profile code
|
|
46
|
+
* must predict exactly where `installSkill` will write.
|
|
47
|
+
*/
|
|
48
|
+
export function skillFolderName(name: string): string {
|
|
49
|
+
return name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-|-$/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
// ─── GitHub Tree API ──────────────────────────────────────────────────────────
|
|
44
56
|
|
|
45
57
|
async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
|
|
@@ -1003,10 +1015,7 @@ export async function installSkill(
|
|
|
1003
1015
|
}
|
|
1004
1016
|
|
|
1005
1017
|
// Use slug for directory name — display names can have slashes/spaces
|
|
1006
|
-
const dirName = skill.name
|
|
1007
|
-
.toLowerCase()
|
|
1008
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1009
|
-
.replace(/^-|-$/g, "");
|
|
1018
|
+
const dirName = skillFolderName(skill.name);
|
|
1010
1019
|
|
|
1011
1020
|
const installDir =
|
|
1012
1021
|
scope === "user"
|
|
@@ -1090,10 +1099,7 @@ export async function uninstallSkill(
|
|
|
1090
1099
|
scope: "user" | "project",
|
|
1091
1100
|
projectPath?: string,
|
|
1092
1101
|
): Promise<void> {
|
|
1093
|
-
const dirName = skillName
|
|
1094
|
-
.toLowerCase()
|
|
1095
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1096
|
-
.replace(/^-|-$/g, "");
|
|
1102
|
+
const dirName = skillFolderName(skillName);
|
|
1097
1103
|
|
|
1098
1104
|
const installDir =
|
|
1099
1105
|
scope === "user"
|
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
*
|
|
20
20
|
* ## Profile awareness
|
|
21
21
|
*
|
|
22
|
-
* `.claude/settings.json` is
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `profiles.<name>.settings.outputStyle`, which is the durable source.
|
|
22
|
+
* `.claude/settings.json` is rendered from the active profile when there is
|
|
23
|
+
* one, so the selection is recorded in `.claude/profiles.json` under
|
|
24
|
+
* `profiles.<name>.settings.outputStyle` — the durable source — and the live
|
|
25
|
+
* file is rendered again from it. Without a profile the live file is edited in
|
|
26
|
+
* place.
|
|
28
27
|
*
|
|
29
28
|
* The generated style file is named per-profile (`composed-<name>`) so two
|
|
30
29
|
* profiles can hold different styles without clobbering each other's file
|
|
@@ -43,8 +42,10 @@ import {
|
|
|
43
42
|
readManifest,
|
|
44
43
|
writeManifest,
|
|
45
44
|
} from "./manifest.js";
|
|
45
|
+
import { regenerateLiveFiles } from "./profile-activate.js";
|
|
46
|
+
import { activeProfile } from "./profile-live.js";
|
|
47
|
+
import { prepareProfileWrite } from "./profile-state.js";
|
|
46
48
|
import { updateSettingsFile } from "./settings-file.js";
|
|
47
|
-
import { activeProfile } from "./symlink-manager.js";
|
|
48
49
|
|
|
49
50
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
50
51
|
|
|
@@ -188,7 +189,7 @@ export interface StylesSnapshot {
|
|
|
188
189
|
settingsPath: string;
|
|
189
190
|
/** Name of the generated style, e.g. "composed" or "composed-dev". */
|
|
190
191
|
styleName: string;
|
|
191
|
-
/** Active profile name, when
|
|
192
|
+
/** Active profile name, when a profile is active (see `activeProfile`). */
|
|
192
193
|
profile: string | null;
|
|
193
194
|
/** The value currently in settings.json, whatever set it. */
|
|
194
195
|
currentOutputStyle: string | null;
|
|
@@ -1112,6 +1113,10 @@ export async function applyStyles({
|
|
|
1112
1113
|
throw new Error("Nothing selected. Pick at least one style.");
|
|
1113
1114
|
}
|
|
1114
1115
|
|
|
1116
|
+
// The gate before anything is written: whatever Claude Code wrote to the
|
|
1117
|
+
// live config goes into the profile, so the render below replaces nothing
|
|
1118
|
+
// unrecorded — and a live file that does not parse stops everything.
|
|
1119
|
+
await prepareProfileWrite(projectPath);
|
|
1115
1120
|
const profile = await activeProfile(projectPath);
|
|
1116
1121
|
const styleName = generatedStyleName(profile);
|
|
1117
1122
|
const stylePath = stylePathFor(projectPath, styleName);
|
|
@@ -1122,7 +1127,19 @@ export async function applyStyles({
|
|
|
1122
1127
|
|
|
1123
1128
|
await fs.ensureDir(path.dirname(stylePath));
|
|
1124
1129
|
await fs.writeFile(stylePath, contents, "utf8");
|
|
1125
|
-
|
|
1130
|
+
|
|
1131
|
+
// With a profile active, the manifest is where the selection lives and the
|
|
1132
|
+
// live file is rendered from it.
|
|
1133
|
+
let recordedInManifest = false;
|
|
1134
|
+
if (profile !== null) {
|
|
1135
|
+
recordedInManifest = await recordInManifest(
|
|
1136
|
+
projectPath,
|
|
1137
|
+
profile,
|
|
1138
|
+
styleName,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
if (recordedInManifest) await regenerateLiveFiles(projectPath);
|
|
1142
|
+
else await setOutputStyle(settingsPath, styleName);
|
|
1126
1143
|
|
|
1127
1144
|
// Verify by re-reading, not by trusting the write.
|
|
1128
1145
|
const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
|
|
@@ -1144,12 +1161,6 @@ export async function applyStyles({
|
|
|
1144
1161
|
throw new Error(`Verification failed — ${problems.join("; ")}`);
|
|
1145
1162
|
}
|
|
1146
1163
|
|
|
1147
|
-
const recordedInManifest = await recordInManifest(
|
|
1148
|
-
projectPath,
|
|
1149
|
-
profile,
|
|
1150
|
-
styleName,
|
|
1151
|
-
);
|
|
1152
|
-
|
|
1153
1164
|
// Write the declaration LAST, once the artifact is verified. It is the thing
|
|
1154
1165
|
// that gets committed and that teammates are told to match, so it must never
|
|
1155
1166
|
// claim a composition that failed to produce a valid file.
|
|
@@ -1268,6 +1279,25 @@ export async function createTeamStyle(
|
|
|
1268
1279
|
*/
|
|
1269
1280
|
export async function clearStyle(projectPath: string): Promise<void> {
|
|
1270
1281
|
const settingsPath = settingsPathFor(projectPath);
|
|
1282
|
+
// The gate first; a live file that does not parse stops everything.
|
|
1283
|
+
await prepareProfileWrite(projectPath);
|
|
1284
|
+
const profile = await activeProfile(projectPath);
|
|
1285
|
+
|
|
1286
|
+
if (profile !== null && (await fs.pathExists(getManifestPath(projectPath)))) {
|
|
1287
|
+
const manifest = await readManifest(projectPath);
|
|
1288
|
+
const entry = manifest.profiles[profile];
|
|
1289
|
+
if (entry?.settings && "outputStyle" in entry.settings) {
|
|
1290
|
+
// The guard above uses `in`, so assigning undefined would make a second
|
|
1291
|
+
// call a no-op that still leaves the key serialized into profiles.json.
|
|
1292
|
+
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1293
|
+
delete entry.settings.outputStyle;
|
|
1294
|
+
entry.updatedAt = new Date().toISOString();
|
|
1295
|
+
await writeManifest(manifest, projectPath);
|
|
1296
|
+
}
|
|
1297
|
+
await regenerateLiveFiles(projectPath);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1271
1301
|
// The guard stays: a project that never had a style must not gain a
|
|
1272
1302
|
// settings.json just because the style was cleared.
|
|
1273
1303
|
if (await fs.pathExists(settingsPath)) {
|
|
@@ -1278,17 +1308,4 @@ export async function clearStyle(projectPath: string): Promise<void> {
|
|
|
1278
1308
|
delete settings.outputStyle;
|
|
1279
1309
|
});
|
|
1280
1310
|
}
|
|
1281
|
-
|
|
1282
|
-
const profile = await activeProfile(projectPath);
|
|
1283
|
-
if (!profile) return;
|
|
1284
|
-
if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
|
|
1285
|
-
const manifest = await readManifest(projectPath);
|
|
1286
|
-
const entry = manifest.profiles[profile];
|
|
1287
|
-
if (!entry?.settings || !("outputStyle" in entry.settings)) return;
|
|
1288
|
-
// The guard above uses `in`, so assigning undefined would make a second call
|
|
1289
|
-
// a no-op that still leaves the key serialized into profiles.json.
|
|
1290
|
-
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1291
|
-
delete entry.settings.outputStyle;
|
|
1292
|
-
entry.updatedAt = new Date().toISOString();
|
|
1293
|
-
await writeManifest(manifest, projectPath);
|
|
1294
1311
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* magus never writes a generated file that git tracks.
|
|
3
|
+
*
|
|
4
|
+
* In a project with profiles, `.claude/settings.json`, `.mcp.json` and
|
|
5
|
+
* `.claude/models.json` are generated per developer from `.claude/profiles.json`.
|
|
6
|
+
* A committed copy is the team's hand-kept file: writing it leaves git
|
|
7
|
+
* tracking a file that now differs per developer, so every write shows as a
|
|
8
|
+
* modified tracked file and one developer's plugin set gets committed for
|
|
9
|
+
* everyone. `.gitignore` cannot fix that — it does not untrack a file already
|
|
10
|
+
* committed. So every writer refuses, having written nothing, and says how to
|
|
11
|
+
* stop tracking it; the user decides.
|
|
12
|
+
*
|
|
13
|
+
* Activation checks every path it would write up front (`profile-activate.ts`).
|
|
14
|
+
* This module holds the refusal itself, and the check for the writers outside
|
|
15
|
+
* activation: the TUI's direct settings edits, `.mcp.json` edits, and the
|
|
16
|
+
* Claude CLI's project-scope installs, which write `.claude/settings.json`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { GitQueryError, trackedByGit } from "./git-worktree.js";
|
|
21
|
+
import { getManifestPath, readProfilesFile } from "./manifest.js";
|
|
22
|
+
|
|
23
|
+
/** magus refused to write a live file, and changed nothing. */
|
|
24
|
+
export class LiveFileRefusal extends Error {}
|
|
25
|
+
|
|
26
|
+
/** A live file magus would write is tracked by git. */
|
|
27
|
+
export class TrackedLiveFileError extends LiveFileRefusal {
|
|
28
|
+
constructor(readonly paths: readonly string[]) {
|
|
29
|
+
const one = paths.length === 1;
|
|
30
|
+
super(
|
|
31
|
+
[
|
|
32
|
+
`magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
|
|
33
|
+
one ? "it is" : "they are"
|
|
34
|
+
} generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
|
|
35
|
+
...paths.map((p) => ` ${p}`),
|
|
36
|
+
`Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
|
|
37
|
+
...paths.map((p) => ` ${untrackCommand(p)}`),
|
|
38
|
+
' git commit -m "Stop tracking generated Claude Code config"',
|
|
39
|
+
"Nothing was changed.",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
);
|
|
42
|
+
this.name = "TrackedLiveFileError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The project is a git work tree, but git could not say what it tracks — it
|
|
48
|
+
* timed out, is missing, or refused the repository (`detected dubious
|
|
49
|
+
* ownership`). Answering "nothing is tracked" would let magus write a tracked
|
|
50
|
+
* file, so it refuses instead and passes on git's own words.
|
|
51
|
+
*/
|
|
52
|
+
export class GitCheckError extends LiveFileRefusal {
|
|
53
|
+
constructor(readonly detail: string) {
|
|
54
|
+
super(
|
|
55
|
+
[
|
|
56
|
+
"magus could not check which files git tracks in this project, so it wrote nothing. git said:",
|
|
57
|
+
...detail
|
|
58
|
+
.trim()
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((line) => ` ${line}`),
|
|
61
|
+
"Fix that, then run the command again. Nothing was changed.",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
);
|
|
64
|
+
this.name = "GitCheckError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How to untrack one generated path — `-r` for a skill folder. */
|
|
69
|
+
export function untrackCommand(rel: string): string {
|
|
70
|
+
const isDir =
|
|
71
|
+
rel.split(path.sep).length > 2 &&
|
|
72
|
+
rel.startsWith(path.join(".claude", "skills") + path.sep);
|
|
73
|
+
return isDir ? `git rm --cached -r ${rel}` : `git rm --cached ${rel}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which of `files` (exact entries) and `dirs` (any file under them) git
|
|
78
|
+
* tracks, relative to `projectPath`. Empty outside a git work tree; throws
|
|
79
|
+
* {@link GitCheckError} when inside one and git cannot answer.
|
|
80
|
+
*/
|
|
81
|
+
export async function trackedPaths(
|
|
82
|
+
projectPath: string,
|
|
83
|
+
files: readonly string[],
|
|
84
|
+
dirs: readonly string[] = [],
|
|
85
|
+
): Promise<Set<string>> {
|
|
86
|
+
try {
|
|
87
|
+
return await trackedByGit(projectPath, files, dirs);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof GitQueryError) throw new GitCheckError(error.detail);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Does magus generate this project's live files — does its profiles.json
|
|
96
|
+
* define a profile? A profiles.json that cannot be read counts as yes: the
|
|
97
|
+
* check that follows only ever stops a write.
|
|
98
|
+
*/
|
|
99
|
+
async function hasProfiles(projectPath: string): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
const file = await readProfilesFile(getManifestPath(projectPath));
|
|
102
|
+
return Object.keys(file.profiles).length > 0;
|
|
103
|
+
} catch {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Throw {@link TrackedLiveFileError} — before anything is written — when
|
|
110
|
+
* `projectPath` has profiles and git tracks any of `files` (absolute, or
|
|
111
|
+
* relative to the project). A project without profiles owns its files by
|
|
112
|
+
* hand, and writing them is not magus's to refuse.
|
|
113
|
+
*/
|
|
114
|
+
export async function refuseTrackedLiveFiles(
|
|
115
|
+
projectPath: string,
|
|
116
|
+
files: readonly string[],
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
if (!(await hasProfiles(projectPath))) return;
|
|
119
|
+
const rel = files.map((f) =>
|
|
120
|
+
path.isAbsolute(f) ? path.relative(projectPath, f) : f,
|
|
121
|
+
);
|
|
122
|
+
const tracked = await trackedPaths(projectPath, rel);
|
|
123
|
+
const hit = rel.filter((r) => tracked.has(r));
|
|
124
|
+
if (hit.length > 0) throw new TrackedLiveFileError(hit);
|
|
125
|
+
}
|