magus-cli 7.4.0 → 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__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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__/stale-load-repair.test.ts +5 -4
- 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 +152 -44
- package/src/cli/install.ts +92 -127
- 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/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- 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,180 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { lstat, mkdtemp, readlink, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import fs from "fs-extra";
|
|
6
|
-
import {
|
|
7
|
-
activateProfile,
|
|
8
|
-
activeProfile,
|
|
9
|
-
profileDir,
|
|
10
|
-
} from "../services/symlink-manager.js";
|
|
11
|
-
|
|
12
|
-
/** Materialize a fake profile dir with the given files. */
|
|
13
|
-
async function materialize(
|
|
14
|
-
project: string,
|
|
15
|
-
name: string,
|
|
16
|
-
files: {
|
|
17
|
-
settings?: object;
|
|
18
|
-
mcp?: object;
|
|
19
|
-
skills?: boolean;
|
|
20
|
-
models?: object;
|
|
21
|
-
},
|
|
22
|
-
) {
|
|
23
|
-
const dir = profileDir(name, project);
|
|
24
|
-
await fs.ensureDir(dir);
|
|
25
|
-
if (files.settings)
|
|
26
|
-
await fs.writeJson(join(dir, "settings.json"), files.settings);
|
|
27
|
-
if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
|
|
28
|
-
if (files.skills) await fs.ensureDir(join(dir, "skills"));
|
|
29
|
-
if (files.models) await fs.writeJson(join(dir, "models.json"), files.models);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("symlink-manager", () => {
|
|
33
|
-
let project: string;
|
|
34
|
-
beforeEach(async () => {
|
|
35
|
-
project = await mkdtemp(join(tmpdir(), "symlink-"));
|
|
36
|
-
});
|
|
37
|
-
afterEach(async () => {
|
|
38
|
-
await rm(project, { recursive: true, force: true });
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("activateProfile links settings.json, .mcp.json, and skills/", async () => {
|
|
42
|
-
await materialize(project, "frontend", {
|
|
43
|
-
settings: { enabledPlugins: { "dev@magus": true } },
|
|
44
|
-
mcp: { mcpServers: {} },
|
|
45
|
-
skills: true,
|
|
46
|
-
});
|
|
47
|
-
await activateProfile("frontend", project);
|
|
48
|
-
|
|
49
|
-
// settings.json is a symlink into the profile dir
|
|
50
|
-
expect(
|
|
51
|
-
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
52
|
-
).toBe(true);
|
|
53
|
-
expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(
|
|
54
|
-
true,
|
|
55
|
-
);
|
|
56
|
-
expect(
|
|
57
|
-
(await lstat(join(project, ".claude", "skills"))).isSymbolicLink(),
|
|
58
|
-
).toBe(true);
|
|
59
|
-
|
|
60
|
-
// reading through the link returns the profile's content
|
|
61
|
-
const settings = await fs.readJson(
|
|
62
|
-
join(project, ".claude", "settings.json"),
|
|
63
|
-
);
|
|
64
|
-
expect(settings.enabledPlugins["dev@magus"]).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("writeJson through the settings symlink lands in the profile dir", async () => {
|
|
68
|
-
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
69
|
-
await activateProfile("frontend", project);
|
|
70
|
-
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
|
|
71
|
-
// The real file inside the profile dir was updated (symlink preserved).
|
|
72
|
-
expect(
|
|
73
|
-
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
74
|
-
).toBe(true);
|
|
75
|
-
expect(
|
|
76
|
-
(await fs.readJson(`${profileDir("frontend", project)}/settings.json`)).a,
|
|
77
|
-
).toBe(2);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("switching repoints links and activeProfile tracks it", async () => {
|
|
81
|
-
await materialize(project, "frontend", { settings: { which: "frontend" } });
|
|
82
|
-
await materialize(project, "backend", { settings: { which: "backend" } });
|
|
83
|
-
|
|
84
|
-
await activateProfile("frontend", project);
|
|
85
|
-
expect(await activeProfile(project)).toBe("frontend");
|
|
86
|
-
expect(
|
|
87
|
-
(await fs.readJson(join(project, ".claude", "settings.json"))).which,
|
|
88
|
-
).toBe("frontend");
|
|
89
|
-
|
|
90
|
-
await activateProfile("backend", project);
|
|
91
|
-
expect(await activeProfile(project)).toBe("backend");
|
|
92
|
-
expect(
|
|
93
|
-
(await fs.readJson(join(project, ".claude", "settings.json"))).which,
|
|
94
|
-
).toBe("backend");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("uses relative (not absolute) symlinks", async () => {
|
|
98
|
-
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
99
|
-
await activateProfile("frontend", project);
|
|
100
|
-
const target = await readlink(join(project, ".claude", "settings.json"));
|
|
101
|
-
expect(target.startsWith("/")).toBe(false);
|
|
102
|
-
expect(target).toContain("_profiles");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("a profile without skills/ creates no skills link", async () => {
|
|
106
|
-
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
107
|
-
await activateProfile("frontend", project);
|
|
108
|
-
expect(await fs.pathExists(join(project, ".claude", "skills"))).toBe(false);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("activateProfile throws if the profile isn't materialized", async () => {
|
|
112
|
-
await expect(activateProfile("ghost", project)).rejects.toThrow(
|
|
113
|
-
"not materialized",
|
|
114
|
-
);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("links models.json when the profile has routing", async () => {
|
|
118
|
-
await materialize(project, "frontend", {
|
|
119
|
-
settings: { a: 1 },
|
|
120
|
-
models: { version: 1, preset: "fable-advisor" },
|
|
121
|
-
});
|
|
122
|
-
await activateProfile("frontend", project);
|
|
123
|
-
|
|
124
|
-
const link = join(project, ".claude", "models.json");
|
|
125
|
-
expect((await lstat(link)).isSymbolicLink()).toBe(true);
|
|
126
|
-
expect((await fs.readJson(link)).preset).toBe("fable-advisor");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// A dangling .claude/models.json is a repo-wide hazard: `.claude/` is
|
|
130
|
-
// committed, so it would ship to every teammate and point at a directory
|
|
131
|
-
// that only exists on the machine that made it.
|
|
132
|
-
test("a profile without routing gets no models link at all", async () => {
|
|
133
|
-
await materialize(project, "frontend", { settings: { a: 1 } });
|
|
134
|
-
await activateProfile("frontend", project);
|
|
135
|
-
|
|
136
|
-
const link = join(project, ".claude", "models.json");
|
|
137
|
-
expect(await fs.pathExists(link)).toBe(false);
|
|
138
|
-
await expect(lstat(link)).rejects.toThrow();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("switching to a profile without routing removes the previous link", async () => {
|
|
142
|
-
await materialize(project, "routed", {
|
|
143
|
-
settings: { which: "routed" },
|
|
144
|
-
models: { version: 1, preset: "fable-advisor" },
|
|
145
|
-
});
|
|
146
|
-
await materialize(project, "plain", { settings: { which: "plain" } });
|
|
147
|
-
|
|
148
|
-
await activateProfile("routed", project);
|
|
149
|
-
expect(
|
|
150
|
-
(await lstat(join(project, ".claude", "models.json"))).isSymbolicLink(),
|
|
151
|
-
).toBe(true);
|
|
152
|
-
|
|
153
|
-
await activateProfile("plain", project);
|
|
154
|
-
await expect(
|
|
155
|
-
lstat(join(project, ".claude", "models.json")),
|
|
156
|
-
).rejects.toThrow();
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Removal is scoped to links INTO _profiles/. A real models.json a project
|
|
160
|
-
// wrote by hand is not magus's to delete.
|
|
161
|
-
test("a real models.json file is never clobbered by activation", async () => {
|
|
162
|
-
await materialize(project, "plain", { settings: { a: 1 } });
|
|
163
|
-
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
164
|
-
version: 1,
|
|
165
|
-
preset: "hand-written",
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
await activateProfile("plain", project);
|
|
169
|
-
|
|
170
|
-
expect(
|
|
171
|
-
(await fs.readJson(join(project, ".claude", "models.json"))).preset,
|
|
172
|
-
).toBe("hand-written");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("activeProfile returns null when settings.json is a real file", async () => {
|
|
176
|
-
await fs.ensureDir(join(project, ".claude"));
|
|
177
|
-
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
|
|
178
|
-
expect(await activeProfile(project)).toBeNull();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
@@ -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
|
-
}
|