magus-cli 7.10.4 → 7.10.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.10.
|
|
3
|
+
"version": "7.10.5",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.10.
|
|
69
|
-
"magus-cli-darwin-x64": "7.10.
|
|
70
|
-
"magus-cli-linux-x64": "7.10.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.10.5",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.5",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.5"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code copies git-ignored files into a NEW worktree (`claude -w`,
|
|
3
|
+
* EnterWorktree, agent isolation) only when the launch root's
|
|
4
|
+
* `.worktreeinclude` lists them. magus git-ignores the set-up state it
|
|
5
|
+
* generates, so it must also list that state there — or every new worktree of
|
|
6
|
+
* a profiled repo starts without the project's plugins.
|
|
7
|
+
*
|
|
8
|
+
* Exercised against a real git repository, with HOME and git config pointed at
|
|
9
|
+
* a scratch folder, because "listed AND git-ignored" is a statement about git.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
13
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import fs from "fs-extra";
|
|
17
|
+
import { writeManifest } from "../services/manifest.js";
|
|
18
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
19
|
+
import { updateSkillIgnoreLines } from "../services/profile-skills.js";
|
|
20
|
+
import {
|
|
21
|
+
inspectProfileState,
|
|
22
|
+
prepareProfileState,
|
|
23
|
+
} from "../services/profile-state.js";
|
|
24
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
25
|
+
|
|
26
|
+
// A profile with no plugins: activation renders the project's files with no
|
|
27
|
+
// install, CLI call or network.
|
|
28
|
+
const MANIFEST: ProfileManifest = {
|
|
29
|
+
version: 2,
|
|
30
|
+
profiles: { team: { name: "Team", plugins: {} } },
|
|
31
|
+
default: "team",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The set-up state a new worktree needs to start with the project's plugins. */
|
|
35
|
+
const REQUIRED = [
|
|
36
|
+
".claude/settings.json",
|
|
37
|
+
".mcp.json",
|
|
38
|
+
".claude/models.json",
|
|
39
|
+
".claude/profiles/active.json",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const GIT_CFG = [
|
|
43
|
+
"-c",
|
|
44
|
+
"user.name=test",
|
|
45
|
+
"-c",
|
|
46
|
+
"user.email=test@example.com",
|
|
47
|
+
"-c",
|
|
48
|
+
"commit.gpgsign=false",
|
|
49
|
+
"-c",
|
|
50
|
+
"init.defaultBranch=main",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const git = (cwd: string, ...args: string[]): string =>
|
|
54
|
+
execFileSync("git", [...GIT_CFG, ...args], {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
}).trim();
|
|
59
|
+
|
|
60
|
+
/** Whether git treats `rel` as ignored in `repo` (exit 0 = ignored). */
|
|
61
|
+
const ignoredByGit = (repo: string, rel: string): boolean =>
|
|
62
|
+
spawnSync("git", [...GIT_CFG, "check-ignore", "-q", "--", rel], {
|
|
63
|
+
cwd: repo,
|
|
64
|
+
}).status === 0;
|
|
65
|
+
|
|
66
|
+
const ENV_KEYS = [
|
|
67
|
+
"HOME",
|
|
68
|
+
"CLAUDE_CONFIG_DIR",
|
|
69
|
+
"XDG_CONFIG_HOME",
|
|
70
|
+
"XDG_CACHE_HOME",
|
|
71
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
72
|
+
"GIT_CEILING_DIRECTORIES",
|
|
73
|
+
"GIT_DIR",
|
|
74
|
+
"GIT_WORK_TREE",
|
|
75
|
+
"GIT_INDEX_FILE",
|
|
76
|
+
"GIT_COMMON_DIR",
|
|
77
|
+
"GIT_OBJECT_DIRECTORY",
|
|
78
|
+
"GIT_PREFIX",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
let savedEnv: Record<string, string | undefined> = {};
|
|
82
|
+
let base: string;
|
|
83
|
+
let project: string;
|
|
84
|
+
let log: ReturnType<typeof spyOn>;
|
|
85
|
+
let err: ReturnType<typeof spyOn>;
|
|
86
|
+
|
|
87
|
+
const includePath = () => path.join(project, ".worktreeinclude");
|
|
88
|
+
|
|
89
|
+
/** The non-blank, non-comment lines of `.worktreeinclude`. */
|
|
90
|
+
async function includeLines(): Promise<string[]> {
|
|
91
|
+
const text = await fs.readFile(includePath(), "utf8");
|
|
92
|
+
return text
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map((l) => l.trim())
|
|
95
|
+
.filter((l) => l !== "" && !l.startsWith("#"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function activate(): Promise<void> {
|
|
99
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
savedEnv = {};
|
|
104
|
+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
|
|
105
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
106
|
+
|
|
107
|
+
base = await fs.realpath(
|
|
108
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "magus-wt-include-")),
|
|
109
|
+
);
|
|
110
|
+
const home = path.join(base, "home");
|
|
111
|
+
await fs.ensureDir(path.join(home, ".claude"));
|
|
112
|
+
process.env.HOME = home;
|
|
113
|
+
process.env.XDG_CONFIG_HOME = path.join(home, ".config");
|
|
114
|
+
process.env.XDG_CACHE_HOME = path.join(home, ".cache");
|
|
115
|
+
process.env.CLAUDE_CONFIG_DIR = path.join(home, ".claude");
|
|
116
|
+
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
|
117
|
+
process.env.GIT_CEILING_DIRECTORIES = base;
|
|
118
|
+
|
|
119
|
+
project = path.join(base, "repo");
|
|
120
|
+
await fs.ensureDir(project);
|
|
121
|
+
git(project, "init", "-q");
|
|
122
|
+
await writeManifest(MANIFEST, project);
|
|
123
|
+
git(project, "add", "-A");
|
|
124
|
+
git(project, "commit", "-qm", "manifest");
|
|
125
|
+
|
|
126
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
127
|
+
err = spyOn(console, "error").mockImplementation(() => {});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(async () => {
|
|
131
|
+
log.mockRestore();
|
|
132
|
+
err.mockRestore();
|
|
133
|
+
for (const key of ENV_KEYS) {
|
|
134
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
135
|
+
else process.env[key] = savedEnv[key];
|
|
136
|
+
}
|
|
137
|
+
await fs.remove(base);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("activation lists the set-up state in .worktreeinclude", () => {
|
|
141
|
+
test("lists every generated file and never the backups", async () => {
|
|
142
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
143
|
+
await activate();
|
|
144
|
+
|
|
145
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
146
|
+
const lines = await includeLines();
|
|
147
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
148
|
+
expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("every listed path is git-ignored, so Claude Code copies it", async () => {
|
|
152
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
153
|
+
await activate();
|
|
154
|
+
|
|
155
|
+
const lines = await includeLines();
|
|
156
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
157
|
+
const notIgnored = lines.filter((l) => !ignoredByGit(project, l));
|
|
158
|
+
expect(notIgnored).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a person's own .worktreeinclude lines survive activation untouched", async () => {
|
|
162
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
163
|
+
const own =
|
|
164
|
+
"# mine: local secrets for every worktree\n.env\nconfig/local.yaml\n";
|
|
165
|
+
await fs.writeFile(includePath(), own);
|
|
166
|
+
|
|
167
|
+
await activate();
|
|
168
|
+
|
|
169
|
+
const text = await fs.readFile(includePath(), "utf8");
|
|
170
|
+
expect(text.startsWith(own)).toBe(true);
|
|
171
|
+
const lines = await includeLines();
|
|
172
|
+
expect(lines).toContain(".env");
|
|
173
|
+
expect(lines).toContain("config/local.yaml");
|
|
174
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("an already set-up project gets the include on the next gate pass", () => {
|
|
179
|
+
test("inspectProfileState writes nothing; prepareProfileState adds the block", async () => {
|
|
180
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
181
|
+
await activate();
|
|
182
|
+
// A project set up by a release that never wrote the include.
|
|
183
|
+
await fs.remove(includePath());
|
|
184
|
+
expect(
|
|
185
|
+
await fs.pathExists(
|
|
186
|
+
path.join(project, ".claude", "profiles", "active.json"),
|
|
187
|
+
),
|
|
188
|
+
).toBe(true);
|
|
189
|
+
|
|
190
|
+
await inspectProfileState(project);
|
|
191
|
+
expect(await fs.pathExists(includePath())).toBe(false);
|
|
192
|
+
|
|
193
|
+
const prep = await prepareProfileState(project);
|
|
194
|
+
expect(prep.problems).toEqual([]);
|
|
195
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
196
|
+
const lines = await includeLines();
|
|
197
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
198
|
+
expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("magus-installed skill folders follow the gitignore into the include", () => {
|
|
203
|
+
test("a recorded skill folder is listed, and unlisted when removed", async () => {
|
|
204
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
205
|
+
await updateSkillIgnoreLines(project, [], ["team-skill"]);
|
|
206
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
207
|
+
expect(await includeLines()).toContain(".claude/skills/team-skill/");
|
|
208
|
+
|
|
209
|
+
await updateSkillIgnoreLines(project, ["team-skill"], []);
|
|
210
|
+
expect(await includeLines()).not.toContain(".claude/skills/team-skill/");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
OBSOLETE_GITIGNORE_ENTRIES,
|
|
43
43
|
PROFILE_GITIGNORE_COMMENT,
|
|
44
44
|
PROFILE_GITIGNORE_ENTRIES,
|
|
45
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
46
|
+
PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
45
47
|
assertLiveFilesParse,
|
|
46
48
|
backupRoot,
|
|
47
49
|
backupStamp,
|
|
@@ -324,6 +326,13 @@ export async function activateProfile(
|
|
|
324
326
|
remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
|
|
325
327
|
},
|
|
326
328
|
);
|
|
329
|
+
// Claude Code copies git-ignored files into a worktree it creates only when
|
|
330
|
+
// `.worktreeinclude` lists them — see PROFILE_WORKTREE_INCLUDE_ENTRIES.
|
|
331
|
+
await updateManagedGitignoreBlock(
|
|
332
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
333
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
334
|
+
{ add: PROFILE_WORKTREE_INCLUDE_ENTRIES },
|
|
335
|
+
);
|
|
327
336
|
|
|
328
337
|
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
329
338
|
const displaced: DisplacedArtifact[] = [];
|
|
@@ -72,6 +72,32 @@ export const PROFILE_GITIGNORE_ENTRIES = [
|
|
|
72
72
|
export const PROFILE_GITIGNORE_COMMENT =
|
|
73
73
|
"# magus: generated profile config (source of truth: .claude/profiles.json)";
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Paths magus lists in a project's `.worktreeinclude`. Claude Code copies a
|
|
77
|
+
* file into a worktree it creates (`claude -w`, EnterWorktree, agent
|
|
78
|
+
* isolation) only when that file is both git-ignored and listed there, at
|
|
79
|
+
* creation time. magus git-ignores the set-up state above, so without this
|
|
80
|
+
* list every new worktree gets `.claude/profiles.json` from the commit and none
|
|
81
|
+
* of the project's plugins.
|
|
82
|
+
*
|
|
83
|
+
* The marker FILE is listed, not `.claude/profiles/`: the directory would also
|
|
84
|
+
* copy a crash-orphaned temp file. It holds only hashes and ids, so a
|
|
85
|
+
* byte-for-byte copy reconciles as unchanged in the worktree. Backups are never
|
|
86
|
+
* listed — they are this checkout's hand-written files, possibly credentials,
|
|
87
|
+
* and nothing in a worktree uses them. Each magus-installed skill folder gets
|
|
88
|
+
* its own line (see `profile-skills.ts`).
|
|
89
|
+
*/
|
|
90
|
+
export const PROFILE_WORKTREE_INCLUDE_ENTRIES = [
|
|
91
|
+
".claude/settings.json",
|
|
92
|
+
".mcp.json",
|
|
93
|
+
".claude/models.json",
|
|
94
|
+
".claude/profiles/active.json",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** The comment heading magus's lines in a project's `.worktreeinclude`. */
|
|
98
|
+
export const PROFILE_WORKTREE_INCLUDE_COMMENT =
|
|
99
|
+
"# magus: copied into new worktrees so they start with this project's plugins (source of truth: .claude/profiles.json)";
|
|
100
|
+
|
|
75
101
|
/**
|
|
76
102
|
* Lines older releases wrote into magus's managed `.gitignore` block that must
|
|
77
103
|
* go when an older layout is converted: the blanket skills ignore hid committed
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
29
29
|
import {
|
|
30
30
|
PROFILE_GITIGNORE_COMMENT,
|
|
31
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
31
32
|
backupRoot,
|
|
32
33
|
backupStamp,
|
|
33
34
|
folderHash,
|
|
@@ -251,6 +252,9 @@ export async function inspectManagedSkills(
|
|
|
251
252
|
* `.gitignore` block: `before` are the folders the marker listed, `after` the
|
|
252
253
|
* ones it lists now. Lines are taken out of magus's block only — the same
|
|
253
254
|
* pattern a person wrote elsewhere in the file stays.
|
|
255
|
+
*
|
|
256
|
+
* The same lines go into magus's `.worktreeinclude` block, so a worktree
|
|
257
|
+
* Claude Code creates gets the folders the copied marker records.
|
|
254
258
|
*/
|
|
255
259
|
export async function updateSkillIgnoreLines(
|
|
256
260
|
projectPath: string,
|
|
@@ -258,13 +262,19 @@ export async function updateSkillIgnoreLines(
|
|
|
258
262
|
after: readonly string[],
|
|
259
263
|
): Promise<void> {
|
|
260
264
|
const keep = new Set(after);
|
|
265
|
+
const edits = {
|
|
266
|
+
add: [...keep].map(skillIgnoreLine),
|
|
267
|
+
remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
|
|
268
|
+
};
|
|
261
269
|
await updateManagedGitignoreBlock(
|
|
262
270
|
path.join(projectPath, ".gitignore"),
|
|
263
271
|
PROFILE_GITIGNORE_COMMENT,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
edits,
|
|
273
|
+
);
|
|
274
|
+
await updateManagedGitignoreBlock(
|
|
275
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
276
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
277
|
+
edits,
|
|
268
278
|
);
|
|
269
279
|
}
|
|
270
280
|
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
30
30
|
import { selectProfileId } from "./active-closure.js";
|
|
31
|
+
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
31
32
|
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
32
33
|
import {
|
|
33
34
|
type ReconcileResult,
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
type ActiveMarker,
|
|
49
50
|
LIVE_FILE_KEYS,
|
|
50
51
|
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
52
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
53
|
+
PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
51
54
|
activeProfile,
|
|
52
55
|
folderHash,
|
|
53
56
|
hasCurrentMarker,
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
SkillClashError,
|
|
70
73
|
inspectManagedSkills,
|
|
71
74
|
skillFolder,
|
|
75
|
+
skillIgnoreLine,
|
|
72
76
|
} from "./profile-skills.js";
|
|
73
77
|
import { resolveExtends } from "./resolver.js";
|
|
74
78
|
import type { SettingsParseError } from "./settings-file.js";
|
|
@@ -371,6 +375,20 @@ export async function prepareProfileState(
|
|
|
371
375
|
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
372
376
|
out.activations.push(result);
|
|
373
377
|
}
|
|
378
|
+
// A project set up by a release that never wrote `.worktreeinclude`
|
|
379
|
+
// re-activates rarely, so the gate lists the set-up state here. A no-op
|
|
380
|
+
// when every line is already there.
|
|
381
|
+
const marker = await readActiveMarker(projectPath);
|
|
382
|
+
await updateManagedGitignoreBlock(
|
|
383
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
384
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
385
|
+
{
|
|
386
|
+
add: [
|
|
387
|
+
...PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
388
|
+
...Object.keys(marker?.skills ?? {}).map(skillIgnoreLine),
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
);
|
|
374
392
|
out.check = { state: "active", profile, restored: false };
|
|
375
393
|
}
|
|
376
394
|
return out;
|