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,825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The active profile's live config: where it is, what magus last wrote there,
|
|
3
|
+
* and the marker that records it.
|
|
4
|
+
*
|
|
5
|
+
* `.claude/profiles.json` is the ONE source of truth. Everything Claude Code
|
|
6
|
+
* reads is generated from the active profile's entry and written as real files
|
|
7
|
+
* — there are no symlinks:
|
|
8
|
+
*
|
|
9
|
+
* .claude/settings.json rendered from the entry (plugins + settings)
|
|
10
|
+
* .mcp.json rendered from the entry (mcpServers)
|
|
11
|
+
* .claude/models.json rendered from the entry (models); absent when
|
|
12
|
+
* the profile routes no models
|
|
13
|
+
* .claude/skills/<name>/ one folder per skill the entry lists, installed
|
|
14
|
+
* by magus. Any OTHER folder in .claude/skills is a
|
|
15
|
+
* custom skill, owned by git and the team; magus
|
|
16
|
+
* never moves, copies, backs up or deletes one.
|
|
17
|
+
*
|
|
18
|
+
* `.claude/profiles/` holds only the marker, `.claude/profiles/active.json`:
|
|
19
|
+
*
|
|
20
|
+
* { "version": 1, "profile": "<name>",
|
|
21
|
+
* "definition": "<sha256 of everything the live files are rendered from>",
|
|
22
|
+
* "base": { ...that entry... },
|
|
23
|
+
* "allPluginIds": [ ...every profile's plugin ids, as rendered... ],
|
|
24
|
+
* "files": { "settings": "<sha256>", "mcp": "<sha256>", "models": "<sha256>" },
|
|
25
|
+
* "skills": { "<folder>": "<sha256 of its contents>" } }
|
|
26
|
+
*
|
|
27
|
+
* A live file whose hash differs was changed since magus wrote it — by Claude
|
|
28
|
+
* Code or a person — and in a magus project that IS a change to the active
|
|
29
|
+
* profile: `profile-absorb.ts` writes it into profiles.json. An entry whose
|
|
30
|
+
* hash differs was changed in profiles.json — usually a teammate's commit
|
|
31
|
+
* arriving by `git pull` — and the live files are regenerated. Edits are
|
|
32
|
+
* absorbed first and merged against `base`, so neither side is lost. Once a
|
|
33
|
+
* marker exists, nothing live is ever backed up.
|
|
34
|
+
*
|
|
35
|
+
* The one thing that IS backed up is a genuine pre-magus, hand-written
|
|
36
|
+
* settings.json, .mcp.json or models.json met at first adoption (no marker, no
|
|
37
|
+
* older layout). It is not magus's, so it goes to `.claude/.magus-backups/`.
|
|
38
|
+
*
|
|
39
|
+
* Why real files: Claude Code writes settings.json and .mcp.json itself, and
|
|
40
|
+
* its atomic writer refuses to write through a symlink
|
|
41
|
+
* (`SymlinkWriteRefusedError`). Every write here goes through a temp file and a
|
|
42
|
+
* rename, so a symlink left by an older layout is REPLACED, never written
|
|
43
|
+
* through.
|
|
44
|
+
*
|
|
45
|
+
* settings.local.json is NEVER touched: it holds personal credentials.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
49
|
+
import type { Stats } from "node:fs";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import fs from "fs-extra";
|
|
52
|
+
import type { ProfileManifestEntry } from "../types/index.js";
|
|
53
|
+
import { readManifest } from "./manifest.js";
|
|
54
|
+
import { SettingsParseError } from "./settings-file.js";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Paths that must be gitignored once a project uses profiles: the marker
|
|
58
|
+
* directory, backups and the generated live files. Only .claude/profiles.json
|
|
59
|
+
* is committed. `.claude/skills` is deliberately NOT here — custom skills in it
|
|
60
|
+
* are committed; each magus-installed skill folder gets its own line instead
|
|
61
|
+
* (see `profile-skills.ts`).
|
|
62
|
+
*/
|
|
63
|
+
export const PROFILE_GITIGNORE_ENTRIES = [
|
|
64
|
+
".claude/profiles/",
|
|
65
|
+
".claude/.magus-backups/",
|
|
66
|
+
".claude/settings.json",
|
|
67
|
+
".claude/models.json",
|
|
68
|
+
".mcp.json",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** The comment heading magus's lines in a project's .gitignore. */
|
|
72
|
+
export const PROFILE_GITIGNORE_COMMENT =
|
|
73
|
+
"# magus: generated profile config (source of truth: .claude/profiles.json)";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lines older releases wrote into magus's managed `.gitignore` block that must
|
|
77
|
+
* go when an older layout is converted: the blanket skills ignore hid committed
|
|
78
|
+
* custom skills, and `_profiles/` no longer exists. Removed from magus's own
|
|
79
|
+
* block only, and only during that conversion — the same line written by a
|
|
80
|
+
* person elsewhere in the file is theirs.
|
|
81
|
+
*/
|
|
82
|
+
export const OBSOLETE_GITIGNORE_ENTRIES = [
|
|
83
|
+
".claude/skills",
|
|
84
|
+
".claude/skills/",
|
|
85
|
+
".claude/_profiles/",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Settings keys that describe THIS MACHINE rather than the profile.
|
|
90
|
+
*
|
|
91
|
+
* magus records `installedPluginVersions` in the project settings file. It is
|
|
92
|
+
* never absorbed into profiles.json (that would commit one developer's install
|
|
93
|
+
* state as the team's contract), and it is carried across every re-render so
|
|
94
|
+
* writing the live file does not erase it.
|
|
95
|
+
*/
|
|
96
|
+
export const MACHINE_LOCAL_SETTINGS_KEYS: readonly string[] = [
|
|
97
|
+
"installedPluginVersions",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** `.claude/profiles/` — holds only the marker. */
|
|
103
|
+
export function profilesRoot(projectPath?: string): string {
|
|
104
|
+
return path.join(projectPath ?? process.cwd(), ".claude", "profiles");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The directory an earlier release materialized profiles into. */
|
|
108
|
+
export function legacyProfilesRoot(projectPath?: string): string {
|
|
109
|
+
return path.join(projectPath ?? process.cwd(), ".claude", "_profiles");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Where the active-profile marker lives. */
|
|
113
|
+
export function activeMarkerPath(projectPath?: string): string {
|
|
114
|
+
return path.join(profilesRoot(projectPath), "active.json");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function legacyMarkerPath(projectPath?: string): string {
|
|
118
|
+
return path.join(legacyProfilesRoot(projectPath), ".active");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `.claude/skills/` — a real directory of skill folders. */
|
|
122
|
+
export function skillsRoot(projectPath?: string): string {
|
|
123
|
+
return path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The live files rendered from the manifest and tracked by hash. */
|
|
127
|
+
export type LiveFileKey = "settings" | "mcp" | "models";
|
|
128
|
+
|
|
129
|
+
export const LIVE_FILE_KEYS: readonly LiveFileKey[] = [
|
|
130
|
+
"settings",
|
|
131
|
+
"mcp",
|
|
132
|
+
"models",
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const LIVE_FILES: Record<LiveFileKey, string[]> = {
|
|
136
|
+
settings: [".claude", "settings.json"],
|
|
137
|
+
mcp: [".mcp.json"],
|
|
138
|
+
models: [".claude", "models.json"],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The live path of a rendered file. */
|
|
142
|
+
export function livePath(key: LiveFileKey, projectPath?: string): string {
|
|
143
|
+
return path.join(projectPath ?? process.cwd(), ...LIVE_FILES[key]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every generated path, for the git-tracked check and doctor. */
|
|
147
|
+
export function generatedPaths(projectPath?: string): string[] {
|
|
148
|
+
return [
|
|
149
|
+
livePath("settings", projectPath),
|
|
150
|
+
livePath("mcp", projectPath),
|
|
151
|
+
livePath("models", projectPath),
|
|
152
|
+
skillsRoot(projectPath),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Content helpers ─────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** The live content of a profile, rendered in memory from the manifest. */
|
|
159
|
+
export interface RenderedProfile {
|
|
160
|
+
settings: Record<string, unknown>;
|
|
161
|
+
mcp: Record<string, unknown>;
|
|
162
|
+
/** The routing file's content, or null when the profile routes nothing. */
|
|
163
|
+
models: Record<string, unknown> | null;
|
|
164
|
+
/** The manifest entry they were rendered from, as written in profiles.json. */
|
|
165
|
+
entry: ProfileManifestEntry;
|
|
166
|
+
/**
|
|
167
|
+
* sha256 of everything the rendering read — see `renderProfile`. What the
|
|
168
|
+
* marker's `definition` records, so a change anywhere in profiles.json that
|
|
169
|
+
* alters this profile's output is noticed, including in another profile.
|
|
170
|
+
*/
|
|
171
|
+
definition: string;
|
|
172
|
+
/**
|
|
173
|
+
* Every profile's plugin ids at render time, sorted. Any of them this
|
|
174
|
+
* profile does not enable was written as `false` by the render itself.
|
|
175
|
+
*/
|
|
176
|
+
allPluginIds: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The exact bytes magus writes for a rendered file. */
|
|
180
|
+
export function serializeLive(content: Record<string, unknown>): string {
|
|
181
|
+
return `${JSON.stringify(content, null, 2)}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function sha256(bytes: Buffer | string): string {
|
|
185
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sortKeys(value: unknown): unknown {
|
|
189
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
190
|
+
if (typeof value === "object" && value !== null) {
|
|
191
|
+
const out: Record<string, unknown> = {};
|
|
192
|
+
for (const key of Object.keys(value).sort()) {
|
|
193
|
+
out[key] = sortKeys((value as Record<string, unknown>)[key]);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** JSON with object keys sorted at every level — equal meaning, equal text. */
|
|
201
|
+
export function canonicalJson(value: unknown): string {
|
|
202
|
+
return JSON.stringify(sortKeys(value));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Semantic JSON equality: key order does not matter, everything else does. */
|
|
206
|
+
export function jsonEqual(a: unknown, b: unknown): boolean {
|
|
207
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
211
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── The marker ──────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** The contents of the active-profile marker. */
|
|
217
|
+
export interface ActiveMarker {
|
|
218
|
+
version: 1;
|
|
219
|
+
/** The active profile's id. */
|
|
220
|
+
profile: string;
|
|
221
|
+
/**
|
|
222
|
+
* sha256 of the whole render input of the live files (`renderProfile`):
|
|
223
|
+
* this profile's resolved closure and the plugin ids of every profile.
|
|
224
|
+
*/
|
|
225
|
+
definition?: string;
|
|
226
|
+
/**
|
|
227
|
+
* That entry itself: the common ancestor when Claude Code edited the live
|
|
228
|
+
* files AND profiles.json changed underneath them, so both can be merged.
|
|
229
|
+
*/
|
|
230
|
+
base?: ProfileManifestEntry;
|
|
231
|
+
/**
|
|
232
|
+
* Every profile's plugin ids when the live files were rendered. A `false`
|
|
233
|
+
* in the live settings for one of these that `base` does not enable is the
|
|
234
|
+
* render's own output, not an edit — even after a teammate removes the
|
|
235
|
+
* plugin from every profile and the current manifest no longer names it.
|
|
236
|
+
*/
|
|
237
|
+
allPluginIds?: string[];
|
|
238
|
+
/**
|
|
239
|
+
* Set while magus runs the Claude CLI's plugin installs (`magus install`,
|
|
240
|
+
* `magus update`). Each `claude plugin install --scope project` enables its
|
|
241
|
+
* plugin in the live settings.json — for every profile's plugins, not only
|
|
242
|
+
* the active one's. That is the CLI's bookkeeping, and it is rendered over
|
|
243
|
+
* when the installs end. If the run dies first (Ctrl-C, a crash), the flag
|
|
244
|
+
* is still here, and the next reconcile treats settings.json's
|
|
245
|
+
* enabledPlugins as that bookkeeping rather than as the user's edits.
|
|
246
|
+
*/
|
|
247
|
+
cliBookkeeping?: true;
|
|
248
|
+
/** sha256 of each live file exactly as magus last wrote or absorbed it. */
|
|
249
|
+
files: Partial<Record<LiveFileKey, string>>;
|
|
250
|
+
/** Magus-installed skill folders under `.claude/skills/`, with content hash. */
|
|
251
|
+
skills: Record<string, string>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A profile id or skill folder is one path segment. Anything else in the
|
|
256
|
+
* marker is not a name magus wrote, and following it could escape the tree.
|
|
257
|
+
*/
|
|
258
|
+
export function isSegment(name: unknown): name is string {
|
|
259
|
+
return (
|
|
260
|
+
typeof name === "string" &&
|
|
261
|
+
name.length > 0 &&
|
|
262
|
+
name !== "." &&
|
|
263
|
+
name !== ".." &&
|
|
264
|
+
!name.includes("/") &&
|
|
265
|
+
!name.includes("\\")
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseMarker(raw: unknown): ActiveMarker | null {
|
|
270
|
+
if (!isJsonRecord(raw)) return null;
|
|
271
|
+
const {
|
|
272
|
+
profile,
|
|
273
|
+
files,
|
|
274
|
+
definition,
|
|
275
|
+
base,
|
|
276
|
+
skills,
|
|
277
|
+
allPluginIds,
|
|
278
|
+
cliBookkeeping,
|
|
279
|
+
} = raw;
|
|
280
|
+
if (!isSegment(profile)) return null;
|
|
281
|
+
const hashes: ActiveMarker["files"] = {};
|
|
282
|
+
if (isJsonRecord(files)) {
|
|
283
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
284
|
+
const value = files[key];
|
|
285
|
+
if (typeof value === "string") hashes[key] = value;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const skillHashes: Record<string, string> = {};
|
|
289
|
+
if (isJsonRecord(skills)) {
|
|
290
|
+
for (const [folder, hash] of Object.entries(skills)) {
|
|
291
|
+
if (isSegment(folder) && typeof hash === "string") {
|
|
292
|
+
skillHashes[folder] = hash;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const marker: ActiveMarker = {
|
|
297
|
+
version: 1,
|
|
298
|
+
profile,
|
|
299
|
+
files: hashes,
|
|
300
|
+
skills: skillHashes,
|
|
301
|
+
};
|
|
302
|
+
if (typeof definition === "string") marker.definition = definition;
|
|
303
|
+
if (isJsonRecord(base)) marker.base = base as unknown as ProfileManifestEntry;
|
|
304
|
+
if (
|
|
305
|
+
Array.isArray(allPluginIds) &&
|
|
306
|
+
allPluginIds.every((id) => typeof id === "string")
|
|
307
|
+
) {
|
|
308
|
+
marker.allPluginIds = allPluginIds as string[];
|
|
309
|
+
}
|
|
310
|
+
if (cliBookkeeping === true) marker.cliBookkeeping = true;
|
|
311
|
+
return marker;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read the marker. Absent, unparseable or wrongly shaped all answer null: the
|
|
316
|
+
* marker is magus's own bookkeeping, and a damaged one degrades to "no marker"
|
|
317
|
+
* — which the active-profile check then resolves — never to a throw.
|
|
318
|
+
*
|
|
319
|
+
* Reading and writing the marker go through this pair and nothing else, so
|
|
320
|
+
* where it lives is decided in one place. A marker an earlier release left at
|
|
321
|
+
* `.claude/_profiles/.active` is read too, while no current one exists.
|
|
322
|
+
*/
|
|
323
|
+
export async function readActiveMarker(
|
|
324
|
+
projectPath?: string,
|
|
325
|
+
): Promise<ActiveMarker | null> {
|
|
326
|
+
for (const file of [
|
|
327
|
+
activeMarkerPath(projectPath),
|
|
328
|
+
legacyMarkerPath(projectPath),
|
|
329
|
+
]) {
|
|
330
|
+
try {
|
|
331
|
+
return parseMarker(await fs.readJson(file));
|
|
332
|
+
} catch {
|
|
333
|
+
// Try the next location.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Is there a marker in the current format at `.claude/profiles/active.json`?
|
|
341
|
+
* False for none, and for one an earlier release left at `_profiles/.active`,
|
|
342
|
+
* which belongs to an older layout and is converted with it.
|
|
343
|
+
*/
|
|
344
|
+
export async function hasCurrentMarker(projectPath?: string): Promise<boolean> {
|
|
345
|
+
return fs.pathExists(activeMarkerPath(projectPath));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The hashes an earlier release recorded at `_profiles/.active`, or null. Used
|
|
350
|
+
* only to tell an unedited live file of that layout from an edited one.
|
|
351
|
+
*/
|
|
352
|
+
export async function readLegacyMarkerHashes(
|
|
353
|
+
projectPath?: string,
|
|
354
|
+
): Promise<ActiveMarker["files"] | null> {
|
|
355
|
+
try {
|
|
356
|
+
return (
|
|
357
|
+
parseMarker(await fs.readJson(legacyMarkerPath(projectPath)))?.files ??
|
|
358
|
+
null
|
|
359
|
+
);
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Write the marker. The other half of {@link readActiveMarker}. */
|
|
366
|
+
export async function writeActiveMarker(
|
|
367
|
+
marker: ActiveMarker,
|
|
368
|
+
projectPath?: string,
|
|
369
|
+
): Promise<void> {
|
|
370
|
+
// The older marker at `_profiles/.active` is left where it is: the current
|
|
371
|
+
// one takes precedence, and conversion keeps `_profiles/` whole in a backup.
|
|
372
|
+
await writeRealFile(
|
|
373
|
+
activeMarkerPath(projectPath),
|
|
374
|
+
`${JSON.stringify(marker, null, 2)}\n`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A marker for `profile`, recording what `rendered` was rendered from. Never
|
|
380
|
+
* carries `cliBookkeeping`: writing a fresh marker is what clears it.
|
|
381
|
+
*/
|
|
382
|
+
export function markerFor(
|
|
383
|
+
profile: string,
|
|
384
|
+
rendered: Pick<RenderedProfile, "entry" | "definition" | "allPluginIds">,
|
|
385
|
+
files: ActiveMarker["files"],
|
|
386
|
+
skills: Record<string, string>,
|
|
387
|
+
): ActiveMarker {
|
|
388
|
+
return {
|
|
389
|
+
version: 1,
|
|
390
|
+
profile,
|
|
391
|
+
definition: rendered.definition,
|
|
392
|
+
base: rendered.entry,
|
|
393
|
+
allPluginIds: rendered.allPluginIds,
|
|
394
|
+
files,
|
|
395
|
+
skills,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Live files ──────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/** What sits at a live path. */
|
|
402
|
+
export type LiveKind = "file" | "missing" | "symlink" | "other";
|
|
403
|
+
|
|
404
|
+
export async function liveKind(
|
|
405
|
+
key: LiveFileKey,
|
|
406
|
+
projectPath?: string,
|
|
407
|
+
): Promise<LiveKind> {
|
|
408
|
+
try {
|
|
409
|
+
const stat = await fs.lstat(livePath(key, projectPath));
|
|
410
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
411
|
+
return stat.isFile() ? "file" : "other";
|
|
412
|
+
} catch {
|
|
413
|
+
return "missing";
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The hash of a live file, or null when it is not a regular file. */
|
|
418
|
+
export async function liveHash(
|
|
419
|
+
key: LiveFileKey,
|
|
420
|
+
projectPath?: string,
|
|
421
|
+
): Promise<string | null> {
|
|
422
|
+
if ((await liveKind(key, projectPath)) !== "file") return null;
|
|
423
|
+
return sha256(await fs.readFile(livePath(key, projectPath)));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The live file parsed as a JSON object. `null` when absent or not a regular
|
|
428
|
+
* file; throws `SyntaxError` when it exists and is not a JSON object.
|
|
429
|
+
*/
|
|
430
|
+
export async function readLiveJson(
|
|
431
|
+
key: LiveFileKey,
|
|
432
|
+
projectPath?: string,
|
|
433
|
+
): Promise<Record<string, unknown> | null> {
|
|
434
|
+
if ((await liveKind(key, projectPath)) !== "file") return null;
|
|
435
|
+
const parsed: unknown = JSON.parse(
|
|
436
|
+
await fs.readFile(livePath(key, projectPath), "utf8"),
|
|
437
|
+
);
|
|
438
|
+
if (!isJsonRecord(parsed)) throw new SyntaxError("not a JSON object");
|
|
439
|
+
return parsed;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** {@link readLiveJson} that answers null instead of throwing. */
|
|
443
|
+
export async function readLiveJsonSafely(
|
|
444
|
+
key: LiveFileKey,
|
|
445
|
+
projectPath?: string,
|
|
446
|
+
): Promise<Record<string, unknown> | null> {
|
|
447
|
+
try {
|
|
448
|
+
return await readLiveJson(key, projectPath);
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** What a live path holds, read THROUGH a symlink if it is one. */
|
|
455
|
+
export interface LiveRead {
|
|
456
|
+
/** Nothing there, or a link whose target is gone. */
|
|
457
|
+
missing: boolean;
|
|
458
|
+
/** The path is a symlink (an older layout's). */
|
|
459
|
+
isLink: boolean;
|
|
460
|
+
/** The parsed object, when it parsed. */
|
|
461
|
+
value: Record<string, unknown> | null;
|
|
462
|
+
/** sha256 of the bytes, when there were any. */
|
|
463
|
+
hash: string | null;
|
|
464
|
+
/** Why it could not be used, when it could not. */
|
|
465
|
+
error: SettingsParseError | null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Read a live file following a symlink — an older layout linked settings.json,
|
|
470
|
+
* .mcp.json and models.json into `.claude/_profiles/<name>/`, and Claude Code
|
|
471
|
+
* before 2.1.280 wrote its edits through those links. Never throws.
|
|
472
|
+
*/
|
|
473
|
+
export async function readLiveThrough(
|
|
474
|
+
key: LiveFileKey,
|
|
475
|
+
projectPath?: string,
|
|
476
|
+
): Promise<LiveRead> {
|
|
477
|
+
const file = livePath(key, projectPath);
|
|
478
|
+
const out: LiveRead = {
|
|
479
|
+
missing: true,
|
|
480
|
+
isLink: false,
|
|
481
|
+
value: null,
|
|
482
|
+
hash: null,
|
|
483
|
+
error: null,
|
|
484
|
+
};
|
|
485
|
+
try {
|
|
486
|
+
out.isLink = (await fs.lstat(file)).isSymbolicLink();
|
|
487
|
+
} catch {
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
let bytes: Buffer;
|
|
491
|
+
try {
|
|
492
|
+
bytes = await fs.readFile(file);
|
|
493
|
+
} catch {
|
|
494
|
+
return out; // A dangling link: nothing to read.
|
|
495
|
+
}
|
|
496
|
+
out.missing = false;
|
|
497
|
+
out.hash = sha256(bytes);
|
|
498
|
+
try {
|
|
499
|
+
const parsed: unknown = JSON.parse(bytes.toString("utf8"));
|
|
500
|
+
if (!isJsonRecord(parsed)) throw new SyntaxError("not a JSON object");
|
|
501
|
+
out.value = parsed;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
out.error = new SettingsParseError(file, error);
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Live files that exist as regular files and do not parse as a JSON object.
|
|
510
|
+
*
|
|
511
|
+
* magus never writes over a file it cannot parse — the same rule every
|
|
512
|
+
* settings writer follows — so every path that writes the live files checks
|
|
513
|
+
* this first and refuses, leaving the file byte for byte as it is.
|
|
514
|
+
*/
|
|
515
|
+
export async function unreadableLiveFiles(
|
|
516
|
+
projectPath?: string,
|
|
517
|
+
): Promise<SettingsParseError[]> {
|
|
518
|
+
const out: SettingsParseError[] = [];
|
|
519
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
520
|
+
if ((await liveKind(key, projectPath)) !== "file") continue;
|
|
521
|
+
const read = await readLiveThrough(key, projectPath);
|
|
522
|
+
if (read.error) out.push(read.error);
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Throw the first of {@link unreadableLiveFiles}, if there is one. */
|
|
528
|
+
export async function assertLiveFilesParse(
|
|
529
|
+
projectPath?: string,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
const [first] = await unreadableLiveFiles(projectPath);
|
|
532
|
+
if (first) throw first;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Write `bytes` at `file` as a real file, replacing whatever entry is there.
|
|
537
|
+
*
|
|
538
|
+
* Via a temp file and a rename, so Claude Code never reads half a file, and so
|
|
539
|
+
* a symlink at `file` is REPLACED rather than written through.
|
|
540
|
+
*/
|
|
541
|
+
async function writeRealFile(file: string, bytes: string): Promise<void> {
|
|
542
|
+
await fs.ensureDir(path.dirname(file));
|
|
543
|
+
const tmp = `${file}.magus-tmp-${process.pid}`;
|
|
544
|
+
await fs.writeFile(tmp, bytes);
|
|
545
|
+
await fs.rename(tmp, file);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Write one rendered live file (or remove it, for `null` content) and return
|
|
550
|
+
* the hash of what was written, or null when nothing is there now. Does NOT
|
|
551
|
+
* touch the marker — callers record the hash with the rest of their state.
|
|
552
|
+
*
|
|
553
|
+
* Machine-local settings keys in the current live file are carried into the
|
|
554
|
+
* new one (see {@link MACHINE_LOCAL_SETTINGS_KEYS}).
|
|
555
|
+
*/
|
|
556
|
+
export async function writeLiveFile(
|
|
557
|
+
key: LiveFileKey,
|
|
558
|
+
content: Record<string, unknown> | null,
|
|
559
|
+
projectPath?: string,
|
|
560
|
+
): Promise<string | null> {
|
|
561
|
+
const file = livePath(key, projectPath);
|
|
562
|
+
// Backstop for the callers' own checks: never replace a regular file that
|
|
563
|
+
// does not parse. A symlink (an older layout) is replaced by the rename.
|
|
564
|
+
if ((await liveKind(key, projectPath)) === "file") {
|
|
565
|
+
const current = await readLiveThrough(key, projectPath);
|
|
566
|
+
if (current.error) throw current.error;
|
|
567
|
+
}
|
|
568
|
+
if (content === null) {
|
|
569
|
+
await fs.remove(file);
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
let body = content;
|
|
573
|
+
if (key === "settings") {
|
|
574
|
+
// Through a link too: an older layout's settings.json carries them.
|
|
575
|
+
const live = (await readLiveThrough("settings", projectPath)).value;
|
|
576
|
+
if (live) {
|
|
577
|
+
body = { ...content };
|
|
578
|
+
for (const k of MACHINE_LOCAL_SETTINGS_KEYS) {
|
|
579
|
+
if (k in live && !(k in body)) body[k] = live[k];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const bytes = serializeLive(body);
|
|
584
|
+
await writeRealFile(file, bytes);
|
|
585
|
+
return sha256(bytes);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Write all three live files of `rendered`; returns their hashes. */
|
|
589
|
+
export async function writeRenderedFiles(
|
|
590
|
+
rendered: RenderedProfile,
|
|
591
|
+
projectPath?: string,
|
|
592
|
+
): Promise<ActiveMarker["files"]> {
|
|
593
|
+
const files: ActiveMarker["files"] = {};
|
|
594
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
595
|
+
const hash = await writeLiveFile(key, rendered[key], projectPath);
|
|
596
|
+
if (hash) files[key] = hash;
|
|
597
|
+
}
|
|
598
|
+
return files;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ─── Skill folders ───────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* A content hash of a skill folder: every file's path relative to the folder
|
|
605
|
+
* and its bytes, in sorted order. A symlink inside contributes its target text.
|
|
606
|
+
* Null when the folder does not exist.
|
|
607
|
+
*/
|
|
608
|
+
export async function folderHash(dir: string): Promise<string | null> {
|
|
609
|
+
try {
|
|
610
|
+
if (!(await fs.lstat(dir)).isDirectory()) return null;
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
const hash = createHash("sha256");
|
|
615
|
+
const walk = async (rel: string): Promise<void> => {
|
|
616
|
+
const entries = (await fs.readdir(path.join(dir, rel))).sort();
|
|
617
|
+
for (const name of entries) {
|
|
618
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
619
|
+
const abs = path.join(dir, childRel);
|
|
620
|
+
const stat = await fs.lstat(abs);
|
|
621
|
+
if (stat.isDirectory()) {
|
|
622
|
+
await walk(childRel);
|
|
623
|
+
} else if (stat.isSymbolicLink()) {
|
|
624
|
+
hash.update(`L\0${childRel}\0${await fs.readlink(abs)}\0`);
|
|
625
|
+
} else if (stat.isFile()) {
|
|
626
|
+
hash.update(`F\0${childRel}\0`);
|
|
627
|
+
hash.update(await fs.readFile(abs));
|
|
628
|
+
hash.update("\0");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
await walk("");
|
|
633
|
+
return hash.digest("hex");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ─── Older layouts ───────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* If `link` is a symlink into `.claude/_profiles/<n>/…` — the layout magus
|
|
640
|
+
* 7.4.1 and earlier wrote — the profile it points into. No release linked into
|
|
641
|
+
* `.claude/profiles/`, so a link there is somebody's own and is left alone.
|
|
642
|
+
*/
|
|
643
|
+
export async function profileLinkTarget(
|
|
644
|
+
link: string,
|
|
645
|
+
projectPath?: string,
|
|
646
|
+
): Promise<string | null> {
|
|
647
|
+
try {
|
|
648
|
+
if (!(await fs.lstat(link)).isSymbolicLink()) return null;
|
|
649
|
+
const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
|
|
650
|
+
const prefix = legacyProfilesRoot(projectPath) + path.sep;
|
|
651
|
+
if (!resolved.startsWith(prefix)) return null;
|
|
652
|
+
const name = resolved.slice(prefix.length).split(path.sep)[0];
|
|
653
|
+
return isSegment(name) ? name : null;
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The profile an older symlink layout names, or null. Any of the generated
|
|
661
|
+
* paths being a symlink into a profiles directory counts — the skills link
|
|
662
|
+
* outlived the settings link by one release.
|
|
663
|
+
*/
|
|
664
|
+
export async function oldLayoutProfile(
|
|
665
|
+
projectPath?: string,
|
|
666
|
+
): Promise<string | null> {
|
|
667
|
+
for (const p of generatedPaths(projectPath)) {
|
|
668
|
+
const name = await profileLinkTarget(p, projectPath);
|
|
669
|
+
if (name) return name;
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The per-profile directories older releases left: `.claude/_profiles/<n>/`,
|
|
676
|
+
* written by magus 7.4.1 and earlier and by the release after it. Conversion
|
|
677
|
+
* takes the skills out and keeps the rest in `.claude/.magus-backups/`.
|
|
678
|
+
*
|
|
679
|
+
* `.claude/profiles/` is never scanned: no release wrote per-profile
|
|
680
|
+
* directories there, so anything in it besides `active.json` is somebody's.
|
|
681
|
+
*/
|
|
682
|
+
export async function legacyProfileDirs(
|
|
683
|
+
projectPath?: string,
|
|
684
|
+
): Promise<Array<{ name: string; dir: string }>> {
|
|
685
|
+
const out: Array<{ name: string; dir: string }> = [];
|
|
686
|
+
const root = legacyProfilesRoot(projectPath);
|
|
687
|
+
let names: string[];
|
|
688
|
+
try {
|
|
689
|
+
names = await fs.readdir(root);
|
|
690
|
+
} catch {
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
for (const name of names) {
|
|
694
|
+
if (!isSegment(name) || name.startsWith(".")) continue;
|
|
695
|
+
const dir = path.join(root, name);
|
|
696
|
+
try {
|
|
697
|
+
if ((await fs.lstat(dir)).isDirectory()) out.push({ name, dir });
|
|
698
|
+
} catch {
|
|
699
|
+
// Vanished between listing and stat.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Does anything of an older layout remain — a generated path that is a
|
|
707
|
+
* symlink into `.claude/_profiles/`, the `_profiles/` directory itself, or the
|
|
708
|
+
* older marker? Activation converts all of it.
|
|
709
|
+
*/
|
|
710
|
+
export async function hasOlderLayout(projectPath?: string): Promise<boolean> {
|
|
711
|
+
if ((await oldLayoutProfile(projectPath)) !== null) return true;
|
|
712
|
+
return fs.pathExists(legacyProfilesRoot(projectPath));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ─── Backups ─────────────────────────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Where first adoption moves a hand-written file it had to displace. Never
|
|
719
|
+
* inside `.claude/profiles/`, which is documented as regenerable.
|
|
720
|
+
*/
|
|
721
|
+
export function backupRoot(projectPath?: string): string {
|
|
722
|
+
return path.join(projectPath ?? process.cwd(), ".claude", ".magus-backups");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** One artifact first adoption moved aside to make room for the profile's. */
|
|
726
|
+
export interface DisplacedArtifact {
|
|
727
|
+
/** The live path the profile's artifact now occupies. */
|
|
728
|
+
link: string;
|
|
729
|
+
/** Where its previous contents were moved. */
|
|
730
|
+
savedTo: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** A directory name that sorts chronologically and is legal on every platform. */
|
|
734
|
+
export function backupStamp(): string {
|
|
735
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Move whatever sits at `file` into `backupDir`. A symlink is moved as a link,
|
|
740
|
+
* rewritten as absolute so its meaning survives the move.
|
|
741
|
+
*/
|
|
742
|
+
export async function displace(
|
|
743
|
+
file: string,
|
|
744
|
+
backupDir: string,
|
|
745
|
+
): Promise<DisplacedArtifact | null> {
|
|
746
|
+
let stat: Stats;
|
|
747
|
+
try {
|
|
748
|
+
stat = await fs.lstat(file);
|
|
749
|
+
} catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
const savedTo = path.join(backupDir, path.basename(file));
|
|
753
|
+
await fs.ensureDir(backupDir);
|
|
754
|
+
if (stat.isSymbolicLink()) {
|
|
755
|
+
const absolute = path.resolve(path.dirname(file), await fs.readlink(file));
|
|
756
|
+
await fs.remove(savedTo);
|
|
757
|
+
await fs.symlink(absolute, savedTo);
|
|
758
|
+
await fs.remove(file);
|
|
759
|
+
return { link: file, savedTo };
|
|
760
|
+
}
|
|
761
|
+
await fs.move(file, savedTo, { overwrite: true });
|
|
762
|
+
return { link: file, savedTo };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Hand-written files that activating ANY profile would have to move aside.
|
|
767
|
+
*
|
|
768
|
+
* Empty means activating is lossless — a fresh clone, a project magus already
|
|
769
|
+
* manages (a marker exists: its live files are absorbed, never displaced), or
|
|
770
|
+
* an older layout magus converts. That is what makes it safe to honour the
|
|
771
|
+
* manifest's declared default without asking.
|
|
772
|
+
*/
|
|
773
|
+
export async function displaceableArtifacts(
|
|
774
|
+
projectPath?: string,
|
|
775
|
+
): Promise<string[]> {
|
|
776
|
+
if ((await readActiveMarker(projectPath)) !== null) return [];
|
|
777
|
+
if ((await oldLayoutProfile(projectPath)) !== null) return [];
|
|
778
|
+
const found: string[] = [];
|
|
779
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
780
|
+
if ((await liveKind(key, projectPath)) !== "missing") {
|
|
781
|
+
found.push(livePath(key, projectPath));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return found;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ─── Which profile is active ─────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The profile the marker (or an older symlink layout) NAMES, whether or not
|
|
791
|
+
* the manifest still defines it. For the strict check in `profile-state.ts`;
|
|
792
|
+
* everyone else wants {@link activeProfile}.
|
|
793
|
+
*/
|
|
794
|
+
export async function namedActiveProfile(
|
|
795
|
+
projectPath?: string,
|
|
796
|
+
): Promise<{ profile: string; source: "marker" | "old-layout" } | null> {
|
|
797
|
+
const marker = await readActiveMarker(projectPath);
|
|
798
|
+
if (marker) return { profile: marker.profile, source: "marker" };
|
|
799
|
+
const old = await oldLayoutProfile(projectPath);
|
|
800
|
+
return old ? { profile: old, source: "old-layout" } : null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The name of the currently active profile, or null when none is.
|
|
805
|
+
*
|
|
806
|
+
* Strict: the marker (or an older layout) must name a profile that
|
|
807
|
+
* `.claude/profiles.json` defines. A marker naming a profile the manifest no
|
|
808
|
+
* longer has describes nothing magus can act on, so it answers null here and
|
|
809
|
+
* the entry-point check (`checkActiveProfile`) reports it with the fix.
|
|
810
|
+
*/
|
|
811
|
+
export async function activeProfile(
|
|
812
|
+
projectPath?: string,
|
|
813
|
+
): Promise<string | null> {
|
|
814
|
+
const named = await namedActiveProfile(projectPath);
|
|
815
|
+
if (!named) return null;
|
|
816
|
+
try {
|
|
817
|
+
const manifest = await readManifest(projectPath);
|
|
818
|
+
return Object.hasOwn(manifest.profiles, named.profile)
|
|
819
|
+
? named.profile
|
|
820
|
+
: null;
|
|
821
|
+
} catch {
|
|
822
|
+
// An unreadable manifest: the commands that read it properly report why.
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
}
|