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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a profile's live config from the manifest, in memory.
|
|
3
|
+
*
|
|
4
|
+
* The output is what activation writes straight to `.claude/settings.json`,
|
|
5
|
+
* `.mcp.json` and `.claude/models.json`. Nothing is kept anywhere else: the
|
|
6
|
+
* manifest is the source, these are views of it. Skills are not rendered —
|
|
7
|
+
* they are installed (see `profile-skills.ts`).
|
|
8
|
+
*
|
|
9
|
+
* No installs and no global state, so it is testable with a temp dir.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ProfileManifest, ResolvedClosure } from "../types/index.js";
|
|
13
|
+
import { type RenderedProfile, canonicalJson, sha256 } from "./profile-live.js";
|
|
14
|
+
import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the settings.json contents for a profile: every resolved plugin
|
|
18
|
+
* enabled, merged with the profile's declared settings. The profile's settings
|
|
19
|
+
* win over the derived enabledPlugins only if it explicitly sets that key
|
|
20
|
+
* (it normally won't).
|
|
21
|
+
*
|
|
22
|
+
* `allPluginIds` is the manifest-wide union of plugins across every profile.
|
|
23
|
+
* Plugins in the union that this profile does NOT want are written as an
|
|
24
|
+
* explicit `false`, which is what makes `profile switch` exclusive rather than
|
|
25
|
+
* additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
|
|
26
|
+
* the highest-precedence scope down and taking the first scope that mentions
|
|
27
|
+
* the id — an id a scope omits falls through to the next one, so a non-member
|
|
28
|
+
* enabled at user scope would otherwise stay enabled here. A plugin the
|
|
29
|
+
* profile itself declares `false` is written as `false` for the same reason.
|
|
30
|
+
*/
|
|
31
|
+
export function buildProfileSettings(
|
|
32
|
+
closure: ResolvedClosure,
|
|
33
|
+
allPluginIds: readonly string[] = [],
|
|
34
|
+
): Record<string, unknown> {
|
|
35
|
+
const enabledPlugins: Record<string, boolean> = {};
|
|
36
|
+
for (const pluginId of allPluginIds) {
|
|
37
|
+
enabledPlugins[pluginId] = false;
|
|
38
|
+
}
|
|
39
|
+
for (const pluginId of closure.disabledPlugins ?? []) {
|
|
40
|
+
enabledPlugins[pluginId] = false;
|
|
41
|
+
}
|
|
42
|
+
for (const pluginId of Object.keys(closure.plugins)) {
|
|
43
|
+
enabledPlugins[pluginId] = true;
|
|
44
|
+
}
|
|
45
|
+
return { enabledPlugins, ...closure.settings };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the .mcp.json contents for a profile. */
|
|
49
|
+
export function buildProfileMcp(closure: ResolvedClosure): {
|
|
50
|
+
mcpServers: Record<string, unknown>;
|
|
51
|
+
} {
|
|
52
|
+
return { mcpServers: { ...closure.mcpServers } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Rendering needs no binary requirements — nothing it writes reads
|
|
57
|
+
* `closure.bins` — and skipping the per-plugin `plugin.json` reads keeps it off
|
|
58
|
+
* the marketplace cache entirely.
|
|
59
|
+
*/
|
|
60
|
+
const noBins = async () => [];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The hash of everything a profile's live config is rendered from.
|
|
64
|
+
*
|
|
65
|
+
* Not the entry alone. The resolved closure carries what the entry inherits
|
|
66
|
+
* through `extends` and the marketplaces its plugin ids imply, and the plugin
|
|
67
|
+
* ids of EVERY profile decide which `false` entries this one writes — so a
|
|
68
|
+
* teammate adding a plugin to another profile changes this profile's
|
|
69
|
+
* settings.json. Hashing only the entry would leave that change unrendered
|
|
70
|
+
* until the next switch. Install-only fields (`bins`, `env`, cross-profile
|
|
71
|
+
* version conflicts) are left out: they change no live file.
|
|
72
|
+
*/
|
|
73
|
+
function renderDefinition(
|
|
74
|
+
closure: ResolvedClosure,
|
|
75
|
+
allPluginIds: readonly string[],
|
|
76
|
+
): string {
|
|
77
|
+
return sha256(
|
|
78
|
+
canonicalJson({
|
|
79
|
+
marketplaces: closure.marketplaces,
|
|
80
|
+
plugins: closure.plugins,
|
|
81
|
+
disabledPlugins: [...(closure.disabledPlugins ?? [])].sort(),
|
|
82
|
+
settings: closure.settings,
|
|
83
|
+
mcpServers: closure.mcpServers,
|
|
84
|
+
models: closure.models ?? null,
|
|
85
|
+
skills: closure.skills,
|
|
86
|
+
allPluginIds: [...allPluginIds].sort(),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The live settings.json, .mcp.json and models.json content of profile
|
|
93
|
+
* `name`, rendered from the manifest. The union of plugin ids across every
|
|
94
|
+
* profile is included so non-members are written as an explicit `false`.
|
|
95
|
+
*/
|
|
96
|
+
export async function renderProfile(
|
|
97
|
+
manifest: ProfileManifest,
|
|
98
|
+
name: string,
|
|
99
|
+
): Promise<RenderedProfile> {
|
|
100
|
+
const entry = manifest.profiles[name];
|
|
101
|
+
if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
|
|
102
|
+
const closure = await resolveProfile(manifest, name, { binResolver: noBins });
|
|
103
|
+
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
104
|
+
const allPluginIds = Object.keys(union.plugins);
|
|
105
|
+
return {
|
|
106
|
+
settings: buildProfileSettings(closure, allPluginIds),
|
|
107
|
+
mcp: buildProfileMcp(closure),
|
|
108
|
+
models: closure.models
|
|
109
|
+
? (closure.models as unknown as Record<string, unknown>)
|
|
110
|
+
: null,
|
|
111
|
+
entry,
|
|
112
|
+
definition: renderDefinition(closure, allPluginIds),
|
|
113
|
+
allPluginIds: [...allPluginIds].sort(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The active profile's skills in `.claude/skills/`.
|
|
3
|
+
*
|
|
4
|
+
* `.claude/skills/` is a real directory holding two kinds of folder:
|
|
5
|
+
*
|
|
6
|
+
* magus-installed a skill the active profile lists (`skills` in
|
|
7
|
+
* profiles.json). Installed by magus, recorded in the
|
|
8
|
+
* marker with a content hash, gitignored by its own line.
|
|
9
|
+
* custom every other folder. Owned by git and the team. magus
|
|
10
|
+
* never moves, copies, backs up or deletes one.
|
|
11
|
+
*
|
|
12
|
+
* Switching removes only the magus-installed folders the new profile does not
|
|
13
|
+
* list, and installs the ones it does. A profile listing a skill whose folder
|
|
14
|
+
* already exists as a custom skill is refused before anything changes.
|
|
15
|
+
*
|
|
16
|
+
* A magus-installed folder whose contents no longer match the marker has been
|
|
17
|
+
* edited by someone, and magus stops managing it: it becomes a custom skill
|
|
18
|
+
* (see `profile-absorb.ts`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import fs from "fs-extra";
|
|
23
|
+
import type {
|
|
24
|
+
ProfileManifest,
|
|
25
|
+
ProfileSkillRef,
|
|
26
|
+
SkillInfo,
|
|
27
|
+
} from "../types/index.js";
|
|
28
|
+
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
29
|
+
import {
|
|
30
|
+
PROFILE_GITIGNORE_COMMENT,
|
|
31
|
+
backupRoot,
|
|
32
|
+
backupStamp,
|
|
33
|
+
folderHash,
|
|
34
|
+
isSegment,
|
|
35
|
+
legacyProfileDirs,
|
|
36
|
+
legacyProfilesRoot,
|
|
37
|
+
profileLinkTarget,
|
|
38
|
+
skillsRoot,
|
|
39
|
+
} from "./profile-live.js";
|
|
40
|
+
import { installSkill, skillFolderName } from "./skills-manager.js";
|
|
41
|
+
|
|
42
|
+
/** Installs one profile skill into `.claude/skills/<folder>/`. */
|
|
43
|
+
export type SkillInstaller = (
|
|
44
|
+
ref: ProfileSkillRef,
|
|
45
|
+
projectPath: string,
|
|
46
|
+
) => Promise<void>;
|
|
47
|
+
|
|
48
|
+
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
49
|
+
export function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
50
|
+
return {
|
|
51
|
+
id: `${ref.repo}/${ref.path}`,
|
|
52
|
+
name: ref.name,
|
|
53
|
+
source: {
|
|
54
|
+
label: ref.repo,
|
|
55
|
+
repo: ref.repo,
|
|
56
|
+
skillsPath: path.dirname(ref.path),
|
|
57
|
+
},
|
|
58
|
+
repoPath: ref.path,
|
|
59
|
+
gitBlobSha: "",
|
|
60
|
+
frontmatter: null,
|
|
61
|
+
installed: false,
|
|
62
|
+
installedScope: null,
|
|
63
|
+
hasUpdate: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The real installer: fetches the skill from GitHub into the project. */
|
|
68
|
+
export const installProfileSkill: SkillInstaller = async (ref, projectPath) => {
|
|
69
|
+
await installSkill(toSkillInfo(ref), "project", projectPath);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The folder under `.claude/skills/` a profile skill installs into. */
|
|
73
|
+
export function skillFolder(ref: ProfileSkillRef): string {
|
|
74
|
+
return skillFolderName(ref.name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The gitignore line for one magus-installed skill folder. */
|
|
78
|
+
export function skillIgnoreLine(folder: string): string {
|
|
79
|
+
return `.claude/skills/${folder}/`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function exists(p: string): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
await fs.lstat(p);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Clashes ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** A profile skill whose folder is already taken by a custom skill. */
|
|
94
|
+
export interface SkillClash {
|
|
95
|
+
skill: string;
|
|
96
|
+
folder: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Thrown before anything changes when a profile's skill would overwrite a custom one. */
|
|
100
|
+
export class SkillClashError extends Error {
|
|
101
|
+
constructor(
|
|
102
|
+
readonly profile: string,
|
|
103
|
+
readonly clashes: SkillClash[],
|
|
104
|
+
) {
|
|
105
|
+
super(
|
|
106
|
+
[
|
|
107
|
+
`Cannot activate profile "${profile}": it lists ${clashes.length === 1 ? "a skill" : "skills"} whose folder is already a custom skill.`,
|
|
108
|
+
...clashes.map(
|
|
109
|
+
(c) =>
|
|
110
|
+
` profile skill "${c.skill}" vs custom skill .claude/skills/${c.folder}/`,
|
|
111
|
+
),
|
|
112
|
+
"Rename or remove one of each pair, then try again. Nothing was changed.",
|
|
113
|
+
].join("\n"),
|
|
114
|
+
);
|
|
115
|
+
this.name = "SkillClashError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The profile skills whose folder exists and is NOT magus-installed.
|
|
121
|
+
* `managed` is the marker's skills map.
|
|
122
|
+
*/
|
|
123
|
+
export async function findSkillClashes(
|
|
124
|
+
skills: readonly ProfileSkillRef[],
|
|
125
|
+
managed: Readonly<Record<string, string>>,
|
|
126
|
+
projectPath: string,
|
|
127
|
+
): Promise<SkillClash[]> {
|
|
128
|
+
const clashes: SkillClash[] = [];
|
|
129
|
+
for (const ref of skills) {
|
|
130
|
+
const folder = skillFolder(ref);
|
|
131
|
+
if (folder in managed) continue;
|
|
132
|
+
if (await exists(path.join(skillsRoot(projectPath), folder))) {
|
|
133
|
+
clashes.push({ skill: ref.name, folder });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return clashes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Sync ────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export interface SkillSyncResult {
|
|
142
|
+
/** The magus-installed folders now present, with content hash. */
|
|
143
|
+
skills: Record<string, string>;
|
|
144
|
+
installed: string[];
|
|
145
|
+
removed: string[];
|
|
146
|
+
/** Profile skills that could not be installed, with the reason. */
|
|
147
|
+
failed: Array<{ skill: string; reason: string }>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Make `.claude/skills/` hold exactly the profile's skills, alongside whatever
|
|
152
|
+
* custom skills are there.
|
|
153
|
+
*
|
|
154
|
+
* A previously installed folder the profile no longer lists is removed only if
|
|
155
|
+
* it is unchanged since magus installed it; an edited one is left as a custom
|
|
156
|
+
* skill. A listed skill already installed and unchanged is kept as is — no
|
|
157
|
+
* re-fetch. A failed install leaves no half-written folder behind, since a
|
|
158
|
+
* folder magus does not record would read as a custom skill.
|
|
159
|
+
*/
|
|
160
|
+
export async function syncSkills(
|
|
161
|
+
skills: readonly ProfileSkillRef[],
|
|
162
|
+
managed: Readonly<Record<string, string>>,
|
|
163
|
+
projectPath: string,
|
|
164
|
+
install: SkillInstaller = installProfileSkill,
|
|
165
|
+
): Promise<SkillSyncResult> {
|
|
166
|
+
const root = skillsRoot(projectPath);
|
|
167
|
+
const wanted = new Map<string, ProfileSkillRef>();
|
|
168
|
+
for (const ref of skills) {
|
|
169
|
+
const folder = skillFolder(ref);
|
|
170
|
+
if (isSegment(folder)) wanted.set(folder, ref);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result: SkillSyncResult = {
|
|
174
|
+
skills: {},
|
|
175
|
+
installed: [],
|
|
176
|
+
removed: [],
|
|
177
|
+
failed: [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const [folder, hash] of Object.entries(managed)) {
|
|
181
|
+
if (wanted.has(folder)) continue;
|
|
182
|
+
const dir = path.join(root, folder);
|
|
183
|
+
if ((await folderHash(dir)) === hash) {
|
|
184
|
+
await fs.remove(dir);
|
|
185
|
+
result.removed.push(folder);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const [folder, ref] of wanted) {
|
|
190
|
+
const dir = path.join(root, folder);
|
|
191
|
+
const recorded = managed[folder];
|
|
192
|
+
const current = await folderHash(dir);
|
|
193
|
+
if (recorded !== undefined && current === recorded) {
|
|
194
|
+
result.skills[folder] = recorded;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Present but not ours (or ours and since edited): never overwritten.
|
|
198
|
+
// Clashes are refused before this runs; this is the backstop.
|
|
199
|
+
if (await exists(dir)) continue;
|
|
200
|
+
try {
|
|
201
|
+
await fs.ensureDir(root);
|
|
202
|
+
await install(ref, projectPath);
|
|
203
|
+
const hash = await folderHash(dir);
|
|
204
|
+
if (hash === null) {
|
|
205
|
+
throw new Error(`nothing was installed into .claude/skills/${folder}`);
|
|
206
|
+
}
|
|
207
|
+
result.skills[folder] = hash;
|
|
208
|
+
result.installed.push(folder);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await fs.remove(dir);
|
|
211
|
+
result.failed.push({
|
|
212
|
+
skill: ref.name,
|
|
213
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Magus-installed folders whose contents no longer match the marker (edited),
|
|
222
|
+
* and ones that are gone (missing).
|
|
223
|
+
*/
|
|
224
|
+
export async function inspectManagedSkills(
|
|
225
|
+
managed: Readonly<Record<string, string>>,
|
|
226
|
+
projectPath: string,
|
|
227
|
+
): Promise<{ edited: string[]; missing: string[] }> {
|
|
228
|
+
const edited: string[] = [];
|
|
229
|
+
const missing: string[] = [];
|
|
230
|
+
for (const [folder, hash] of Object.entries(managed)) {
|
|
231
|
+
const current = await folderHash(
|
|
232
|
+
path.join(skillsRoot(projectPath), folder),
|
|
233
|
+
);
|
|
234
|
+
if (current === null) missing.push(folder);
|
|
235
|
+
else if (current !== hash) edited.push(folder);
|
|
236
|
+
}
|
|
237
|
+
return { edited, missing };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Gitignore ───────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Keep one ignore line per magus-installed skill folder in magus's own
|
|
244
|
+
* `.gitignore` block: `before` are the folders the marker listed, `after` the
|
|
245
|
+
* ones it lists now. Lines are taken out of magus's block only — the same
|
|
246
|
+
* pattern a person wrote elsewhere in the file stays.
|
|
247
|
+
*/
|
|
248
|
+
export async function updateSkillIgnoreLines(
|
|
249
|
+
projectPath: string,
|
|
250
|
+
before: readonly string[],
|
|
251
|
+
after: readonly string[],
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const keep = new Set(after);
|
|
254
|
+
await updateManagedGitignoreBlock(
|
|
255
|
+
path.join(projectPath, ".gitignore"),
|
|
256
|
+
PROFILE_GITIGNORE_COMMENT,
|
|
257
|
+
{
|
|
258
|
+
add: [...keep].map(skillIgnoreLine),
|
|
259
|
+
remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Converting an older layout ──────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export interface LegacySkillConversion {
|
|
267
|
+
/** True when there was anything to convert. */
|
|
268
|
+
converted: boolean;
|
|
269
|
+
/** Magus-installed skills carried over into `.claude/skills/`, with hash. */
|
|
270
|
+
managed: Record<string, string>;
|
|
271
|
+
/** Custom skills put back into `.claude/skills/`. */
|
|
272
|
+
restored: string[];
|
|
273
|
+
/** A custom skill found in two places with different contents; both kept. */
|
|
274
|
+
conflicts: Array<{ folder: string; kept: string; other: string }>;
|
|
275
|
+
/**
|
|
276
|
+
* Where `.claude/_profiles/` is kept once activation finishes — see
|
|
277
|
+
* {@link archiveOlderLayout} — or null when there is none to keep.
|
|
278
|
+
*/
|
|
279
|
+
keptAt: string | null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Backup directories that hold a `skills/` dir, newest first. */
|
|
283
|
+
async function backupSkillDirs(projectPath: string): Promise<string[]> {
|
|
284
|
+
let stamps: string[];
|
|
285
|
+
try {
|
|
286
|
+
stamps = await fs.readdir(backupRoot(projectPath));
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const out: string[] = [];
|
|
291
|
+
for (const stamp of stamps.sort().reverse()) {
|
|
292
|
+
const dir = path.join(backupRoot(projectPath), stamp, "skills");
|
|
293
|
+
try {
|
|
294
|
+
if ((await fs.lstat(dir)).isDirectory()) out.push(dir);
|
|
295
|
+
} catch {
|
|
296
|
+
// This backup holds no skills.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function subfolders(dir: string): Promise<string[]> {
|
|
303
|
+
try {
|
|
304
|
+
const names = await fs.readdir(dir);
|
|
305
|
+
const out: string[] = [];
|
|
306
|
+
for (const name of names.sort()) {
|
|
307
|
+
if (!isSegment(name)) continue;
|
|
308
|
+
if ((await fs.lstat(path.join(dir, name))).isDirectory()) out.push(name);
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function manifestSkillFolders(
|
|
317
|
+
manifest: ProfileManifest,
|
|
318
|
+
profile: string,
|
|
319
|
+
): Set<string> {
|
|
320
|
+
return new Set((manifest.profiles[profile]?.skills ?? []).map(skillFolder));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Convert an older layout's skills: `.claude/skills` as a symlink into
|
|
325
|
+
* `.claude/_profiles/<n>/skills`, the layout magus 7.4.1 and earlier wrote.
|
|
326
|
+
*
|
|
327
|
+
* The link is replaced by a real directory. The linked profile's own skills
|
|
328
|
+
* move into it as magus-installed. Every other skill folder found in the old
|
|
329
|
+
* profile directories or in `.claude/.magus-backups/*\/skills/` (newest first)
|
|
330
|
+
* that is not one of that profile's manifest skills goes back as a CUSTOM
|
|
331
|
+
* skill, byte for byte — those are the committed skills the old layout
|
|
332
|
+
* displaced when it seeded them into a profile, which is why git showed them as
|
|
333
|
+
* deleted. An existing folder is never overwritten; one that differs from a
|
|
334
|
+
* candidate is reported with both paths. Backups are copied from, never
|
|
335
|
+
* modified.
|
|
336
|
+
*
|
|
337
|
+
* Nothing is deleted. What remains of `.claude/_profiles/` — the settings,
|
|
338
|
+
* MCP and routing files Claude Code may have written through the old links,
|
|
339
|
+
* other profiles' skills, the older marker — is moved whole into
|
|
340
|
+
* `.claude/.magus-backups/<stamp>-layout/` by {@link archiveOlderLayout} once
|
|
341
|
+
* activation has written the new files. `keptAt` names that path.
|
|
342
|
+
*/
|
|
343
|
+
export async function convertLegacySkills(
|
|
344
|
+
manifest: ProfileManifest,
|
|
345
|
+
projectPath: string,
|
|
346
|
+
): Promise<LegacySkillConversion> {
|
|
347
|
+
const result: LegacySkillConversion = {
|
|
348
|
+
converted: false,
|
|
349
|
+
managed: {},
|
|
350
|
+
restored: [],
|
|
351
|
+
conflicts: [],
|
|
352
|
+
keptAt: null,
|
|
353
|
+
};
|
|
354
|
+
const root = skillsRoot(projectPath);
|
|
355
|
+
const legacyRoot = legacyProfilesRoot(projectPath);
|
|
356
|
+
const oldDirs = await legacyProfileDirs(projectPath);
|
|
357
|
+
const linkedProfile = await profileLinkTarget(root, projectPath);
|
|
358
|
+
const hasLegacyRoot = await exists(legacyRoot);
|
|
359
|
+
if (linkedProfile === null && oldDirs.length === 0 && !hasLegacyRoot) {
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
result.converted = true;
|
|
363
|
+
if (hasLegacyRoot) {
|
|
364
|
+
result.keptAt = path.join(
|
|
365
|
+
backupRoot(projectPath),
|
|
366
|
+
`${backupStamp()}-layout`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Where the link pointed: the skills the project was actually running.
|
|
371
|
+
let linkedDir: string | null = null;
|
|
372
|
+
if (linkedProfile !== null) {
|
|
373
|
+
linkedDir = path.resolve(path.dirname(root), await fs.readlink(root));
|
|
374
|
+
await fs.remove(root);
|
|
375
|
+
}
|
|
376
|
+
const preexisting = new Set(await subfolders(root));
|
|
377
|
+
await fs.ensureDir(root);
|
|
378
|
+
const placed = new Map<string, string>();
|
|
379
|
+
/** Where `src`, inside `_profiles/`, will be once the layout is kept. */
|
|
380
|
+
const inKeptLayout = (src: string): string =>
|
|
381
|
+
result.keptAt && src.startsWith(legacyRoot + path.sep)
|
|
382
|
+
? path.join(result.keptAt, path.relative(legacyRoot, src))
|
|
383
|
+
: src;
|
|
384
|
+
|
|
385
|
+
const place = async (
|
|
386
|
+
from: string,
|
|
387
|
+
folder: string,
|
|
388
|
+
mode: "move" | "copy",
|
|
389
|
+
asManaged: boolean,
|
|
390
|
+
): Promise<void> => {
|
|
391
|
+
const src = path.join(from, folder);
|
|
392
|
+
const dest = path.join(root, folder);
|
|
393
|
+
if (!(await exists(dest))) {
|
|
394
|
+
if (mode === "move") await fs.move(src, dest);
|
|
395
|
+
else await fs.copy(src, dest, { preserveTimestamps: true });
|
|
396
|
+
placed.set(folder, src);
|
|
397
|
+
if (asManaged) {
|
|
398
|
+
const hash = await folderHash(dest);
|
|
399
|
+
if (hash) result.managed[folder] = hash;
|
|
400
|
+
} else {
|
|
401
|
+
result.restored.push(folder);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Already there, and identical: nothing to keep.
|
|
406
|
+
if ((await folderHash(dest)) === (await folderHash(src))) return;
|
|
407
|
+
// A backup is never modified, so an older backup copy losing to a newer
|
|
408
|
+
// one placed by this conversion loses nothing: newest wins, silently.
|
|
409
|
+
if (mode === "copy") {
|
|
410
|
+
if (placed.has(folder) && !preexisting.has(folder)) return;
|
|
411
|
+
result.conflicts.push({ folder, kept: dest, other: src });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// A differing copy inside an old profile directory stays where it is,
|
|
415
|
+
// and goes into the kept layout with the rest of `_profiles/`.
|
|
416
|
+
result.conflicts.push({ folder, kept: dest, other: inKeptLayout(src) });
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const activeFolders =
|
|
420
|
+
linkedProfile !== null
|
|
421
|
+
? manifestSkillFolders(manifest, linkedProfile)
|
|
422
|
+
: new Set<string>();
|
|
423
|
+
|
|
424
|
+
if (linkedDir !== null) {
|
|
425
|
+
for (const folder of await subfolders(linkedDir)) {
|
|
426
|
+
await place(linkedDir, folder, "move", activeFolders.has(folder));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const dir of await backupSkillDirs(projectPath)) {
|
|
430
|
+
for (const folder of await subfolders(dir)) {
|
|
431
|
+
if (activeFolders.has(folder)) continue;
|
|
432
|
+
await place(dir, folder, "copy", false);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const { name, dir } of oldDirs) {
|
|
436
|
+
const skills = path.join(dir, "skills");
|
|
437
|
+
if (linkedDir !== null && path.resolve(skills) === linkedDir) continue;
|
|
438
|
+
const own = manifestSkillFolders(manifest, name);
|
|
439
|
+
for (const folder of await subfolders(skills)) {
|
|
440
|
+
if (own.has(folder)) continue;
|
|
441
|
+
await place(skills, folder, "move", false);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Move what remains of `.claude/_profiles/` to `keptAt`, whole. Never a
|
|
450
|
+
* delete: it may hold the only copy of an edit Claude Code wrote through an
|
|
451
|
+
* old link. Called by activation after the new live files are written, so a
|
|
452
|
+
* failure part-way leaves the old layout readable.
|
|
453
|
+
*/
|
|
454
|
+
export async function archiveOlderLayout(
|
|
455
|
+
projectPath: string,
|
|
456
|
+
keptAt: string | null,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
const legacyRoot = legacyProfilesRoot(projectPath);
|
|
459
|
+
if (!keptAt || !(await exists(legacyRoot))) return;
|
|
460
|
+
await fs.ensureDir(path.dirname(keptAt));
|
|
461
|
+
await fs.move(legacyRoot, keptAt, { overwrite: false });
|
|
462
|
+
}
|