magus-cli 7.4.1 → 7.5.0
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 +4 -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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +132 -38
- package/src/cli/install.ts +58 -118
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- 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/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Materialize a resolved profile into `.claude/_profiles/<name>/`.
|
|
3
|
-
*
|
|
4
|
-
* Writes the shared config the symlink manager later points at:
|
|
5
|
-
* _profiles/<name>/settings.json — enabledPlugins + the profile's settings
|
|
6
|
-
* _profiles/<name>/mcp.json — the profile's MCP servers
|
|
7
|
-
* _profiles/<name>/models.json — per-subagent model routing, when the
|
|
8
|
-
* profile declares any; REMOVED when it
|
|
9
|
-
* does not
|
|
10
|
-
* _profiles/<name>/skills/ — created empty; skills install into it
|
|
11
|
-
* (through the activated symlink)
|
|
12
|
-
*
|
|
13
|
-
* This is pure output derived from a ResolvedClosure — it performs no installs
|
|
14
|
-
* and touches no global state, so it is fully testable with a temp dir.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import fs from "fs-extra";
|
|
19
|
-
import type { ResolvedClosure } from "../types/index.js";
|
|
20
|
-
import { profileDir } from "./symlink-manager.js";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Build the settings.json contents for a profile: every resolved plugin
|
|
24
|
-
* enabled, merged with the profile's declared settings. The profile's settings
|
|
25
|
-
* win over the derived enabledPlugins only if it explicitly sets that key
|
|
26
|
-
* (it normally won't).
|
|
27
|
-
*
|
|
28
|
-
* `allPluginIds` is the manifest-wide union of plugins across every profile.
|
|
29
|
-
* Plugins in the union that this profile does NOT want are written as an
|
|
30
|
-
* explicit `false`, which is what makes `profile switch` exclusive rather than
|
|
31
|
-
* additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
|
|
32
|
-
* the highest-precedence scope down and taking the first scope that mentions
|
|
33
|
-
* the id — an id a scope omits falls through to the next one. Since
|
|
34
|
-
* `magus install` installs the union at user scope, every non-member would
|
|
35
|
-
* otherwise inherit that user-scope `true` and stay enabled while a profile
|
|
36
|
-
* that excludes it is active. Omitting the union (the default) preserves the
|
|
37
|
-
* old additive behavior for callers that have no cross-profile view.
|
|
38
|
-
*/
|
|
39
|
-
export function buildProfileSettings(
|
|
40
|
-
closure: ResolvedClosure,
|
|
41
|
-
allPluginIds: readonly string[] = [],
|
|
42
|
-
): Record<string, unknown> {
|
|
43
|
-
const enabledPlugins: Record<string, boolean> = {};
|
|
44
|
-
for (const pluginId of allPluginIds) {
|
|
45
|
-
enabledPlugins[pluginId] = false;
|
|
46
|
-
}
|
|
47
|
-
for (const pluginId of Object.keys(closure.plugins)) {
|
|
48
|
-
enabledPlugins[pluginId] = true;
|
|
49
|
-
}
|
|
50
|
-
return { enabledPlugins, ...closure.settings };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Build the .mcp.json contents for a profile. */
|
|
54
|
-
export function buildProfileMcp(closure: ResolvedClosure): {
|
|
55
|
-
mcpServers: Record<string, unknown>;
|
|
56
|
-
} {
|
|
57
|
-
return { mcpServers: { ...closure.mcpServers } };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
|
|
62
|
-
*
|
|
63
|
-
* Activating a profile replaces `.claude/skills` with a symlink into the profile
|
|
64
|
-
* dir, and replacing means `fs.remove` first. A project that committed its own
|
|
65
|
-
* skills before adopting profiles would lose them at that moment. So the first
|
|
66
|
-
* time a profile's skills/ dir is created, any real (non-symlink) project skills
|
|
67
|
-
* are copied in — every profile inherits what the project already had, and the
|
|
68
|
-
* link swap destroys nothing.
|
|
69
|
-
*
|
|
70
|
-
* Only runs when the profile's skills/ dir does not exist yet, so it never
|
|
71
|
-
* fights a later `skills-manager` install or re-adds a skill the user removed.
|
|
72
|
-
*/
|
|
73
|
-
async function seedSkillsFromProject(
|
|
74
|
-
skillsDir: string,
|
|
75
|
-
projectPath?: string,
|
|
76
|
-
): Promise<void> {
|
|
77
|
-
if (await fs.pathExists(skillsDir)) return;
|
|
78
|
-
|
|
79
|
-
const projectSkills = path.join(
|
|
80
|
-
projectPath ?? process.cwd(),
|
|
81
|
-
".claude",
|
|
82
|
-
"skills",
|
|
83
|
-
);
|
|
84
|
-
try {
|
|
85
|
-
// lstat, not pathExists: an existing profile symlink is not a source.
|
|
86
|
-
const stat = await fs.lstat(projectSkills);
|
|
87
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
88
|
-
if ((await fs.readdir(projectSkills)).length === 0) return;
|
|
89
|
-
await fs.copy(projectSkills, skillsDir, { dereference: true });
|
|
90
|
-
} catch {
|
|
91
|
-
// No project skills dir (or unreadable) — nothing to carry over.
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
|
|
97
|
-
* settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
|
|
98
|
-
*/
|
|
99
|
-
export async function materializeProfile(
|
|
100
|
-
name: string,
|
|
101
|
-
closure: ResolvedClosure,
|
|
102
|
-
projectPath?: string,
|
|
103
|
-
allPluginIds: readonly string[] = [],
|
|
104
|
-
): Promise<string> {
|
|
105
|
-
const dir = profileDir(name, projectPath);
|
|
106
|
-
await fs.ensureDir(dir);
|
|
107
|
-
await fs.writeJson(
|
|
108
|
-
path.join(dir, "settings.json"),
|
|
109
|
-
buildProfileSettings(closure, allPluginIds),
|
|
110
|
-
{ spaces: 2 },
|
|
111
|
-
);
|
|
112
|
-
await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
|
|
113
|
-
spaces: 2,
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// models.json is the one artifact that is REMOVED when the closure lacks it.
|
|
117
|
-
// Every other file here is unconditional, so "overwrite" is enough; routing
|
|
118
|
-
// is optional, and a profile that dropped it would otherwise keep routing
|
|
119
|
-
// from a file nothing writes any more — `magus models off` could not turn
|
|
120
|
-
// routing off, because the hook walks up to the nearest models.json and this
|
|
121
|
-
// one would still be there (and still linked).
|
|
122
|
-
const modelsPath = path.join(dir, "models.json");
|
|
123
|
-
if (closure.models) {
|
|
124
|
-
await fs.writeJson(modelsPath, closure.models, { spaces: 2 });
|
|
125
|
-
} else {
|
|
126
|
-
await fs.remove(modelsPath);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const skillsDir = path.join(dir, "skills");
|
|
130
|
-
await seedSkillsFromProject(skillsDir, projectPath);
|
|
131
|
-
await fs.ensureDir(skillsDir);
|
|
132
|
-
return dir;
|
|
133
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `profile sync` — promote edits made to the active profile dir back into the
|
|
3
|
-
* committed manifest.
|
|
4
|
-
*
|
|
5
|
-
* `.claude/_profiles/<name>/` is generated build output, so an edit to the
|
|
6
|
-
* symlinked settings.json / mcp.json would be lost on the next `install`. Sync
|
|
7
|
-
* reverse-materializes those files into `.claude/profiles.json` so the change
|
|
8
|
-
* becomes a reviewable manifest diff instead of silent, throwaway drift.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import fs from "fs-extra";
|
|
13
|
-
import type {
|
|
14
|
-
McpServerConfig,
|
|
15
|
-
ProfileManifest,
|
|
16
|
-
ProfileManifestEntry,
|
|
17
|
-
} from "../types/index.js";
|
|
18
|
-
import { readManifest, writeManifest } from "./manifest.js";
|
|
19
|
-
import { profileDir } from "./symlink-manager.js";
|
|
20
|
-
|
|
21
|
-
export interface SyncChange {
|
|
22
|
-
kind: "plugin-added" | "plugin-removed" | "settings-changed" | "mcp-changed";
|
|
23
|
-
detail: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Merge a materialized profile's settings.json + mcp.json back into a manifest
|
|
28
|
-
* entry. Pure: returns the updated entry and the list of changes.
|
|
29
|
-
*
|
|
30
|
-
* - enabledPlugins present but not in the entry -> added as "latest".
|
|
31
|
-
* - entry plugins that are no longer enabled -> removed.
|
|
32
|
-
* - an existing version PIN is preserved (enabled != downgrade-to-latest).
|
|
33
|
-
* - non-enabledPlugins settings keys replace the entry's settings.
|
|
34
|
-
* - mcpServers replace the entry's mcpServers when they differ.
|
|
35
|
-
*/
|
|
36
|
-
export function computeSync(
|
|
37
|
-
entry: ProfileManifestEntry,
|
|
38
|
-
materializedSettings: Record<string, unknown>,
|
|
39
|
-
materializedMcp: { mcpServers?: Record<string, McpServerConfig> },
|
|
40
|
-
now: string = new Date().toISOString(),
|
|
41
|
-
): { entry: ProfileManifestEntry; changes: SyncChange[] } {
|
|
42
|
-
const changes: SyncChange[] = [];
|
|
43
|
-
const next: ProfileManifestEntry = { ...entry };
|
|
44
|
-
|
|
45
|
-
const enabled = (materializedSettings.enabledPlugins ?? {}) as Record<
|
|
46
|
-
string,
|
|
47
|
-
boolean
|
|
48
|
-
>;
|
|
49
|
-
const currentPlugins = { ...(entry.plugins ?? {}) };
|
|
50
|
-
|
|
51
|
-
// Additions: enabled plugins the manifest doesn't list.
|
|
52
|
-
for (const [id, on] of Object.entries(enabled)) {
|
|
53
|
-
if (on && !(id in currentPlugins)) {
|
|
54
|
-
currentPlugins[id] = "latest";
|
|
55
|
-
changes.push({ kind: "plugin-added", detail: id });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
// Removals: manifest plugins no longer enabled.
|
|
59
|
-
for (const id of Object.keys(entry.plugins ?? {})) {
|
|
60
|
-
if (!enabled[id]) {
|
|
61
|
-
delete currentPlugins[id];
|
|
62
|
-
changes.push({ kind: "plugin-removed", detail: id });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
next.plugins = currentPlugins;
|
|
66
|
-
|
|
67
|
-
// Settings = everything in the materialized file except enabledPlugins.
|
|
68
|
-
const { enabledPlugins: _drop, ...settings } = materializedSettings;
|
|
69
|
-
if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
|
|
70
|
-
if (Object.keys(settings).length > 0) next.settings = settings;
|
|
71
|
-
else {
|
|
72
|
-
// biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
|
|
73
|
-
delete next.settings;
|
|
74
|
-
}
|
|
75
|
-
changes.push({ kind: "settings-changed", detail: "settings updated" });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// MCP servers.
|
|
79
|
-
const mcp = materializedMcp.mcpServers ?? {};
|
|
80
|
-
if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
|
|
81
|
-
if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
|
|
82
|
-
else {
|
|
83
|
-
// biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
|
|
84
|
-
delete next.mcpServers;
|
|
85
|
-
}
|
|
86
|
-
changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Refresh the timestamp when anything changed. `now` is injectable so
|
|
90
|
-
// callers/tests control it (and computeSync stays pure).
|
|
91
|
-
if (changes.length > 0) next.updatedAt = now;
|
|
92
|
-
|
|
93
|
-
return { entry: next, changes };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Detect drift between a materialized profile and the manifest WITHOUT writing.
|
|
98
|
-
* Returns the changes `sync` would promote (empty = in sync). Returns [] if the
|
|
99
|
-
* profile isn't materialized (nothing to lose).
|
|
100
|
-
*/
|
|
101
|
-
export async function detectProfileDrift(
|
|
102
|
-
name: string,
|
|
103
|
-
projectPath?: string,
|
|
104
|
-
): Promise<SyncChange[]> {
|
|
105
|
-
const manifest = await readManifest(projectPath);
|
|
106
|
-
const entry = manifest.profiles[name];
|
|
107
|
-
if (!entry) return [];
|
|
108
|
-
|
|
109
|
-
const dir = profileDir(name, projectPath);
|
|
110
|
-
const settingsPath = path.join(dir, "settings.json");
|
|
111
|
-
if (!(await fs.pathExists(settingsPath))) return [];
|
|
112
|
-
|
|
113
|
-
const mcpPath = path.join(dir, "mcp.json");
|
|
114
|
-
const materializedSettings = await fs.readJson(settingsPath);
|
|
115
|
-
const materializedMcp = (await fs.pathExists(mcpPath))
|
|
116
|
-
? await fs.readJson(mcpPath)
|
|
117
|
-
: { mcpServers: {} };
|
|
118
|
-
|
|
119
|
-
return computeSync(entry, materializedSettings, materializedMcp).changes;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Read the materialized active profile, merge it into the manifest, and write
|
|
124
|
-
* the manifest back if anything changed. Returns the applied changes.
|
|
125
|
-
*/
|
|
126
|
-
export async function syncProfile(
|
|
127
|
-
name: string,
|
|
128
|
-
projectPath?: string,
|
|
129
|
-
): Promise<{ changes: SyncChange[]; wrote: boolean }> {
|
|
130
|
-
const manifest: ProfileManifest = await readManifest(projectPath);
|
|
131
|
-
const entry = manifest.profiles[name];
|
|
132
|
-
if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
|
|
133
|
-
|
|
134
|
-
const dir = profileDir(name, projectPath);
|
|
135
|
-
const settingsPath = path.join(dir, "settings.json");
|
|
136
|
-
const mcpPath = path.join(dir, "mcp.json");
|
|
137
|
-
if (!(await fs.pathExists(settingsPath))) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`Profile "${name}" isn't materialized (${settingsPath} missing).`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const materializedSettings = await fs.readJson(settingsPath);
|
|
144
|
-
const materializedMcp = (await fs.pathExists(mcpPath))
|
|
145
|
-
? await fs.readJson(mcpPath)
|
|
146
|
-
: { mcpServers: {} };
|
|
147
|
-
|
|
148
|
-
const { entry: nextEntry, changes } = computeSync(
|
|
149
|
-
entry,
|
|
150
|
-
materializedSettings,
|
|
151
|
-
materializedMcp,
|
|
152
|
-
);
|
|
153
|
-
if (changes.length === 0) return { changes: [], wrote: false };
|
|
154
|
-
|
|
155
|
-
manifest.profiles[name] = nextEntry;
|
|
156
|
-
await writeManifest(manifest, projectPath);
|
|
157
|
-
return { changes, wrote: true };
|
|
158
|
-
}
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Profile symlink manager.
|
|
3
|
-
*
|
|
4
|
-
* Materialized profiles live at `.claude/_profiles/<name>/` (generated,
|
|
5
|
-
* gitignored). The active profile is made "live" by symlinking the shared
|
|
6
|
-
* config files into place:
|
|
7
|
-
*
|
|
8
|
-
* .claude/settings.json -> .claude/_profiles/<name>/settings.json
|
|
9
|
-
* .claude/skills -> .claude/_profiles/<name>/skills
|
|
10
|
-
* .mcp.json -> .claude/_profiles/<name>/mcp.json
|
|
11
|
-
* .claude/models.json -> .claude/_profiles/<name>/models.json
|
|
12
|
-
*
|
|
13
|
-
* Switching profiles just repoints these links — no network, no reinstall.
|
|
14
|
-
* settings.local.json is NEVER linked: it holds personal credentials and must
|
|
15
|
-
* survive switches untouched.
|
|
16
|
-
*
|
|
17
|
-
* models.json is the one link whose target is OPTIONAL: a profile that declares
|
|
18
|
-
* no routing has none, and the rule below (remove a link into _profiles/ whose
|
|
19
|
-
* target is gone) is what keeps a dangling `.claude/models.json` out of the
|
|
20
|
-
* repo. A dangling one would not be harmless — `.claude/` is committed, so it
|
|
21
|
-
* would ship to every teammate.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import type { Stats } from "node:fs";
|
|
25
|
-
import path from "node:path";
|
|
26
|
-
import fs from "fs-extra";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Paths that must be gitignored once a project uses profiles: the generated
|
|
30
|
-
* build output plus the active-profile symlinks. Only .claude/profiles.json is
|
|
31
|
-
* the committed source of truth. settings.local.json is intentionally NOT here
|
|
32
|
-
* (it's already gitignored elsewhere and holds personal credentials).
|
|
33
|
-
*/
|
|
34
|
-
export const PROFILE_GITIGNORE_ENTRIES = [
|
|
35
|
-
".claude/_profiles/",
|
|
36
|
-
".claude/.magus-backups/",
|
|
37
|
-
".claude/settings.json",
|
|
38
|
-
".claude/skills",
|
|
39
|
-
".claude/models.json",
|
|
40
|
-
".mcp.json",
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
/** The generated, gitignored directory holding materialized profiles. */
|
|
44
|
-
export function profilesRoot(projectPath?: string): string {
|
|
45
|
-
const base = projectPath ?? process.cwd();
|
|
46
|
-
return path.join(base, ".claude", "_profiles");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The materialized directory for one profile. */
|
|
50
|
-
export function profileDir(name: string, projectPath?: string): string {
|
|
51
|
-
return path.join(profilesRoot(projectPath), name);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The artifacts an active profile links into place. */
|
|
55
|
-
export type ProfileLinkKey = "settings" | "skills" | "mcp" | "models";
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Where each linked artifact lives, and what it is called inside a profile.
|
|
59
|
-
*
|
|
60
|
-
* One table, because the link paths do NOT depend on which profile is active
|
|
61
|
-
* and two places listing them is two places to forget one. `linkTargets` adds
|
|
62
|
-
* the profile dir; `linkPaths` needs no profile at all.
|
|
63
|
-
*/
|
|
64
|
-
const LINKED_ARTIFACTS: Array<{
|
|
65
|
-
key: ProfileLinkKey;
|
|
66
|
-
/** Path segments under the project root. */
|
|
67
|
-
at: string[];
|
|
68
|
-
/** File or directory name inside `_profiles/<name>/`. */
|
|
69
|
-
inProfile: string;
|
|
70
|
-
}> = [
|
|
71
|
-
{
|
|
72
|
-
key: "settings",
|
|
73
|
-
at: [".claude", "settings.json"],
|
|
74
|
-
inProfile: "settings.json",
|
|
75
|
-
},
|
|
76
|
-
{ key: "skills", at: [".claude", "skills"], inProfile: "skills" },
|
|
77
|
-
{ key: "mcp", at: [".mcp.json"], inProfile: "mcp.json" },
|
|
78
|
-
{ key: "models", at: [".claude", "models.json"], inProfile: "models.json" },
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
/** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
|
|
82
|
-
export function linkTargets(
|
|
83
|
-
name: string,
|
|
84
|
-
projectPath?: string,
|
|
85
|
-
): Array<{ key: ProfileLinkKey; link: string; target: string }> {
|
|
86
|
-
const base = projectPath ?? process.cwd();
|
|
87
|
-
const dir = profileDir(name, projectPath);
|
|
88
|
-
return LINKED_ARTIFACTS.map(({ key, at, inProfile }) => ({
|
|
89
|
-
key,
|
|
90
|
-
link: path.join(base, ...at),
|
|
91
|
-
target: path.join(dir, inProfile),
|
|
92
|
-
}));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The paths activation writes links at, whichever profile is chosen. */
|
|
96
|
-
export function linkPaths(projectPath?: string): string[] {
|
|
97
|
-
const base = projectPath ?? process.cwd();
|
|
98
|
-
return LINKED_ARTIFACTS.map(({ at }) => path.join(base, ...at));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Real artifacts that activating ANY profile would have to move aside.
|
|
103
|
-
*
|
|
104
|
-
* Empty means activating is lossless — a fresh clone, where the only things at
|
|
105
|
-
* these paths are nothing at all or links magus wrote itself. That is what
|
|
106
|
-
* makes it safe to honour the manifest's declared default without asking: there
|
|
107
|
-
* is no work of the user's to replace, so there is no decision to put to them.
|
|
108
|
-
*
|
|
109
|
-
* Non-empty means a human put something there, and the choice is theirs.
|
|
110
|
-
*/
|
|
111
|
-
export async function displaceableArtifacts(
|
|
112
|
-
projectPath?: string,
|
|
113
|
-
): Promise<string[]> {
|
|
114
|
-
const found: string[] = [];
|
|
115
|
-
for (const link of linkPaths(projectPath)) {
|
|
116
|
-
// A link we own is generated state the manifest reproduces on demand.
|
|
117
|
-
if (await isProfileLink(link, projectPath)) continue;
|
|
118
|
-
try {
|
|
119
|
-
await fs.lstat(link);
|
|
120
|
-
found.push(link);
|
|
121
|
-
} catch {
|
|
122
|
-
// Nothing there.
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return found;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Where activation moves a real file it had to displace.
|
|
130
|
-
*
|
|
131
|
-
* Beside `_profiles/`, never inside it. `_profiles/` is generated and is
|
|
132
|
-
* documented as regenerable from the manifest, so it is the first thing a
|
|
133
|
-
* cleanup — a user's, or ours — deletes. A backup stored there is the only copy
|
|
134
|
-
* of a hand-written file sitting in the one directory everybody is told is safe
|
|
135
|
-
* to remove. Gitignored all the same: these are recovery copies, not something
|
|
136
|
-
* to commit.
|
|
137
|
-
*/
|
|
138
|
-
export function backupRoot(projectPath?: string): string {
|
|
139
|
-
const base = projectPath ?? process.cwd();
|
|
140
|
-
return path.join(base, ".claude", ".magus-backups");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** One artifact activation moved aside to make room for its symlink. */
|
|
144
|
-
export interface DisplacedArtifact {
|
|
145
|
-
/** The path that is now a profile symlink. */
|
|
146
|
-
link: string;
|
|
147
|
-
/** Where its previous contents were moved. */
|
|
148
|
-
savedTo: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** A directory name that sorts chronologically and is legal on every platform. */
|
|
152
|
-
function backupStamp(): string {
|
|
153
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Move whatever sits at `link` into `backupDir`, unless it is generated state.
|
|
158
|
-
*
|
|
159
|
-
* Activation replaces four paths with symlinks, and replacing means removing
|
|
160
|
-
* what is there. Three of those paths are ordinary files a project writes by
|
|
161
|
-
* hand and commits. `fs.remove` on `.claude/settings.json` is unrecoverable, and
|
|
162
|
-
* it ran on every activation — which is why adoption deliberately stopped short
|
|
163
|
-
* of activating and left migrations half-done.
|
|
164
|
-
*
|
|
165
|
-
* A symlink magus itself wrote is skipped: it points into `_profiles/`, which
|
|
166
|
-
* the manifest regenerates on demand, so copying it would archive nothing and
|
|
167
|
-
* every `profile switch` would leave another directory behind. A symlink
|
|
168
|
-
* pointing ANYWHERE ELSE is somebody's deliberate wiring and is preserved like
|
|
169
|
-
* a real file — the link is moved, never its target.
|
|
170
|
-
*/
|
|
171
|
-
async function displace(
|
|
172
|
-
link: string,
|
|
173
|
-
backupDir: string,
|
|
174
|
-
projectPath?: string,
|
|
175
|
-
): Promise<DisplacedArtifact | null> {
|
|
176
|
-
if (await isProfileLink(link, projectPath)) return null;
|
|
177
|
-
let stat: Stats;
|
|
178
|
-
try {
|
|
179
|
-
stat = await fs.lstat(link);
|
|
180
|
-
} catch {
|
|
181
|
-
return null; // Nothing there — the common case on a first activation.
|
|
182
|
-
}
|
|
183
|
-
// The four link paths have four distinct basenames, so one flat directory
|
|
184
|
-
// holds a whole activation without collisions.
|
|
185
|
-
const savedTo = path.join(backupDir, path.basename(link));
|
|
186
|
-
await fs.ensureDir(backupDir);
|
|
187
|
-
|
|
188
|
-
if (stat.isSymbolicLink()) {
|
|
189
|
-
// A symlink's target is resolved against the directory the LINK sits in,
|
|
190
|
-
// so moving the link two levels deeper silently repoints it: a relative
|
|
191
|
-
// `../shared/mcp.json` that worked becomes dangling, and the "preserved"
|
|
192
|
-
// copy preserves nothing. Rewrite it as absolute at its original meaning.
|
|
193
|
-
const target = await fs.readlink(link);
|
|
194
|
-
const absolute = path.resolve(path.dirname(link), target);
|
|
195
|
-
await fs.remove(savedTo);
|
|
196
|
-
await fs.symlink(absolute, savedTo);
|
|
197
|
-
await fs.remove(link);
|
|
198
|
-
return { link, savedTo };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
await fs.move(link, savedTo, { overwrite: true });
|
|
202
|
-
return { link, savedTo };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Point `link` at `target` with a relative symlink. Any existing file, dir, or
|
|
207
|
-
* link at `link` is removed first. The target need not exist yet (dangling
|
|
208
|
-
* links are tolerated so a profile can be activated before every artifact is
|
|
209
|
-
* materialized), but its parent is created.
|
|
210
|
-
*/
|
|
211
|
-
async function relink(link: string, target: string): Promise<void> {
|
|
212
|
-
await fs.ensureDir(path.dirname(link));
|
|
213
|
-
await fs.remove(link);
|
|
214
|
-
const rel = path.relative(path.dirname(link), target);
|
|
215
|
-
await fs.symlink(rel, link);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Activate a profile: repoint every shared-config symlink into its
|
|
220
|
-
* materialized directory. Only links whose target exists in the profile dir
|
|
221
|
-
* are created (e.g. a profile with no skills/ dir gets no skills symlink).
|
|
222
|
-
*
|
|
223
|
-
* Returns whatever had to be moved aside to make room — empty for an ordinary
|
|
224
|
-
* `profile switch`, one entry per hand-written artifact the first time a
|
|
225
|
-
* project adopts profiles. Callers report it so the move is never silent.
|
|
226
|
-
*/
|
|
227
|
-
export async function activateProfile(
|
|
228
|
-
name: string,
|
|
229
|
-
projectPath?: string,
|
|
230
|
-
/**
|
|
231
|
-
* Called the instant an artifact is moved, before the next link is touched.
|
|
232
|
-
*
|
|
233
|
-
* The return value alone is not enough, and the difference is data the user
|
|
234
|
-
* cannot otherwise find: activation moves up to four artifacts in sequence,
|
|
235
|
-
* so a throw on the second discards the array describing the first — leaving
|
|
236
|
-
* `.claude/settings.json` gone from where they put it and no message saying
|
|
237
|
-
* where it went. Reporting as it happens is what makes a half-finished
|
|
238
|
-
* activation recoverable by hand.
|
|
239
|
-
*/
|
|
240
|
-
onDisplace?: (artifact: DisplacedArtifact) => void,
|
|
241
|
-
): Promise<DisplacedArtifact[]> {
|
|
242
|
-
const dir = profileDir(name, projectPath);
|
|
243
|
-
if (!(await fs.pathExists(dir))) {
|
|
244
|
-
throw new Error(
|
|
245
|
-
`Profile "${name}" is not materialized (${dir} missing). Run \`magus install ${name}\`.`,
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
// One stamped directory per activation, so the four artifacts that were live
|
|
249
|
-
// together stay together and can be restored as the set they were.
|
|
250
|
-
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
251
|
-
const displaced: DisplacedArtifact[] = [];
|
|
252
|
-
for (const { link, target } of linkTargets(name, projectPath)) {
|
|
253
|
-
const moved = await syncLink(link, target, projectPath, backupDir);
|
|
254
|
-
if (moved) {
|
|
255
|
-
displaced.push(moved);
|
|
256
|
-
onDisplace?.(moved);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return displaced;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Point one link at its target, or remove it when the profile has no such
|
|
264
|
-
* artifact. A link is only removed when it points into `_profiles/` — a real
|
|
265
|
-
* file the user put there is never clobbered.
|
|
266
|
-
*/
|
|
267
|
-
async function syncLink(
|
|
268
|
-
link: string,
|
|
269
|
-
target: string,
|
|
270
|
-
projectPath: string | undefined,
|
|
271
|
-
// Required, not optional. An optional backup directory keeps a
|
|
272
|
-
// replace-without-backup path expressible, and that path is the whole defect
|
|
273
|
-
// this function was changed to close — a future caller omitting the argument
|
|
274
|
-
// would reintroduce it silently, and type-check clean while doing so.
|
|
275
|
-
backupDir: string,
|
|
276
|
-
): Promise<DisplacedArtifact | null> {
|
|
277
|
-
if (await fs.pathExists(target)) {
|
|
278
|
-
const moved = await displace(link, backupDir, projectPath);
|
|
279
|
-
await relink(link, target);
|
|
280
|
-
return moved;
|
|
281
|
-
}
|
|
282
|
-
if (await isProfileLink(link, projectPath)) await fs.remove(link);
|
|
283
|
-
return null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Sync ONE of the active profile's links, leaving the others alone.
|
|
288
|
-
*
|
|
289
|
-
* `activateProfile` repoints all four, which is right when switching profiles
|
|
290
|
-
* and wrong when only one artifact changed: `magus models use` would replace
|
|
291
|
-
* a project's real `.mcp.json` with a symlink as a side effect of choosing a
|
|
292
|
-
* model. This is the narrow door for that case.
|
|
293
|
-
*/
|
|
294
|
-
export async function syncProfileLink(
|
|
295
|
-
name: string,
|
|
296
|
-
key: ProfileLinkKey,
|
|
297
|
-
projectPath?: string,
|
|
298
|
-
): Promise<DisplacedArtifact | null> {
|
|
299
|
-
const entry = linkTargets(name, projectPath).find((t) => t.key === key);
|
|
300
|
-
if (!entry) return null;
|
|
301
|
-
// Backed up for the same reason activation is: `magus models use` on a
|
|
302
|
-
// project with a hand-written `.claude/models.json` would otherwise delete it
|
|
303
|
-
// as a side effect of choosing a model.
|
|
304
|
-
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
305
|
-
return syncLink(entry.link, entry.target, projectPath, backupDir);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Is `link` a symlink pointing into `.claude/_profiles/`? */
|
|
309
|
-
async function isProfileLink(
|
|
310
|
-
link: string,
|
|
311
|
-
projectPath?: string,
|
|
312
|
-
): Promise<boolean> {
|
|
313
|
-
try {
|
|
314
|
-
const stat = await fs.lstat(link);
|
|
315
|
-
if (!stat.isSymbolicLink()) return false;
|
|
316
|
-
const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
|
|
317
|
-
return resolved.startsWith(profilesRoot(projectPath) + path.sep);
|
|
318
|
-
} catch {
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* The name of the currently active profile, derived from where
|
|
325
|
-
* .claude/settings.json points. Returns null if it isn't a profile symlink.
|
|
326
|
-
*/
|
|
327
|
-
export async function activeProfile(
|
|
328
|
-
projectPath?: string,
|
|
329
|
-
): Promise<string | null> {
|
|
330
|
-
const base = projectPath ?? process.cwd();
|
|
331
|
-
const link = path.join(base, ".claude", "settings.json");
|
|
332
|
-
try {
|
|
333
|
-
const stat = await fs.lstat(link);
|
|
334
|
-
if (!stat.isSymbolicLink()) return null;
|
|
335
|
-
const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
|
|
336
|
-
const root = profilesRoot(projectPath) + path.sep;
|
|
337
|
-
if (!resolved.startsWith(root)) return null;
|
|
338
|
-
// resolved = <root>/<name>/settings.json → take the first segment.
|
|
339
|
-
return resolved.slice(root.length).split(path.sep)[0] ?? null;
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
}
|