magus-cli 7.5.2 → 7.5.4
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 +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec 7.5.4 v3 — REQ-1, REQ-2, REQ-3, REQ-5 and REQ-6.
|
|
3
|
+
*
|
|
4
|
+
* Written blind: from the spec and the exported signatures only, never from the
|
|
5
|
+
* implementation. Every assertion is on something a user can observe — a return
|
|
6
|
+
* value, a line printed, a file written or left alone.
|
|
7
|
+
*
|
|
8
|
+
* Anything that could read `~/.claude` (REQ-3, REQ-5) runs in a CHILD process that
|
|
9
|
+
* starts with HOME, CLAUDE_CONFIG_DIR and XDG_* already pointing at temp dirs: Bun's
|
|
10
|
+
* `os.homedir()` ignores a HOME changed at runtime, so an in-process test could read
|
|
11
|
+
* the real home of whoever runs it. The child captures console.log / console.error
|
|
12
|
+
* (and raw stdout / stderr writes) in its own process, with colours disabled, and
|
|
13
|
+
* hands the output and the resolved value back through a result file.
|
|
14
|
+
*
|
|
15
|
+
* The child's PATH carries only `bun` and `git`. A test must not reach a real
|
|
16
|
+
* `claude` CLI, whatever the code under test does.
|
|
17
|
+
*
|
|
18
|
+
* Helper scripts are written to a mkdtemp dir at runtime and removed afterwards.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
afterAll,
|
|
22
|
+
afterEach,
|
|
23
|
+
beforeAll,
|
|
24
|
+
describe,
|
|
25
|
+
expect,
|
|
26
|
+
test,
|
|
27
|
+
} from "bun:test";
|
|
28
|
+
import { readdirSync } from "node:fs";
|
|
29
|
+
import {
|
|
30
|
+
mkdir,
|
|
31
|
+
mkdtemp,
|
|
32
|
+
readFile,
|
|
33
|
+
readdir,
|
|
34
|
+
realpath,
|
|
35
|
+
rm,
|
|
36
|
+
symlink,
|
|
37
|
+
writeFile,
|
|
38
|
+
} from "node:fs/promises";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
41
|
+
import { setColorEnabled } from "../cli/ansi.js";
|
|
42
|
+
import {
|
|
43
|
+
type SelectKeypress,
|
|
44
|
+
type SelectStep,
|
|
45
|
+
selectKey,
|
|
46
|
+
} from "../cli/select.js";
|
|
47
|
+
import {
|
|
48
|
+
noteBlock,
|
|
49
|
+
question,
|
|
50
|
+
selectDone,
|
|
51
|
+
selectFrame,
|
|
52
|
+
} from "../cli/update-view.js";
|
|
53
|
+
import { findEnclosingManifest } from "../services/manifest.js";
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Shared scaffolding
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const SRC = join(import.meta.dir, "..");
|
|
60
|
+
const MODULES = {
|
|
61
|
+
ansi: join(SRC, "cli", "ansi.ts"),
|
|
62
|
+
bootstrap: join(SRC, "cli", "bootstrap.ts"),
|
|
63
|
+
prompt: join(SRC, "cli", "prompt.ts"),
|
|
64
|
+
select: join(SRC, "cli", "select.ts"),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** A child that has not finished by then is killed; the test then fails on its output. */
|
|
68
|
+
const CHILD_TIMEOUT_MS = 45_000;
|
|
69
|
+
/** Per-test budget for anything that spawns a child. */
|
|
70
|
+
const CHILD_TEST_TIMEOUT_MS = 60_000;
|
|
71
|
+
|
|
72
|
+
const scratch: string[] = [];
|
|
73
|
+
|
|
74
|
+
/** A fresh directory under os.tmpdir(), passed through realpath (macOS /var → /private/var). */
|
|
75
|
+
async function tempRoot(prefix: string): Promise<string> {
|
|
76
|
+
const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
|
|
77
|
+
scratch.push(dir);
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
82
|
+
await mkdir(dirname(path), { recursive: true });
|
|
83
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function countOf(haystack: string, needle: string): number {
|
|
87
|
+
return haystack.split(needle).length - 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function linesOf(text: string): string[] {
|
|
91
|
+
return text.split("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
for (const dir of scratch) await rm(dir, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Child-process harness (REQ-2b, REQ-3, REQ-5, REQ-6d)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Calls `bootstrap[fn](project, opts)` with stdin/stdout/stderr forced non-TTY and colours
|
|
104
|
+
* off, capturing everything printed, in order, into one buffer.
|
|
105
|
+
*/
|
|
106
|
+
const BOOTSTRAP_CHILD = `
|
|
107
|
+
import { writeFileSync } from "node:fs";
|
|
108
|
+
import { format } from "node:util";
|
|
109
|
+
|
|
110
|
+
const [fn, project, optsJson, resultPath] = process.argv.slice(2);
|
|
111
|
+
const ansi = await import(${JSON.stringify(MODULES.ansi)});
|
|
112
|
+
const bootstrap = await import(${JSON.stringify(MODULES.bootstrap)});
|
|
113
|
+
|
|
114
|
+
ansi.setColorEnabled(false);
|
|
115
|
+
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
|
116
|
+
Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chunks = [];
|
|
120
|
+
const decode = (chunk) =>
|
|
121
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
122
|
+
const capture = (...args) => {
|
|
123
|
+
chunks.push(format(...args) + "\\n");
|
|
124
|
+
};
|
|
125
|
+
console.log = capture;
|
|
126
|
+
console.error = capture;
|
|
127
|
+
console.warn = capture;
|
|
128
|
+
console.info = capture;
|
|
129
|
+
const captureWrite = (chunk, ...rest) => {
|
|
130
|
+
chunks.push(decode(chunk));
|
|
131
|
+
const done = rest.find((arg) => typeof arg === "function");
|
|
132
|
+
if (done) done();
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
process.stdout.write = captureWrite;
|
|
136
|
+
process.stderr.write = captureWrite;
|
|
137
|
+
|
|
138
|
+
let result = null;
|
|
139
|
+
let error = null;
|
|
140
|
+
try {
|
|
141
|
+
const value = await bootstrap[fn](project, JSON.parse(optsJson));
|
|
142
|
+
result = value === undefined ? null : value;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
145
|
+
}
|
|
146
|
+
writeFileSync(
|
|
147
|
+
resultPath,
|
|
148
|
+
JSON.stringify({ result, error, output: chunks.join("") }),
|
|
149
|
+
);
|
|
150
|
+
process.exit(0);
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
/** Calls askYesNo with a marker prompt and prints the result after it. */
|
|
154
|
+
const PROMPT_CHILD = `
|
|
155
|
+
const [promptText] = process.argv.slice(2);
|
|
156
|
+
const prompt = await import(${JSON.stringify(MODULES.prompt)});
|
|
157
|
+
const value = await prompt.askYesNo(promptText);
|
|
158
|
+
process.stdout.write("\\n__RESULT__" + JSON.stringify(value === undefined ? null : value) + "\\n");
|
|
159
|
+
process.exit(0);
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Calls selectOption and prints what it resolved to. `undefined` is reported as the
|
|
164
|
+
* string "undefined", so it can never pass for the `null` REQ-6d requires.
|
|
165
|
+
*/
|
|
166
|
+
const SELECT_CHILD = `
|
|
167
|
+
const select = await import(${JSON.stringify(MODULES.select)});
|
|
168
|
+
const value = await select.selectOption(
|
|
169
|
+
"Choose a profile",
|
|
170
|
+
[{ label: "Save" }, { label: "Activate Lean", meta: "4 plugins" }, { label: "Quit" }],
|
|
171
|
+
1,
|
|
172
|
+
);
|
|
173
|
+
process.stdout.write("\\n__RESULT__" + JSON.stringify(value === undefined ? "undefined" : value) + "\\n");
|
|
174
|
+
process.exit(0);
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
let helperDir = "";
|
|
178
|
+
let binDir = "";
|
|
179
|
+
|
|
180
|
+
beforeAll(async () => {
|
|
181
|
+
helperDir = await tempRoot("cpp-helpers-");
|
|
182
|
+
binDir = join(helperDir, "bin");
|
|
183
|
+
await mkdir(binDir);
|
|
184
|
+
await symlink(process.execPath, join(binDir, "bun"));
|
|
185
|
+
const git = Bun.which("git");
|
|
186
|
+
if (git) await symlink(git, join(binDir, "git"));
|
|
187
|
+
await writeFile(join(helperDir, "bootstrap-child.ts"), BOOTSTRAP_CHILD);
|
|
188
|
+
await writeFile(join(helperDir, "prompt-child.ts"), PROMPT_CHILD);
|
|
189
|
+
await writeFile(join(helperDir, "select-child.ts"), SELECT_CHILD);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
interface Sandbox {
|
|
193
|
+
base: string;
|
|
194
|
+
home: string;
|
|
195
|
+
env: Record<string, string>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** HOME, CLAUDE_CONFIG_DIR and XDG_* in temp dirs. CLAUDE_CONFIG_DIR is `<HOME>/.claude`. */
|
|
199
|
+
async function sandbox(): Promise<Sandbox> {
|
|
200
|
+
const base = await tempRoot("cpp-");
|
|
201
|
+
const home = join(base, "home");
|
|
202
|
+
const xdg = join(base, "xdg");
|
|
203
|
+
await mkdir(join(home, ".claude"), { recursive: true });
|
|
204
|
+
for (const kind of ["config", "cache", "data", "state"]) {
|
|
205
|
+
await mkdir(join(xdg, kind), { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
base,
|
|
209
|
+
home,
|
|
210
|
+
env: {
|
|
211
|
+
PATH: `${binDir}:/usr/bin:/bin`,
|
|
212
|
+
HOME: home,
|
|
213
|
+
CLAUDE_CONFIG_DIR: join(home, ".claude"),
|
|
214
|
+
XDG_CONFIG_HOME: join(xdg, "config"),
|
|
215
|
+
XDG_CACHE_HOME: join(xdg, "cache"),
|
|
216
|
+
XDG_DATA_HOME: join(xdg, "data"),
|
|
217
|
+
XDG_STATE_HOME: join(xdg, "state"),
|
|
218
|
+
TMPDIR: tmpdir(),
|
|
219
|
+
NO_COLOR: "1",
|
|
220
|
+
TERM: "dumb",
|
|
221
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
222
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface ChildRun {
|
|
228
|
+
code: number;
|
|
229
|
+
stdout: string;
|
|
230
|
+
stderr: string;
|
|
231
|
+
/** True when the child had to be killed at the deadline. */
|
|
232
|
+
timedOut: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function runChild(
|
|
236
|
+
args: string[],
|
|
237
|
+
opts: {
|
|
238
|
+
env: Record<string, string>;
|
|
239
|
+
cwd: string;
|
|
240
|
+
input?: string;
|
|
241
|
+
/**
|
|
242
|
+
* Give the child a stdin pipe that is never written to and never closed, so a
|
|
243
|
+
* child that waits for input can only finish by being killed at the deadline.
|
|
244
|
+
*/
|
|
245
|
+
holdStdinOpen?: boolean;
|
|
246
|
+
timeoutMs?: number;
|
|
247
|
+
},
|
|
248
|
+
): Promise<ChildRun> {
|
|
249
|
+
const proc = Bun.spawn({
|
|
250
|
+
cmd: [process.execPath, "--no-env-file", ...args],
|
|
251
|
+
cwd: opts.cwd,
|
|
252
|
+
env: opts.env,
|
|
253
|
+
stdin: opts.holdStdinOpen
|
|
254
|
+
? "pipe"
|
|
255
|
+
: opts.input === undefined
|
|
256
|
+
? "ignore"
|
|
257
|
+
: new Blob([opts.input]),
|
|
258
|
+
stdout: "pipe",
|
|
259
|
+
stderr: "pipe",
|
|
260
|
+
});
|
|
261
|
+
let timedOut = false;
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
timedOut = true;
|
|
264
|
+
proc.kill();
|
|
265
|
+
}, opts.timeoutMs ?? CHILD_TIMEOUT_MS);
|
|
266
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
267
|
+
new Response(proc.stdout).text(),
|
|
268
|
+
new Response(proc.stderr).text(),
|
|
269
|
+
proc.exited,
|
|
270
|
+
]);
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
if (opts.holdStdinOpen && proc.stdin && typeof proc.stdin !== "number") {
|
|
273
|
+
try {
|
|
274
|
+
proc.stdin.end();
|
|
275
|
+
} catch {
|
|
276
|
+
// The child is gone; its pipe may already be closed.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { code, stdout, stderr, timedOut };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface BootstrapRun {
|
|
283
|
+
result: unknown;
|
|
284
|
+
error: string | null;
|
|
285
|
+
output: string;
|
|
286
|
+
lines: string[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function callBootstrap(
|
|
290
|
+
sb: Sandbox,
|
|
291
|
+
fn: "ensureManifest" | "profileGate",
|
|
292
|
+
project: string,
|
|
293
|
+
opts: Record<string, unknown>,
|
|
294
|
+
): Promise<BootstrapRun> {
|
|
295
|
+
const resultPath = join(sb.base, `result-${fn}-${Date.now()}.json`);
|
|
296
|
+
const child = await runChild(
|
|
297
|
+
[
|
|
298
|
+
join(helperDir, "bootstrap-child.ts"),
|
|
299
|
+
fn,
|
|
300
|
+
project,
|
|
301
|
+
JSON.stringify(opts),
|
|
302
|
+
resultPath,
|
|
303
|
+
],
|
|
304
|
+
{ env: sb.env, cwd: project },
|
|
305
|
+
);
|
|
306
|
+
let raw: string;
|
|
307
|
+
try {
|
|
308
|
+
raw = await readFile(resultPath, "utf8");
|
|
309
|
+
} catch {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`${fn} child wrote no result (exit ${child.code}).\nstdout:\n${child.stdout}\nstderr:\n${child.stderr}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const parsed = JSON.parse(raw) as Omit<BootstrapRun, "lines">;
|
|
315
|
+
return { ...parsed, lines: linesOf(parsed.output) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface PromptRun {
|
|
319
|
+
stdout: string;
|
|
320
|
+
value: unknown;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function callPrompt(
|
|
324
|
+
promptText: string,
|
|
325
|
+
input: string,
|
|
326
|
+
): Promise<PromptRun> {
|
|
327
|
+
const sb = await sandbox();
|
|
328
|
+
const child = await runChild(
|
|
329
|
+
[join(helperDir, "prompt-child.ts"), promptText],
|
|
330
|
+
{ env: sb.env, cwd: sb.base, input },
|
|
331
|
+
);
|
|
332
|
+
const match = /__RESULT__(.*)$/m.exec(child.stdout);
|
|
333
|
+
if (!match) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`askYesNo child printed no result (exit ${child.code}).\nstdout:\n${child.stdout}\nstderr:\n${child.stderr}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
stdout: child.stdout.slice(0, match.index),
|
|
340
|
+
value: JSON.parse(match[1] as string),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every directory and file under `root`, with file contents, for a byte-level compare. */
|
|
345
|
+
async function snapshotTree(root: string): Promise<Record<string, string>> {
|
|
346
|
+
const out: Record<string, string> = {};
|
|
347
|
+
async function walk(dir: string): Promise<void> {
|
|
348
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const full = join(dir, entry.name);
|
|
351
|
+
const rel = relative(root, full);
|
|
352
|
+
if (entry.isDirectory()) {
|
|
353
|
+
out[`${rel}/`] = "<dir>";
|
|
354
|
+
await walk(full);
|
|
355
|
+
} else {
|
|
356
|
+
out[rel] = (await readFile(full)).toString("base64");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await walk(root);
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const TWO_PLUGINS = {
|
|
365
|
+
enabledPlugins: { "dev@magus": true, "claudish@magus": true },
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** A project directory with its own `.claude/settings.json` and, by default, a `.git` dir. */
|
|
369
|
+
async function makeProject(
|
|
370
|
+
dir: string,
|
|
371
|
+
settings: unknown = TWO_PLUGINS,
|
|
372
|
+
withGitDir = true,
|
|
373
|
+
): Promise<string> {
|
|
374
|
+
await mkdir(dir, { recursive: true });
|
|
375
|
+
if (withGitDir) await mkdir(join(dir, ".git"), { recursive: true });
|
|
376
|
+
await writeJson(join(dir, ".claude", "settings.json"), settings);
|
|
377
|
+
return dir;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A project that is its own repo root (it carries the `.git`), so no ancestor inside its
|
|
382
|
+
* repo can hold a `.claude/profiles.json`. REQ-3b/3d/3d2/3e use this so that REQ-3c's
|
|
383
|
+
* "inside a project that already has profiles" branch cannot stop them.
|
|
384
|
+
*/
|
|
385
|
+
function standaloneProject(
|
|
386
|
+
sb: Sandbox,
|
|
387
|
+
settings: unknown = TWO_PLUGINS,
|
|
388
|
+
): Promise<string> {
|
|
389
|
+
return makeProject(join(sb.base, "proj"), settings);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const TEAM_MANIFEST = {
|
|
393
|
+
version: 2,
|
|
394
|
+
profiles: { team: { name: "team", plugins: { "dev@magus": true } } },
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Output with every run of whitespace collapsed to one space, for phrase checks: the CLI
|
|
399
|
+
* wraps long lines to the terminal, so a sentence may legitimately span two lines.
|
|
400
|
+
*/
|
|
401
|
+
function flatten(text: string): string {
|
|
402
|
+
return text.replace(/\s+/g, " ");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// REQ-1 findEnclosingManifest
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
describe("REQ-1 findEnclosingManifest", () => {
|
|
410
|
+
async function manifestAt(dir: string): Promise<void> {
|
|
411
|
+
await writeJson(join(dir, ".claude", "profiles.json"), TEAM_MANIFEST);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
test("REQ-1a returns the repo root for a subdirectory when the root has profiles.json", async () => {
|
|
415
|
+
const root = await tempRoot("req1a-");
|
|
416
|
+
await mkdir(join(root, ".git"));
|
|
417
|
+
await manifestAt(root);
|
|
418
|
+
const sub = join(root, "sub");
|
|
419
|
+
await mkdir(sub);
|
|
420
|
+
|
|
421
|
+
expect(await findEnclosingManifest(sub)).toBe(root);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("REQ-1a returns the root from several levels down", async () => {
|
|
425
|
+
const root = await tempRoot("req1a-deep-");
|
|
426
|
+
await mkdir(join(root, ".git"));
|
|
427
|
+
await manifestAt(root);
|
|
428
|
+
const deep = join(root, "a", "b", "c");
|
|
429
|
+
await mkdir(deep, { recursive: true });
|
|
430
|
+
|
|
431
|
+
expect(await findEnclosingManifest(deep)).toBe(root);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("REQ-1b the nearest ancestor with a manifest wins", async () => {
|
|
435
|
+
const root = await tempRoot("req1b-");
|
|
436
|
+
await mkdir(join(root, ".git"));
|
|
437
|
+
await manifestAt(root);
|
|
438
|
+
const middle = join(root, "packages");
|
|
439
|
+
await manifestAt(middle);
|
|
440
|
+
const project = join(middle, "app", "web");
|
|
441
|
+
await mkdir(project, { recursive: true });
|
|
442
|
+
|
|
443
|
+
expect(await findEnclosingManifest(project)).toBe(middle);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("REQ-1c returns null when projectPath itself has a .git directory, even with a manifest above", async () => {
|
|
447
|
+
const top = await tempRoot("req1c-dir-");
|
|
448
|
+
await mkdir(join(top, ".git"));
|
|
449
|
+
await manifestAt(top);
|
|
450
|
+
const nested = join(top, "nested");
|
|
451
|
+
await mkdir(join(nested, ".git"), { recursive: true });
|
|
452
|
+
|
|
453
|
+
expect(await findEnclosingManifest(nested)).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("REQ-1c returns null when projectPath itself has a .git FILE (worktree/submodule)", async () => {
|
|
457
|
+
const top = await tempRoot("req1c-file-");
|
|
458
|
+
await mkdir(join(top, ".git"));
|
|
459
|
+
await manifestAt(top);
|
|
460
|
+
const nested = join(top, "nested");
|
|
461
|
+
await mkdir(nested);
|
|
462
|
+
await writeFile(
|
|
463
|
+
join(nested, ".git"),
|
|
464
|
+
"gitdir: /nowhere/.git/worktrees/x\n",
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(await findEnclosingManifest(nested)).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("REQ-1d never searches above a directory containing .git", async () => {
|
|
471
|
+
const outer = await tempRoot("req1d-");
|
|
472
|
+
await mkdir(join(outer, ".git"));
|
|
473
|
+
await manifestAt(outer);
|
|
474
|
+
const repo = join(outer, "repo");
|
|
475
|
+
await mkdir(join(repo, ".git"), { recursive: true });
|
|
476
|
+
const sub = join(repo, "sub");
|
|
477
|
+
await mkdir(sub);
|
|
478
|
+
|
|
479
|
+
expect(await findEnclosingManifest(sub)).toBeNull();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("REQ-1e returns an ancestor that has both .git and profiles.json", async () => {
|
|
483
|
+
const root = await tempRoot("req1e-");
|
|
484
|
+
await mkdir(join(root, ".git"));
|
|
485
|
+
await manifestAt(root);
|
|
486
|
+
const project = join(root, "tools", "cli");
|
|
487
|
+
await mkdir(project, { recursive: true });
|
|
488
|
+
|
|
489
|
+
expect(await findEnclosingManifest(project)).toBe(root);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("REQ-1f returns null when no ancestor up to the repo root has a manifest", async () => {
|
|
493
|
+
const root = await tempRoot("req1f-");
|
|
494
|
+
await mkdir(join(root, ".git"));
|
|
495
|
+
const project = join(root, "a", "b");
|
|
496
|
+
await mkdir(project, { recursive: true });
|
|
497
|
+
|
|
498
|
+
expect(await findEnclosingManifest(project)).toBeNull();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("REQ-1g projectPath's own manifest does not count", async () => {
|
|
502
|
+
const root = await tempRoot("req1g-");
|
|
503
|
+
await mkdir(join(root, ".git"));
|
|
504
|
+
const project = join(root, "sub");
|
|
505
|
+
await manifestAt(project);
|
|
506
|
+
|
|
507
|
+
expect(await findEnclosingManifest(project)).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("REQ-1g with manifests at both projectPath and an ancestor, the ancestor is returned", async () => {
|
|
511
|
+
const root = await tempRoot("req1g-both-");
|
|
512
|
+
await mkdir(join(root, ".git"));
|
|
513
|
+
await manifestAt(root);
|
|
514
|
+
const project = join(root, "sub");
|
|
515
|
+
await manifestAt(project);
|
|
516
|
+
|
|
517
|
+
expect(await findEnclosingManifest(project)).toBe(root);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// REQ-2a question (in-process, colours off)
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
describe("REQ-2a question with colours off", () => {
|
|
526
|
+
afterEach(() => {
|
|
527
|
+
setColorEnabled(null);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('REQ-2a question("Install this plan?") is exactly " ? Install this plan? [Y/n] "', () => {
|
|
531
|
+
setColorEnabled(false);
|
|
532
|
+
|
|
533
|
+
expect(question("Install this plan?")).toBe(
|
|
534
|
+
" ? Install this plan? [Y/n] ",
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// REQ-2b askYesNo (child process, piped stdin)
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
describe("REQ-2b askYesNo", () => {
|
|
544
|
+
const cases: Array<[label: string, input: string, expected: boolean]> = [
|
|
545
|
+
["y", "y\n", true],
|
|
546
|
+
["Y", "Y\n", true],
|
|
547
|
+
["yes", "yes\n", true],
|
|
548
|
+
["an empty line", "\n", true],
|
|
549
|
+
["n", "n\n", false],
|
|
550
|
+
["no", "no\n", false],
|
|
551
|
+
["anything else (maybe)", "maybe\n", false],
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
for (const [label, input, expected] of cases) {
|
|
555
|
+
test(
|
|
556
|
+
`REQ-2b answers ${expected} for ${label}, writing the prompt once with nothing appended`,
|
|
557
|
+
async () => {
|
|
558
|
+
const run = await callPrompt("PROMPT>", input);
|
|
559
|
+
|
|
560
|
+
expect(run.value).toBe(expected);
|
|
561
|
+
expect(countOf(run.stdout, "PROMPT>")).toBe(1);
|
|
562
|
+
expect(run.stdout).not.toContain("[Y/n]");
|
|
563
|
+
},
|
|
564
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// REQ-6 choosing from a list: an arrow-key list in the terminal, no TUI
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
const PROFILE_OPTIONS = [
|
|
574
|
+
{ label: "Save" },
|
|
575
|
+
{ label: "Activate Lean", meta: "4 plugins" },
|
|
576
|
+
{ label: "Quit" },
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
describe("REQ-6a / REQ-6b / REQ-6f the list's frames with colours off", () => {
|
|
580
|
+
afterEach(() => {
|
|
581
|
+
setColorEnabled(null);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("REQ-6a selectFrame draws title, padded rows with ❯ on the selected one, and the key hint — exactly", () => {
|
|
585
|
+
setColorEnabled(false);
|
|
586
|
+
|
|
587
|
+
expect(selectFrame("Choose a profile", PROFILE_OPTIONS, 1)).toEqual([
|
|
588
|
+
" ? Choose a profile",
|
|
589
|
+
" Save ",
|
|
590
|
+
" ❯ Activate Lean 4 plugins",
|
|
591
|
+
" Quit ",
|
|
592
|
+
" ↑↓ move · enter select · esc cancel",
|
|
593
|
+
]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("REQ-6a (derived) at every index exactly one row carries ❯, and it is that index's label", () => {
|
|
597
|
+
setColorEnabled(false);
|
|
598
|
+
|
|
599
|
+
for (let index = 0; index < PROFILE_OPTIONS.length; index++) {
|
|
600
|
+
const marked = selectFrame(
|
|
601
|
+
"Choose a profile",
|
|
602
|
+
PROFILE_OPTIONS,
|
|
603
|
+
index,
|
|
604
|
+
).filter((line) => line.includes("❯"));
|
|
605
|
+
expect({ index, marked: marked.length }).toEqual({ index, marked: 1 });
|
|
606
|
+
expect(marked[0]).toContain(PROFILE_OPTIONS[index]?.label as string);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('REQ-6b selectDone("Choose a profile", "Activate Lean") is exactly " ✓ Choose a profile Activate Lean"', () => {
|
|
611
|
+
setColorEnabled(false);
|
|
612
|
+
|
|
613
|
+
expect(selectDone("Choose a profile", "Activate Lean")).toBe(
|
|
614
|
+
" ✓ Choose a profile Activate Lean",
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const PARAGRAPH_ONE =
|
|
619
|
+
"Claude Code treats each folder you start it in as a separate project, with its own plugins and settings, and magus follows along.";
|
|
620
|
+
const PARAGRAPH_TWO =
|
|
621
|
+
"Separately, to manage plugins for the whole project, run magus in the parent folder instead of this one.";
|
|
622
|
+
const NOTE = `${PARAGRAPH_ONE}\n\n${PARAGRAPH_TWO}`;
|
|
623
|
+
const words = (text: string): string[] => text.split(/\s+/).filter(Boolean);
|
|
624
|
+
|
|
625
|
+
test("REQ-6f every noteBlock line is empty or indented by 5 spaces, at 40 columns and at the default width", () => {
|
|
626
|
+
setColorEnabled(false);
|
|
627
|
+
|
|
628
|
+
for (const lines of [noteBlock(NOTE, 40), noteBlock(NOTE)]) {
|
|
629
|
+
expect(
|
|
630
|
+
lines.filter((line) => line !== "" && !line.startsWith(" ")),
|
|
631
|
+
).toEqual([]);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("REQ-6f at 40 columns no line is longer than 40 characters", () => {
|
|
636
|
+
setColorEnabled(false);
|
|
637
|
+
|
|
638
|
+
const lines = noteBlock(NOTE, 40);
|
|
639
|
+
|
|
640
|
+
expect(lines.filter((line) => line.length > 40)).toEqual([]);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("REQ-6f the input's words appear in order when the lines are joined", () => {
|
|
644
|
+
setColorEnabled(false);
|
|
645
|
+
|
|
646
|
+
const lines = noteBlock(NOTE, 40);
|
|
647
|
+
|
|
648
|
+
expect(words(lines.join(" "))).toEqual(words(NOTE));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("REQ-6f a blank line in the input becomes an empty line between the paragraphs", () => {
|
|
652
|
+
setColorEnabled(false);
|
|
653
|
+
|
|
654
|
+
const lines = noteBlock(NOTE, 40);
|
|
655
|
+
const endOfFirst = lines.findIndex((line) => line.includes("along."));
|
|
656
|
+
const startOfSecond = lines.findIndex((line) =>
|
|
657
|
+
line.includes("Separately,"),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
expect(endOfFirst).toBeGreaterThanOrEqual(0);
|
|
661
|
+
expect(startOfSecond).toBeGreaterThan(endOfFirst + 1);
|
|
662
|
+
expect(lines.slice(endOfFirst + 1, startOfSecond)).toContain("");
|
|
663
|
+
expect(
|
|
664
|
+
lines
|
|
665
|
+
.slice(endOfFirst + 1, startOfSecond)
|
|
666
|
+
.filter((line) => line.trim() !== ""),
|
|
667
|
+
).toEqual([]);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe("REQ-6c selectKey with count 4", () => {
|
|
672
|
+
const cases: Array<
|
|
673
|
+
[index: number, key: SelectKeypress, expected: SelectStep, note: string]
|
|
674
|
+
> = [
|
|
675
|
+
[0, { name: "down" }, { index: 1 }, "down moves down"],
|
|
676
|
+
[3, { name: "down" }, { index: 0 }, "down from the last row wraps"],
|
|
677
|
+
[0, { name: "up" }, { index: 3 }, "up from the first row wraps"],
|
|
678
|
+
[2, { name: "k" }, { index: 1 }, "k moves up"],
|
|
679
|
+
[2, { name: "j" }, { index: 3 }, "j moves down"],
|
|
680
|
+
[2, { name: "return" }, { chosen: 2 }, "return picks the current row"],
|
|
681
|
+
[1, { name: "enter" }, { chosen: 1 }, "enter picks the current row"],
|
|
682
|
+
[0, { sequence: "3" }, { chosen: 2 }, "a digit picks that row at once"],
|
|
683
|
+
[
|
|
684
|
+
0,
|
|
685
|
+
{ sequence: "9" },
|
|
686
|
+
{ index: 0 },
|
|
687
|
+
"an out-of-range digit changes nothing",
|
|
688
|
+
],
|
|
689
|
+
[1, { name: "escape" }, { cancelled: true }, "escape cancels"],
|
|
690
|
+
[1, { name: "c", ctrl: true }, { cancelled: true }, "ctrl+c cancels"],
|
|
691
|
+
[1, { name: "x" }, { index: 1 }, "any other key leaves the cursor"],
|
|
692
|
+
];
|
|
693
|
+
|
|
694
|
+
for (const [index, key, expected, note] of cases) {
|
|
695
|
+
test(`REQ-6c ${note}: selectKey(${index}, ${JSON.stringify(key)}, 4) → ${JSON.stringify(expected)}`, () => {
|
|
696
|
+
expect(selectKey(index, key, 4)).toEqual(expected);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("REQ-6d selectOption without a terminal", () => {
|
|
702
|
+
test(
|
|
703
|
+
"REQ-6d with stdin piped it resolves null without waiting for input, and draws no ↑↓ hint",
|
|
704
|
+
async () => {
|
|
705
|
+
const sb = await sandbox();
|
|
706
|
+
|
|
707
|
+
const child = await runChild([join(helperDir, "select-child.ts")], {
|
|
708
|
+
env: sb.env,
|
|
709
|
+
cwd: sb.base,
|
|
710
|
+
holdStdinOpen: true,
|
|
711
|
+
timeoutMs: 10_000,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect({ timedOut: child.timedOut, stderr: child.stderr }).toEqual({
|
|
715
|
+
timedOut: false,
|
|
716
|
+
stderr: child.stderr,
|
|
717
|
+
});
|
|
718
|
+
const match = /__RESULT__(.*)$/m.exec(child.stdout);
|
|
719
|
+
expect(match).not.toBeNull();
|
|
720
|
+
expect(JSON.parse(match?.[1] as string)).toBeNull();
|
|
721
|
+
expect(child.stdout.slice(0, match?.index)).not.toContain("↑↓");
|
|
722
|
+
},
|
|
723
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe("REQ-6e the CLI mounts no TUI component and has no numbered choice", () => {
|
|
728
|
+
test("REQ-6e no file named tui-prompt.tsx exists under src/cli/", () => {
|
|
729
|
+
const found = (
|
|
730
|
+
readdirSync(join(SRC, "cli"), { recursive: true }) as string[]
|
|
731
|
+
).filter((entry) => basename(entry) === "tui-prompt.tsx");
|
|
732
|
+
|
|
733
|
+
expect(found).toEqual([]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("REQ-6e src/cli/prompt.ts does not export askChoice", async () => {
|
|
737
|
+
const promptModule: Record<string, unknown> = await import(
|
|
738
|
+
"../cli/prompt.js"
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// The import worked: the module's own contract export is there.
|
|
742
|
+
expect(typeof promptModule.askYesNo).toBe("function");
|
|
743
|
+
expect("askChoice" in promptModule).toBe(false);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// REQ-3 ensureManifest (child process, non-TTY, colours off)
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
describe("REQ-3 ensureManifest", () => {
|
|
752
|
+
test(
|
|
753
|
+
"REQ-3a no manifest, no TTY, no allowAdopt: resolves null, names `magus profile init`, touches nothing",
|
|
754
|
+
async () => {
|
|
755
|
+
const sb = await sandbox();
|
|
756
|
+
const project = await standaloneProject(sb);
|
|
757
|
+
const before = await snapshotTree(project);
|
|
758
|
+
|
|
759
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
760
|
+
|
|
761
|
+
expect(run.error).toBeNull();
|
|
762
|
+
expect(run.result).toBeNull();
|
|
763
|
+
expect(run.lines.some((l) => l.includes("magus profile init"))).toBe(
|
|
764
|
+
true,
|
|
765
|
+
);
|
|
766
|
+
expect(await snapshotTree(project)).toEqual(before);
|
|
767
|
+
},
|
|
768
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
test(
|
|
772
|
+
"REQ-3b with no ancestor manifest, the plan leads with the state and never names a settings file",
|
|
773
|
+
async () => {
|
|
774
|
+
const sb = await sandbox();
|
|
775
|
+
const project = await standaloneProject(sb);
|
|
776
|
+
|
|
777
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
778
|
+
|
|
779
|
+
expect(run.error).toBeNull();
|
|
780
|
+
const firstNonEmpty = run.lines.find((l) => l.trim() !== "");
|
|
781
|
+
expect(firstNonEmpty).toContain("No profiles in this project yet");
|
|
782
|
+
expect(run.lines.filter((l) => l.includes("settings.json"))).toEqual([]);
|
|
783
|
+
expect(run.lines.filter((l) => l.includes(".mcp.json"))).toEqual([]);
|
|
784
|
+
expect(run.lines.filter((l) => l.trim().startsWith("from "))).toEqual([]);
|
|
785
|
+
},
|
|
786
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
/** `<base>/acme-repo` is the repo root and holds the profiles; the project sits below it. */
|
|
790
|
+
async function subfolderOfProfiledRepo(
|
|
791
|
+
sb: Sandbox,
|
|
792
|
+
segments: string[],
|
|
793
|
+
): Promise<{ root: string; project: string }> {
|
|
794
|
+
const root = join(sb.base, "acme-repo");
|
|
795
|
+
await mkdir(join(root, ".git"), { recursive: true });
|
|
796
|
+
await writeJson(join(root, ".claude", "profiles.json"), TEAM_MANIFEST);
|
|
797
|
+
const project = await makeProject(
|
|
798
|
+
join(root, ...segments),
|
|
799
|
+
TWO_PLUGINS,
|
|
800
|
+
false,
|
|
801
|
+
);
|
|
802
|
+
return { root, project };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const DEPTHS: Array<[rel: string, segments: string[]]> = [
|
|
806
|
+
["..", ["web"]],
|
|
807
|
+
["../..", ["apps", "web"]],
|
|
808
|
+
];
|
|
809
|
+
|
|
810
|
+
for (const [rel, segments] of DEPTHS) {
|
|
811
|
+
test(
|
|
812
|
+
`REQ-3c inside a project with profiles at ${rel}: resolves null with a plain note, asks nothing, writes nothing`,
|
|
813
|
+
async () => {
|
|
814
|
+
const sb = await sandbox();
|
|
815
|
+
const { root, project } = await subfolderOfProfiledRepo(sb, segments);
|
|
816
|
+
const before = await snapshotTree(root);
|
|
817
|
+
|
|
818
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
819
|
+
|
|
820
|
+
expect(run.error).toBeNull();
|
|
821
|
+
expect(run.result).toBeNull();
|
|
822
|
+
const flat = flatten(run.output);
|
|
823
|
+
expect(flat).toContain("Inside another Claude Code project");
|
|
824
|
+
expect(flat).toContain(
|
|
825
|
+
"This folder is inside a Claude Code project that already has profiles",
|
|
826
|
+
);
|
|
827
|
+
expect(run.lines.map((l) => l.trim())).toContain(`${rel} (acme-repo)`);
|
|
828
|
+
expect(flat).toContain(
|
|
829
|
+
"Claude Code treats each folder you start it in as a separate project",
|
|
830
|
+
);
|
|
831
|
+
expect(flat).toContain("run magus there");
|
|
832
|
+
expect(flat).toContain("magus profile init");
|
|
833
|
+
expect(run.output).not.toContain("[Y/n]");
|
|
834
|
+
expect(run.output).not.toContain("No profiles in this project yet");
|
|
835
|
+
expect(run.output).not.toContain("⚠");
|
|
836
|
+
expect(await snapshotTree(root)).toEqual(before);
|
|
837
|
+
},
|
|
838
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
test(
|
|
843
|
+
"REQ-3c with allowAdopt in that subfolder: writes the subfolder's own profiles.json and leaves the ancestor's alone",
|
|
844
|
+
async () => {
|
|
845
|
+
const sb = await sandbox();
|
|
846
|
+
const { root, project } = await subfolderOfProfiledRepo(sb, ["web"]);
|
|
847
|
+
const ancestorManifest = join(root, ".claude", "profiles.json");
|
|
848
|
+
const ancestorBefore = await readFile(ancestorManifest, "utf8");
|
|
849
|
+
|
|
850
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {
|
|
851
|
+
allowAdopt: true,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
expect(run.error).toBeNull();
|
|
855
|
+
expect(
|
|
856
|
+
await Bun.file(join(project, ".claude", "profiles.json")).exists(),
|
|
857
|
+
).toBe(true);
|
|
858
|
+
expect(await readFile(ancestorManifest, "utf8")).toBe(ancestorBefore);
|
|
859
|
+
const result = run.result as { profiles?: unknown } | null;
|
|
860
|
+
expect(result).not.toBeNull();
|
|
861
|
+
expect(typeof result?.profiles).toBe("object");
|
|
862
|
+
expect(
|
|
863
|
+
run.lines.filter((l) =>
|
|
864
|
+
l.includes("Inside another Claude Code project"),
|
|
865
|
+
),
|
|
866
|
+
).toEqual([]);
|
|
867
|
+
},
|
|
868
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
test(
|
|
872
|
+
"REQ-3c with no ancestor manifest, no line says Inside another Claude Code project",
|
|
873
|
+
async () => {
|
|
874
|
+
const sb = await sandbox();
|
|
875
|
+
const project = await standaloneProject(sb);
|
|
876
|
+
|
|
877
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
878
|
+
|
|
879
|
+
expect(run.error).toBeNull();
|
|
880
|
+
expect(
|
|
881
|
+
run.lines.filter((l) =>
|
|
882
|
+
l.includes("Inside another Claude Code project"),
|
|
883
|
+
),
|
|
884
|
+
).toEqual([]);
|
|
885
|
+
},
|
|
886
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
test(
|
|
890
|
+
"REQ-3d the plan counts 2 PROJECT plugins, lists them by bare name, and proposes profile default NEW",
|
|
891
|
+
async () => {
|
|
892
|
+
const sb = await sandbox();
|
|
893
|
+
const project = await standaloneProject(sb);
|
|
894
|
+
|
|
895
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
896
|
+
|
|
897
|
+
expect(run.error).toBeNull();
|
|
898
|
+
const pluginsAt = run.lines.findIndex(
|
|
899
|
+
(l) =>
|
|
900
|
+
l.trim().startsWith("plugins") &&
|
|
901
|
+
/\b2\b/.test(l) &&
|
|
902
|
+
l.includes("PROJECT"),
|
|
903
|
+
);
|
|
904
|
+
expect(pluginsAt).toBeGreaterThanOrEqual(0);
|
|
905
|
+
const below = run.lines.slice(pluginsAt + 1).map((l) => l.trim());
|
|
906
|
+
expect(below).toContain("dev");
|
|
907
|
+
expect(below).toContain("claudish");
|
|
908
|
+
expect(run.lines.filter((l) => l.includes("@magus"))).toEqual([]);
|
|
909
|
+
expect(
|
|
910
|
+
run.lines.some(
|
|
911
|
+
(l) =>
|
|
912
|
+
l.trim().startsWith("profile") &&
|
|
913
|
+
l.includes("default") &&
|
|
914
|
+
l.includes("NEW"),
|
|
915
|
+
),
|
|
916
|
+
).toBe(true);
|
|
917
|
+
},
|
|
918
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
test(
|
|
922
|
+
"REQ-3d2 with no project plugins and a USER plugin, the plugins line says USER and the project has none of its own",
|
|
923
|
+
async () => {
|
|
924
|
+
const sb = await sandbox();
|
|
925
|
+
const project = await standaloneProject(sb, {
|
|
926
|
+
enabledPlugins: {},
|
|
927
|
+
});
|
|
928
|
+
await writeJson(join(sb.home, ".claude", "settings.json"), {
|
|
929
|
+
enabledPlugins: { "dev@magus": true },
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
933
|
+
|
|
934
|
+
expect(run.error).toBeNull();
|
|
935
|
+
const pluginLines = run.lines.filter((l) =>
|
|
936
|
+
l.trim().startsWith("plugins"),
|
|
937
|
+
);
|
|
938
|
+
expect(pluginLines.length).toBeGreaterThan(0);
|
|
939
|
+
expect(pluginLines.some((l) => l.includes("USER"))).toBe(true);
|
|
940
|
+
expect(pluginLines.filter((l) => l.includes("PROJECT"))).toEqual([]);
|
|
941
|
+
expect(
|
|
942
|
+
run.lines.some((l) =>
|
|
943
|
+
l.includes("This project has no plugins of its own"),
|
|
944
|
+
),
|
|
945
|
+
).toBe(true);
|
|
946
|
+
},
|
|
947
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
test(
|
|
951
|
+
"REQ-3e allowAdopt writes profiles.json, resolves to a manifest, and says saved / commit / using",
|
|
952
|
+
async () => {
|
|
953
|
+
const sb = await sandbox();
|
|
954
|
+
const project = await standaloneProject(sb);
|
|
955
|
+
|
|
956
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {
|
|
957
|
+
allowAdopt: true,
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
expect(run.error).toBeNull();
|
|
961
|
+
const written = JSON.parse(
|
|
962
|
+
await readFile(join(project, ".claude", "profiles.json"), "utf8"),
|
|
963
|
+
) as { profiles?: Record<string, unknown> };
|
|
964
|
+
expect(Object.keys(written.profiles ?? {}).length).toBeGreaterThan(0);
|
|
965
|
+
const result = run.result as {
|
|
966
|
+
profiles?: Record<string, unknown>;
|
|
967
|
+
} | null;
|
|
968
|
+
expect(result).not.toBeNull();
|
|
969
|
+
expect(Object.keys(result?.profiles ?? {}).length).toBeGreaterThan(0);
|
|
970
|
+
expect(run.lines.some((l) => l.includes("Saved profile"))).toBe(true);
|
|
971
|
+
expect(
|
|
972
|
+
run.lines.some((l) => l.includes("Commit .claude/profiles.json")),
|
|
973
|
+
).toBe(true);
|
|
974
|
+
expect(run.lines.some((l) => l.includes("Using profile"))).toBe(true);
|
|
975
|
+
},
|
|
976
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
test(
|
|
980
|
+
"REQ-3f check mode resolves null, writes no profiles.json, and names --check",
|
|
981
|
+
async () => {
|
|
982
|
+
const sb = await sandbox();
|
|
983
|
+
const project = await standaloneProject(sb);
|
|
984
|
+
|
|
985
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {
|
|
986
|
+
check: true,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
expect(run.error).toBeNull();
|
|
990
|
+
expect(run.result).toBeNull();
|
|
991
|
+
expect(
|
|
992
|
+
await Bun.file(join(project, ".claude", "profiles.json")).exists(),
|
|
993
|
+
).toBe(false);
|
|
994
|
+
expect(run.lines.some((l) => l.includes("--check"))).toBe(true);
|
|
995
|
+
},
|
|
996
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
test(
|
|
1000
|
+
"REQ-3g an existing manifest with a profile is returned, and no adoption state is printed",
|
|
1001
|
+
async () => {
|
|
1002
|
+
const sb = await sandbox();
|
|
1003
|
+
const project = await standaloneProject(sb);
|
|
1004
|
+
await writeJson(join(project, ".claude", "profiles.json"), TEAM_MANIFEST);
|
|
1005
|
+
|
|
1006
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {});
|
|
1007
|
+
|
|
1008
|
+
expect(run.error).toBeNull();
|
|
1009
|
+
expect(run.result).toMatchObject(TEAM_MANIFEST);
|
|
1010
|
+
expect(
|
|
1011
|
+
run.lines.filter((l) => l.includes("No profiles in this project yet")),
|
|
1012
|
+
).toEqual([]);
|
|
1013
|
+
},
|
|
1014
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
test(
|
|
1018
|
+
"REQ-3h in a real repo tracking settings.json, the tracked-file notice prints once and nothing is staged",
|
|
1019
|
+
async () => {
|
|
1020
|
+
const sb = await sandbox();
|
|
1021
|
+
const project = join(sb.base, "proj");
|
|
1022
|
+
await mkdir(project, { recursive: true });
|
|
1023
|
+
const git = (...args: string[]): string => {
|
|
1024
|
+
const r = Bun.spawnSync({
|
|
1025
|
+
cmd: [
|
|
1026
|
+
"git",
|
|
1027
|
+
"-c",
|
|
1028
|
+
"user.email=t@t",
|
|
1029
|
+
"-c",
|
|
1030
|
+
"user.name=t",
|
|
1031
|
+
"-c",
|
|
1032
|
+
"commit.gpgsign=false",
|
|
1033
|
+
...args,
|
|
1034
|
+
],
|
|
1035
|
+
cwd: project,
|
|
1036
|
+
env: sb.env,
|
|
1037
|
+
});
|
|
1038
|
+
if (r.exitCode !== 0) {
|
|
1039
|
+
throw new Error(
|
|
1040
|
+
`git ${args.join(" ")} failed: ${r.stderr.toString()}`,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
return r.stdout.toString();
|
|
1044
|
+
};
|
|
1045
|
+
git("init", "-q");
|
|
1046
|
+
await writeJson(join(project, ".claude", "settings.json"), TWO_PLUGINS);
|
|
1047
|
+
git("add", ".claude/settings.json");
|
|
1048
|
+
git("commit", "-q", "-m", "init");
|
|
1049
|
+
|
|
1050
|
+
const run = await callBootstrap(sb, "ensureManifest", project, {
|
|
1051
|
+
allowAdopt: true,
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
expect(run.error).toBeNull();
|
|
1055
|
+
expect(
|
|
1056
|
+
countOf(
|
|
1057
|
+
run.output,
|
|
1058
|
+
"git tracks .claude/settings.json, but magus rewrites it",
|
|
1059
|
+
),
|
|
1060
|
+
).toBe(1);
|
|
1061
|
+
expect(
|
|
1062
|
+
countOf(
|
|
1063
|
+
run.output,
|
|
1064
|
+
"git rm --cached .claude/settings.json, then commit",
|
|
1065
|
+
),
|
|
1066
|
+
).toBe(1);
|
|
1067
|
+
expect(git("diff", "--cached", "--name-only")).toBe("");
|
|
1068
|
+
},
|
|
1069
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1070
|
+
);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
// REQ-5 profileGate with an old profile format (child process, non-TTY)
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
|
|
1077
|
+
describe("REQ-5 profileGate and the old profile format", () => {
|
|
1078
|
+
const STATE_LINE = "This project uses an old magus profile format";
|
|
1079
|
+
|
|
1080
|
+
async function oldFormatProject(
|
|
1081
|
+
sb: Sandbox,
|
|
1082
|
+
withOldDir: boolean,
|
|
1083
|
+
): Promise<string> {
|
|
1084
|
+
const project = join(sb.base, "proj");
|
|
1085
|
+
await mkdir(join(project, ".git"), { recursive: true });
|
|
1086
|
+
if (withOldDir) {
|
|
1087
|
+
await writeJson(
|
|
1088
|
+
join(project, ".claude", "_profiles", "team", "settings.json"),
|
|
1089
|
+
{ enabledPlugins: { "dev@magus": true } },
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
await writeJson(join(project, ".claude", "profiles.json"), TEAM_MANIFEST);
|
|
1093
|
+
return project;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
test(
|
|
1097
|
+
"REQ-5a non-TTY without yes: the state is printed once and nobody is asked",
|
|
1098
|
+
async () => {
|
|
1099
|
+
const sb = await sandbox();
|
|
1100
|
+
const project = await oldFormatProject(sb, true);
|
|
1101
|
+
|
|
1102
|
+
const run = await callBootstrap(sb, "profileGate", project, {});
|
|
1103
|
+
|
|
1104
|
+
expect(run.error).toBeNull();
|
|
1105
|
+
expect(countOf(run.output, STATE_LINE)).toBe(1);
|
|
1106
|
+
expect(run.output).not.toContain("[Y/n]");
|
|
1107
|
+
},
|
|
1108
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
test(
|
|
1112
|
+
"REQ-5b with yes: the same state line is printed and nobody is asked",
|
|
1113
|
+
async () => {
|
|
1114
|
+
const sb = await sandbox();
|
|
1115
|
+
const project = await oldFormatProject(sb, true);
|
|
1116
|
+
|
|
1117
|
+
const run = await callBootstrap(sb, "profileGate", project, {
|
|
1118
|
+
yes: true,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
expect(run.error).toBeNull();
|
|
1122
|
+
expect(countOf(run.output, STATE_LINE)).toBeGreaterThanOrEqual(1);
|
|
1123
|
+
expect(run.output).not.toContain("[Y/n]");
|
|
1124
|
+
},
|
|
1125
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
test(
|
|
1129
|
+
"REQ-5c without a .claude/_profiles directory, the old format is not mentioned",
|
|
1130
|
+
async () => {
|
|
1131
|
+
const sb = await sandbox();
|
|
1132
|
+
const project = await oldFormatProject(sb, false);
|
|
1133
|
+
|
|
1134
|
+
const run = await callBootstrap(sb, "profileGate", project, {});
|
|
1135
|
+
|
|
1136
|
+
expect(run.error).toBeNull();
|
|
1137
|
+
expect(run.output).not.toContain("old magus profile format");
|
|
1138
|
+
},
|
|
1139
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
test(
|
|
1143
|
+
"REQ-5d the old-format notice names no internal paths or jargon",
|
|
1144
|
+
async () => {
|
|
1145
|
+
const sb = await sandbox();
|
|
1146
|
+
const project = await oldFormatProject(sb, true);
|
|
1147
|
+
|
|
1148
|
+
const run = await callBootstrap(sb, "profileGate", project, {});
|
|
1149
|
+
|
|
1150
|
+
expect(run.error).toBeNull();
|
|
1151
|
+
for (const banned of [
|
|
1152
|
+
"_profiles",
|
|
1153
|
+
".magus-backups",
|
|
1154
|
+
"older layout",
|
|
1155
|
+
"live config",
|
|
1156
|
+
]) {
|
|
1157
|
+
expect({
|
|
1158
|
+
banned,
|
|
1159
|
+
lines: run.lines.filter((l) => l.includes(banned)),
|
|
1160
|
+
}).toEqual({ banned, lines: [] });
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
CHILD_TEST_TIMEOUT_MS,
|
|
1164
|
+
);
|
|
1165
|
+
});
|