magus-cli 7.12.0 → 7.12.2

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.12.0",
3
+ "version": "7.12.2",
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.12.0",
69
- "magus-cli-darwin-x64": "7.12.0",
70
- "magus-cli-linux-x64": "7.12.0"
68
+ "magus-cli-darwin-arm64": "7.12.2",
69
+ "magus-cli-darwin-x64": "7.12.2",
70
+ "magus-cli-linux-x64": "7.12.2"
71
71
  }
72
72
  }
@@ -8,7 +8,7 @@ import {
8
8
  renderArgsAsTokens,
9
9
  renderEnv,
10
10
  } from "../services/alias-shell-writer";
11
- import type { AliasConfig } from "../services/alias-store";
11
+ import { type AliasConfig, defaultValueFor } from "../services/alias-store";
12
12
 
13
13
  const assignment = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1";
14
14
  const shells: ShellKind[] = ["bash", "zsh", "fish"];
@@ -31,23 +31,20 @@ function expectAliasLine(
31
31
  if (shell === "fish") {
32
32
  const quotedArgs =
33
33
  argv.length === 0 ? "" : ` ${argv.map((arg) => `'${arg}'`).join(" ")}`;
34
- expect([
35
- `alias ${aliasName} '${prefix}${bareArgs}'`,
36
- `alias ${aliasName} '${prefix}${quotedArgs}'`,
37
- ]).toContain(lines[0]);
34
+ expect(lines).toEqual([`alias ${aliasName} '${prefix}${quotedArgs}'`]);
38
35
  } else {
39
36
  expect(lines).toEqual([`alias ${aliasName}='${prefix}${bareArgs}'`]);
40
37
  }
41
38
  }
42
39
 
43
40
  describe("agent teams catalog", () => {
44
- it("TEST-7 REQ-1 REQ-4 exposes an env-backed boolean and the Agent teams group", () => {
41
+ it("TEST-7 REQ-1 REQ-4 exposes one env-backed select and the Agent teams group", () => {
45
42
  expect(getFlagById("agent-teams")).toMatchObject({
46
43
  id: "agent-teams",
47
44
  label: "Agent teams",
48
- kind: "boolean",
45
+ kind: "select",
49
46
  env: assignment,
50
- flag: assignment,
47
+ flag: "--teammate-mode",
51
48
  group: "agents",
52
49
  });
53
50
  expect(FLAG_GROUPS.filter((group) => group.id === "agents")).toEqual([
@@ -56,26 +53,39 @@ describe("agent teams catalog", () => {
56
53
  expect(
57
54
  ALIAS_FLAGS.filter((flag) => flag.id === "agent-teams"),
58
55
  ).toHaveLength(1);
56
+ expect(
57
+ ALIAS_FLAGS.filter(
58
+ (flag) => flag.kind === "boolean" && flag.env !== undefined,
59
+ ),
60
+ ).toEqual([]);
59
61
  });
60
62
 
61
- it("TEST-8 REQ-4 exposes exactly the teammate choices and their dependency", () => {
62
- const flag = getFlagById("teammate-mode");
63
+ it("TEST-8 REQ-4 exposes the ordered teammate choices without a dependency", () => {
64
+ const flag = getFlagById("agent-teams");
63
65
 
64
- expect(flag).toMatchObject({
65
- id: "teammate-mode",
66
- label: "Teammate panes",
67
- kind: "select",
68
- flag: "--teammate-mode",
69
- group: "agents",
70
- requires: "agent-teams",
71
- });
72
- expect(flag.options?.map((option) => option.value).sort()).toEqual([
73
- "auto",
74
- "in-process",
75
- "iterm2",
66
+ expect(flag.options?.map((option) => option.value)).toEqual([
76
67
  "tmux",
68
+ "iterm2",
69
+ "in-process",
70
+ "auto",
77
71
  ]);
78
- expect(flag.env).toBeUndefined();
72
+ expect(flag.requires).toBeUndefined();
73
+ });
74
+
75
+ it("TEST-22 REQ-1 REQ-4 has no separate teammate-mode catalog entry", () => {
76
+ expect(ALIAS_FLAGS.filter((flag) => flag.id === "teammate-mode")).toEqual(
77
+ [],
78
+ );
79
+ });
80
+
81
+ it("TEST-23 REQ-1 REQ-4 defaults agent teams to a disabled tmux select", () => {
82
+ const flag = getFlagById("agent-teams");
83
+
84
+ expect(defaultValueFor(flag)).toEqual({
85
+ kind: "select",
86
+ enabled: false,
87
+ value: "tmux",
88
+ });
79
89
  });
80
90
  });
81
91
 
@@ -85,15 +95,23 @@ describe("environment and argv separation", () => {
85
95
  const config: AliasConfig = { aliasName: "c", flags: {} };
86
96
  if (state !== "missing") {
87
97
  config.flags["agent-teams"] = {
88
- kind: "boolean",
98
+ kind: "select",
89
99
  enabled: state === "enabled",
100
+ value: "tmux",
90
101
  };
91
102
  }
92
103
  const expectedEnv = state === "enabled" ? [assignment] : [];
104
+ const expectedArgv =
105
+ state === "enabled" ? ["--teammate-mode", "tmux"] : [];
93
106
 
94
107
  expect(renderEnv(config)).toEqual(expectedEnv);
95
- expect(renderArgs(config)).toEqual([]);
96
- expect(renderArgsAsTokens(config)).toEqual(expectedEnv);
108
+ expect(renderArgs(config)).toEqual(
109
+ expectedArgv.map((text) => ({ kind: "literal", text })),
110
+ );
111
+ expect(renderArgsAsTokens(config)).toEqual([
112
+ ...expectedEnv,
113
+ ...expectedArgv,
114
+ ]);
97
115
  });
98
116
  }
99
117
  });
@@ -101,20 +119,24 @@ describe("environment and argv separation", () => {
101
119
  describe("agent teams shell output and POSIX round-trips", () => {
102
120
  // Fish rendering is covered; the parse-back contract uses POSIX alias lines.
103
121
  for (const shell of shells) {
104
- it(`TEST-10 REQ-8 REQ-13 renders agent teams alone before claude on ${shell}`, () => {
122
+ it(`TEST-10 REQ-8 REQ-13 renders automatic agent teams with env before claude on ${shell}`, () => {
105
123
  const config: AliasConfig = {
106
124
  aliasName: "c",
107
- flags: { "agent-teams": { kind: "boolean", enabled: true } },
125
+ flags: {
126
+ "agent-teams": { kind: "select", enabled: true, value: "auto" },
127
+ },
108
128
  };
109
129
 
110
130
  const rendered = renderAlias(config, shell);
111
131
 
112
132
  expect(rendered.shell).toBe(shell);
113
- expectAliasLine(rendered.block, shell, true, []);
133
+ expectAliasLine(rendered.block, shell, true, ["--teammate-mode", "auto"]);
114
134
  if (shell !== "fish") {
115
135
  expect(parseAliasFromRc(rendered.block)).toEqual({
116
136
  aliasName: "c",
117
- flags: { "agent-teams": { kind: "boolean", enabled: true } },
137
+ flags: {
138
+ "agent-teams": { kind: "select", enabled: true, value: "auto" },
139
+ },
118
140
  });
119
141
  }
120
142
  });
@@ -124,8 +146,7 @@ describe("agent teams shell output and POSIX round-trips", () => {
124
146
  const config: AliasConfig = {
125
147
  aliasName: "c",
126
148
  flags: {
127
- "agent-teams": { kind: "boolean", enabled: true },
128
- "teammate-mode": { kind: "select", enabled: true, value: mode },
149
+ "agent-teams": { kind: "select", enabled: true, value: mode },
129
150
  },
130
151
  };
131
152
 
@@ -146,8 +167,7 @@ describe("agent teams shell output and POSIX round-trips", () => {
146
167
  expect(parseAliasFromRc(rendered.block)).toEqual({
147
168
  aliasName: "c",
148
169
  flags: {
149
- "agent-teams": { kind: "boolean", enabled: true },
150
- "teammate-mode": { kind: "select", enabled: true, value: mode },
170
+ "agent-teams": { kind: "select", enabled: true, value: mode },
151
171
  },
152
172
  });
153
173
  }
@@ -155,21 +175,26 @@ describe("agent teams shell output and POSIX round-trips", () => {
155
175
  }
156
176
 
157
177
  for (const state of ["missing", "disabled"] as const) {
158
- it(`TEST-12 REQ-14 drops teammate-mode with ${state} agent teams but keeps context size on ${shell}`, () => {
178
+ it(`TEST-12 REQ-14 omits both team outputs with ${state} agent teams but keeps context size on ${shell}`, () => {
159
179
  const config: AliasConfig = {
160
180
  aliasName: "c",
161
181
  flags: {
162
- "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
163
182
  autocompact: { kind: "text", enabled: true, value: "200k" },
164
183
  },
165
184
  };
185
+ const withoutTeams = structuredClone(config);
166
186
  if (state === "disabled") {
167
- config.flags["agent-teams"] = { kind: "boolean", enabled: false };
187
+ config.flags["agent-teams"] = {
188
+ kind: "select",
189
+ enabled: false,
190
+ value: "tmux",
191
+ };
168
192
  }
169
193
  const original = structuredClone(config);
170
194
 
171
195
  const rendered = renderAlias(config, shell);
172
196
 
197
+ expect(rendered).toEqual(renderAlias(withoutTeams, shell));
173
198
  expect(renderEnv(config)).toEqual([]);
174
199
  expect(renderArgs(config)).toEqual([
175
200
  { kind: "literal", text: "--autocompact" },
@@ -192,49 +217,47 @@ describe("agent teams shell output and POSIX round-trips", () => {
192
217
  });
193
218
  }
194
219
 
195
- it(`TEST-13 REQ-4 REQ-7 REQ-8 REQ-13 keeps agent teams but omits a disabled teammate mode on ${shell}`, () => {
220
+ it(`TEST-13 REQ-4 REQ-7 REQ-8 REQ-13 omits both outputs for disabled agent teams on ${shell}`, () => {
196
221
  const config: AliasConfig = {
197
222
  aliasName: "c",
198
223
  flags: {
199
- "agent-teams": { kind: "boolean", enabled: true },
200
- "teammate-mode": { kind: "select", enabled: false, value: "tmux" },
224
+ "agent-teams": { kind: "select", enabled: false, value: "tmux" },
201
225
  },
202
226
  };
227
+ const withoutTeams: AliasConfig = { aliasName: "c", flags: {} };
203
228
 
204
229
  const rendered = renderAlias(config, shell);
205
230
 
231
+ expect(rendered).toEqual(renderAlias(withoutTeams, shell));
232
+ expect(renderEnv(config)).toEqual([]);
206
233
  expect(renderArgs(config)).toEqual([]);
207
- expect(renderArgsAsTokens(config)).toEqual([assignment]);
208
- expectAliasLine(rendered.block, shell, true, []);
234
+ expect(renderArgsAsTokens(config)).toEqual([]);
235
+ expectAliasLine(rendered.block, shell, false, []);
209
236
  if (shell !== "fish") {
210
237
  expect(parseAliasFromRc(rendered.block)).toEqual({
211
238
  aliasName: "c",
212
- flags: { "agent-teams": { kind: "boolean", enabled: true } },
239
+ flags: {},
213
240
  });
214
241
  }
215
242
  });
216
243
 
217
- it(`TEST-14 REQ-6 REQ-7 REQ-8 REQ-9 REQ-13 renders all new options with env first on ${shell}`, () => {
244
+ it(`TEST-14 REQ-6 REQ-7 REQ-8 REQ-9 REQ-13 renders agent teams after context size with env first on ${shell}`, () => {
218
245
  const config: AliasConfig = {
219
246
  aliasName: "crew",
220
247
  flags: {
221
- "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
248
+ "agent-teams": { kind: "select", enabled: true, value: "tmux" },
222
249
  autocompact: { kind: "text", enabled: true, value: "200k" },
223
- "agent-teams": { kind: "boolean", enabled: true },
224
250
  },
225
251
  };
226
252
  const contextIndex = ALIAS_FLAGS.findIndex(
227
253
  (flag) => flag.id === "autocompact",
228
254
  );
229
- const modeIndex = ALIAS_FLAGS.findIndex(
230
- (flag) => flag.id === "teammate-mode",
255
+ const teamsIndex = ALIAS_FLAGS.findIndex(
256
+ (flag) => flag.id === "agent-teams",
231
257
  );
232
258
  expect(contextIndex).toBeGreaterThanOrEqual(0);
233
- expect(modeIndex).toBeGreaterThanOrEqual(0);
234
- const argv =
235
- contextIndex < modeIndex
236
- ? ["--autocompact", "200k", "--teammate-mode", "tmux"]
237
- : ["--teammate-mode", "tmux", "--autocompact", "200k"];
259
+ expect(teamsIndex).toBeGreaterThan(contextIndex);
260
+ const argv = ["--autocompact", "200k", "--teammate-mode", "tmux"];
238
261
 
239
262
  const rendered = renderAlias(config, shell);
240
263
 
@@ -248,8 +271,7 @@ describe("agent teams shell output and POSIX round-trips", () => {
248
271
  expect(parseAliasFromRc(rendered.block)).toEqual({
249
272
  aliasName: "crew",
250
273
  flags: {
251
- "agent-teams": { kind: "boolean", enabled: true },
252
- "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
274
+ "agent-teams": { kind: "select", enabled: true, value: "tmux" },
253
275
  autocompact: { kind: "text", enabled: true, value: "200k" },
254
276
  },
255
277
  });
@@ -6,15 +6,16 @@ import {
6
6
  parseAliasLine,
7
7
  parseManagedBlock,
8
8
  renderAlias,
9
+ renderArgsAsTokens,
9
10
  } from "../services/alias-shell-writer";
10
11
  import type { AliasConfig, FlagValue } from "../services/alias-store";
11
12
 
12
13
  const assignment = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1";
14
+ const modes = ["tmux", "iterm2", "in-process", "auto"];
13
15
 
14
16
  function combinedFlags(): Record<string, FlagValue> {
15
17
  return {
16
- "agent-teams": { kind: "boolean", enabled: true },
17
- "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
18
+ "agent-teams": { kind: "select", enabled: true, value: "tmux" },
18
19
  autocompact: { kind: "text", enabled: true, value: "200k" },
19
20
  };
20
21
  }
@@ -46,6 +47,80 @@ function commandBody(
46
47
  ].join(" ");
47
48
  }
48
49
 
50
+ describe("single agent-team parse rows", () => {
51
+ type ParseRow = {
52
+ name: string;
53
+ env: string[];
54
+ argv: string[];
55
+ flags: () => Record<string, FlagValue>;
56
+ leftovers: string[];
57
+ };
58
+ const rows: ParseRow[] = [
59
+ ...modes.map(
60
+ (mode): ParseRow => ({
61
+ name: `assignment and ${mode} argv become one select`,
62
+ env: [assignment],
63
+ argv: ["--teammate-mode", mode],
64
+ flags: () => ({
65
+ "agent-teams": { kind: "select", enabled: true, value: mode },
66
+ }),
67
+ leftovers: [],
68
+ }),
69
+ ),
70
+ {
71
+ name: "assignment alone defaults to auto",
72
+ env: [assignment],
73
+ argv: [],
74
+ flags: () => ({
75
+ "agent-teams": { kind: "select", enabled: true, value: "auto" },
76
+ }),
77
+ leftovers: [],
78
+ },
79
+ ...modes.map(
80
+ (mode): ParseRow => ({
81
+ name: `${mode} argv alone remains leftovers`,
82
+ env: [],
83
+ argv: ["--teammate-mode", mode],
84
+ flags: () => ({}),
85
+ leftovers: ["--teammate-mode", mode],
86
+ }),
87
+ ),
88
+ ];
89
+
90
+ for (const row of rows) {
91
+ it(`TEST-24 REQ-12 REQ-14 maps tokens so ${row.name}`, () => {
92
+ const args = [...row.env, ...row.argv];
93
+
94
+ expect(argsToFlagValuesWithLeftovers(args)).toEqual({
95
+ flags: row.flags(),
96
+ leftovers: [...row.leftovers],
97
+ });
98
+ });
99
+
100
+ for (const form of forms) {
101
+ it(`TEST-25 REQ-11 REQ-12 REQ-14 parses ${form.name} so ${row.name}`, () => {
102
+ const line = form.line(
103
+ commandBody(form.envLauncher, row.env, row.argv),
104
+ );
105
+
106
+ const parsed = parseAliasLine(line);
107
+
108
+ expect(parsed).toEqual({
109
+ aliasName: "crew",
110
+ args: [...row.env, ...row.argv],
111
+ });
112
+ if (parsed === null) {
113
+ throw new Error("Expected a parsed claude alias");
114
+ }
115
+ expect(argsToFlagValuesWithLeftovers(parsed.args)).toEqual({
116
+ flags: row.flags(),
117
+ leftovers: [...row.leftovers],
118
+ });
119
+ });
120
+ }
121
+ }
122
+ });
123
+
49
124
  describe("managed assignment-prefixed alias parsing", () => {
50
125
  const examples = [
51
126
  {
@@ -126,6 +201,21 @@ describe("assignment mapping and leftovers", () => {
126
201
  flags: combinedFlags,
127
202
  leftovers: ["EMPTY=", "EXTRA=a=b"],
128
203
  },
204
+ {
205
+ name: "unknown assignments on both sides of the known one",
206
+ args: [
207
+ "FIRST=one",
208
+ assignment,
209
+ "EMPTY=",
210
+ "EXTRA=a=b",
211
+ "--teammate-mode",
212
+ "tmux",
213
+ "--autocompact",
214
+ "200k",
215
+ ],
216
+ flags: combinedFlags,
217
+ leftovers: ["FIRST=one", "EMPTY=", "EXTRA=a=b"],
218
+ },
129
219
  {
130
220
  name: "known env name with a different value",
131
221
  args: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0", "--ide"],
@@ -159,7 +249,7 @@ describe("assignment mapping and leftovers", () => {
159
249
 
160
250
  expect(argsToFlagValuesWithLeftovers(args)).toEqual({
161
251
  flags: {
162
- "agent-teams": { kind: "boolean", enabled: true },
252
+ "agent-teams": { kind: "select", enabled: true, value: "auto" },
163
253
  ide: { kind: "boolean", enabled: true },
164
254
  },
165
255
  leftovers: ["EXTRA=keep"],
@@ -183,12 +273,14 @@ describe("assignment mapping and leftovers", () => {
183
273
  });
184
274
 
185
275
  describe("safe adoption of assignment-prefixed aliases", () => {
186
- const losslessCases: Array<{
276
+ type LosslessCase = {
187
277
  name: string;
188
278
  env: string[];
189
279
  argv: string[];
190
280
  flags: () => Record<string, FlagValue>;
191
- }> = [
281
+ renderedArgs: string[];
282
+ };
283
+ const losslessCases: LosslessCase[] = [
192
284
  {
193
285
  name: "context size only",
194
286
  env: [],
@@ -196,21 +288,41 @@ describe("safe adoption of assignment-prefixed aliases", () => {
196
288
  flags: () => ({
197
289
  autocompact: { kind: "text", enabled: true, value: "200k" },
198
290
  }),
291
+ renderedArgs: ["--autocompact", "200k"],
199
292
  },
200
293
  {
201
- name: "agent teams only",
294
+ name: "agent teams assignment alone as auto",
202
295
  env: [assignment],
203
296
  argv: [],
204
297
  flags: () => ({
205
- "agent-teams": { kind: "boolean", enabled: true },
298
+ "agent-teams": { kind: "select", enabled: true, value: "auto" },
206
299
  }),
300
+ renderedArgs: [assignment, "--teammate-mode", "auto"],
207
301
  },
208
302
  {
209
303
  name: "all new options",
210
304
  env: [assignment],
211
305
  argv: ["--teammate-mode", "tmux", "--autocompact", "200k"],
212
306
  flags: combinedFlags,
307
+ renderedArgs: [
308
+ assignment,
309
+ "--autocompact",
310
+ "200k",
311
+ "--teammate-mode",
312
+ "tmux",
313
+ ],
213
314
  },
315
+ ...modes.map(
316
+ (mode): LosslessCase => ({
317
+ name: `agent teams with explicit ${mode} mode`,
318
+ env: [assignment],
319
+ argv: ["--teammate-mode", mode],
320
+ flags: () => ({
321
+ "agent-teams": { kind: "select", enabled: true, value: mode },
322
+ }),
323
+ renderedArgs: [assignment, "--teammate-mode", mode],
324
+ }),
325
+ ),
214
326
  ];
215
327
 
216
328
  const unknownCases: Array<{
@@ -241,6 +353,13 @@ describe("safe adoption of assignment-prefixed aliases", () => {
241
353
  flags: combinedFlags,
242
354
  leftovers: ["EXTRA=a=b"],
243
355
  },
356
+ {
357
+ name: "unknown assignments on both sides of known teams in their original order",
358
+ env: ["FIRST=one", assignment, "EMPTY=", "EXTRA=a=b"],
359
+ argv: ["--teammate-mode", "tmux", "--autocompact", "200k"],
360
+ flags: combinedFlags,
361
+ leftovers: ["FIRST=one", "EMPTY=", "EXTRA=a=b"],
362
+ },
244
363
  {
245
364
  name: "a known assignment name with an unsupported value",
246
365
  env: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0"],
@@ -277,6 +396,12 @@ describe("safe adoption of assignment-prefixed aliases", () => {
277
396
  aliasName: adopted.name,
278
397
  flags: adopted.flags,
279
398
  };
399
+ const renderedArgs = renderArgsAsTokens(config);
400
+ expect(renderedArgs).toEqual(example.renderedArgs);
401
+ expect(argsToFlagValuesWithLeftovers(renderedArgs)).toEqual({
402
+ flags: expectedFlags,
403
+ leftovers: [],
404
+ });
280
405
  expect(parseAliasFromRc(renderAlias(config, "bash").block)).toEqual({
281
406
  aliasName: "crew",
282
407
  flags: expectedFlags,
@@ -310,13 +435,29 @@ describe("safe adoption of assignment-prefixed aliases", () => {
310
435
 
311
436
  const adopted = findAdoptableAlias(`${line}\n`);
312
437
 
438
+ expect(adopted).toEqual({
439
+ name: "crew",
440
+ flags: {},
441
+ lineRange: [0, 0],
442
+ leftovers: ["--teammate-mode", "tmux"],
443
+ lossless: false,
444
+ rawLine: line,
445
+ others: [],
446
+ });
447
+ });
448
+
449
+ it(`TEST-26 REQ-12 REQ-13 REQ-14 keeps context size but refuses lossless argv-only teams in ${form.name}`, () => {
450
+ const line = form.line("claude --teammate-mode tmux --autocompact 200k");
451
+
452
+ const adopted = findAdoptableAlias(`${line}\n`);
453
+
313
454
  expect(adopted).toEqual({
314
455
  name: "crew",
315
456
  flags: {
316
- "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
457
+ autocompact: { kind: "text", enabled: true, value: "200k" },
317
458
  },
318
459
  lineRange: [0, 0],
319
- leftovers: [],
460
+ leftovers: ["--teammate-mode", "tmux"],
320
461
  lossless: false,
321
462
  rawLine: line,
322
463
  others: [],
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The Plugins screen installs and uninstalls through `installPluginInScope`
3
+ * and `uninstallPluginInScope`. In a profiled project a PROJECT-scope install
4
+ * turns the plugin on in `.claude/settings.json`; the active profile must list
5
+ * it as soon as the action returns, and the profile's record of the live files
6
+ * must move with it, so the next magus command finds nothing left to absorb.
7
+ *
8
+ * Real files, real reconcile: a temp project with `.claude/profiles.json` and
9
+ * an activated profile, HOME pointed at a temp dir. Only the `claude` CLI is
10
+ * doubled — install sets `enabledPlugins[id] = true` in the scope's
11
+ * settings.json, uninstall deletes it — plus the version read-back.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import fs from "fs-extra";
19
+ import { readManifest, writeManifest } from "../services/manifest.js";
20
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
21
+ import { activateProfile } from "../services/profile-activate.js";
22
+ import {
23
+ readActiveMarker,
24
+ writeActiveMarker,
25
+ } from "../services/profile-live.js";
26
+ import {
27
+ type PluginApplyDeps,
28
+ REAL_PLUGIN_DEPS,
29
+ REAL_REMOVE_DEPS,
30
+ installPluginInScope,
31
+ uninstallPluginInScope,
32
+ updatePluginInScope,
33
+ } from "../services/update-engine.js";
34
+ import type { ProfileManifest } from "../types/index.js";
35
+
36
+ type Scope = "user" | "project" | "local";
37
+
38
+ let project: string;
39
+ let home: string;
40
+ let log: ReturnType<typeof spyOn>;
41
+ const saved = {
42
+ HOME: process.env.HOME,
43
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
44
+ };
45
+
46
+ function manifest(): ProfileManifest {
47
+ return {
48
+ version: 2,
49
+ profiles: {
50
+ team: {
51
+ name: "Team",
52
+ plugins: { "dev@magus": "latest", "terminal@magus": "latest" },
53
+ },
54
+ },
55
+ };
56
+ }
57
+
58
+ /** The settings.json `claude plugin … --scope <scope>` writes. */
59
+ function settingsFor(scope: Scope): string {
60
+ if (scope === "user") return join(home, ".claude", "settings.json");
61
+ if (scope === "local") return join(project, ".claude", "settings.local.json");
62
+ return join(project, ".claude", "settings.json");
63
+ }
64
+
65
+ async function setEnabled(
66
+ scope: Scope,
67
+ pluginId: string,
68
+ on: boolean,
69
+ ): Promise<void> {
70
+ const file = settingsFor(scope);
71
+ const json = (await fs.pathExists(file))
72
+ ? ((await fs.readJson(file)) as Record<string, unknown>)
73
+ : {};
74
+ const enabled = {
75
+ ...((json.enabledPlugins as Record<string, boolean>) ?? {}),
76
+ };
77
+ if (on) enabled[pluginId] = true;
78
+ else delete enabled[pluginId];
79
+ json.enabledPlugins = enabled;
80
+ await fs.outputFile(file, JSON.stringify(json));
81
+ }
82
+
83
+ /**
84
+ * The real engine deps with only the `claude` CLI and the version read-back
85
+ * doubled. The loader and registry probes are dropped so `bringLoadForward`
86
+ * skips; every other member stays real.
87
+ */
88
+ function deps(): PluginApplyDeps {
89
+ const {
90
+ loadedVersion: _loaded,
91
+ registryRows: _rows,
92
+ ...real
93
+ } = REAL_PLUGIN_DEPS;
94
+ return {
95
+ ...real,
96
+ install: async (pluginId, scope) => setEnabled(scope, pluginId, true),
97
+ update: async () => {},
98
+ repair: async () => {},
99
+ readInstalled: async () => "1.0.0",
100
+ saveInstalled: async () => {},
101
+ repairStaleLoad: async () => {
102
+ throw new Error("repairStaleLoad must not run here");
103
+ },
104
+ remove: {
105
+ ...REAL_REMOVE_DEPS,
106
+ uninstall: async (pluginId, scope) => setEnabled(scope, pluginId, false),
107
+ },
108
+ };
109
+ }
110
+
111
+ async function enabledIn(scope: Scope): Promise<Record<string, boolean>> {
112
+ const json = (await fs.readJson(settingsFor(scope))) as {
113
+ enabledPlugins?: Record<string, boolean>;
114
+ };
115
+ return json.enabledPlugins ?? {};
116
+ }
117
+
118
+ function profilesJson(): Promise<string> {
119
+ return fs.readFile(join(project, ".claude", "profiles.json"), "utf8");
120
+ }
121
+
122
+ async function profilePlugins(): Promise<Record<string, unknown>> {
123
+ const { profiles } = await readManifest(project);
124
+ return (profiles.team?.plugins ?? {}) as Record<string, unknown>;
125
+ }
126
+
127
+ beforeEach(async () => {
128
+ project = await mkdtemp(join(tmpdir(), "tui-scope-profile-"));
129
+ home = await mkdtemp(join(tmpdir(), "tui-scope-profile-home-"));
130
+ process.env.HOME = home;
131
+ process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
132
+ log = spyOn(console, "log").mockImplementation(() => {});
133
+ await writeManifest(manifest(), project);
134
+ await activateProfile(manifest(), "team", project, { onDisplace: () => {} });
135
+ });
136
+
137
+ afterEach(async () => {
138
+ log.mockRestore();
139
+ process.env.HOME = saved.HOME;
140
+ if (saved.CLAUDE_CONFIG_DIR === undefined) {
141
+ // biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
142
+ delete process.env.CLAUDE_CONFIG_DIR;
143
+ } else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
144
+ await rm(project, { recursive: true, force: true });
145
+ await rm(home, { recursive: true, force: true });
146
+ });
147
+
148
+ describe("Plugins screen scope actions record the change in the active profile", () => {
149
+ test("PROJECT install: the profile lists the plugin, and nothing is left to absorb", async () => {
150
+ await installPluginInScope("aside@magus", "project", project, deps());
151
+ expect(await enabledIn("project")).toHaveProperty("aside@magus", true);
152
+
153
+ expect(await profilePlugins()).toHaveProperty("aside@magus");
154
+ const next = await reconcileActiveProfile(project);
155
+ expect(next.lines).toEqual([]);
156
+ });
157
+
158
+ test("PROJECT uninstall: the profile drops the plugin, and nothing is left to absorb", async () => {
159
+ await uninstallPluginInScope(
160
+ "terminal@magus",
161
+ "project",
162
+ project,
163
+ deps(),
164
+ "team",
165
+ );
166
+
167
+ expect(await profilePlugins()).not.toHaveProperty("terminal@magus");
168
+ const next = await reconcileActiveProfile(project);
169
+ expect(next.lines).toEqual([]);
170
+ });
171
+
172
+ test("USER install: the profile is left as it was", async () => {
173
+ const before = await fs.readFile(
174
+ join(project, ".claude", "profiles.json"),
175
+ "utf8",
176
+ );
177
+
178
+ await installPluginInScope("aside@magus", "user", project, deps());
179
+ expect(await enabledIn("user")).toHaveProperty("aside@magus", true);
180
+
181
+ expect(
182
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
183
+ ).toBe(before);
184
+ expect(await profilePlugins()).not.toHaveProperty("aside@magus");
185
+ });
186
+
187
+ test("LOCAL install: the private install never reaches the shared profile", async () => {
188
+ const before = await profilesJson();
189
+
190
+ await installPluginInScope("aside@magus", "local", project, deps());
191
+ expect(await enabledIn("local")).toHaveProperty("aside@magus", true);
192
+
193
+ expect(await profilesJson()).toBe(before);
194
+ expect(await profilePlugins()).not.toHaveProperty("aside@magus");
195
+ });
196
+
197
+ test("PROJECT install under CLI bookkeeping: the profile is left to the wrapper", async () => {
198
+ const marker = await readActiveMarker(project);
199
+ if (!marker) throw new Error("activation wrote no marker");
200
+ await writeActiveMarker({ ...marker, cliBookkeeping: true }, project);
201
+ const before = await profilesJson();
202
+
203
+ await installPluginInScope("aside@magus", "project", project, deps());
204
+ expect(await enabledIn("project")).toHaveProperty("aside@magus", true);
205
+
206
+ expect(await profilesJson()).toBe(before);
207
+ expect((await readActiveMarker(project))?.cliBookkeeping).toBe(true);
208
+ });
209
+
210
+ test("PROJECT update of a declared plugin: nothing new is recorded", async () => {
211
+ const before = await profilesJson();
212
+ const updating: PluginApplyDeps = {
213
+ ...deps(),
214
+ // `claude plugin update` rewrites the scope's settings.json; the
215
+ // plugin stays enabled.
216
+ update: async (pluginId, scope) => setEnabled(scope, pluginId, true),
217
+ };
218
+
219
+ await updatePluginInScope("dev@magus", "project", project, updating);
220
+
221
+ expect(await profilesJson()).toBe(before);
222
+ const next = await reconcileActiveProfile(project);
223
+ expect(next.lines).toEqual([]);
224
+ });
225
+
226
+ test("an unreadable live file: the install is reported done, the profile record refused", async () => {
227
+ const broken: PluginApplyDeps = {
228
+ ...deps(),
229
+ install: async (pluginId, scope) => {
230
+ await setEnabled(scope, pluginId, true);
231
+ await fs.appendFile(settingsFor(scope), "{ not json");
232
+ },
233
+ };
234
+
235
+ await expect(
236
+ installPluginInScope("aside@magus", "project", project, broken),
237
+ ).rejects.toThrow(
238
+ "installed at PROJECT scope, but the profile does not record it",
239
+ );
240
+ });
241
+ });
@@ -73,10 +73,11 @@ export interface AliasFlag {
73
73
  */
74
74
  templated?: boolean;
75
75
  /**
76
- * Environment assignment (`NAME=VALUE`) the alias sets before `claude`,
77
- * instead of an argv token. Only for `kind: "boolean"`: enabled emits the
78
- * assignment, disabled emits nothing. `flag` carries the same string so
79
- * lookups by `flag` keep working.
76
+ * Environment assignment (`NAME=VALUE`) the alias sets before `claude`
77
+ * whenever the flag is enabled. For `kind: "boolean"` the assignment is the
78
+ * whole flag, and `flag` carries the same string so lookups by `flag` keep
79
+ * working. For `kind: "select"` the flag also emits `flag <value>` as argv:
80
+ * the assignment and the argv are one setting that only works as a pair.
80
81
  */
81
82
  env?: string;
82
83
  /**
@@ -390,22 +391,12 @@ export const ALIAS_FLAGS: AliasFlag[] = [
390
391
  {
391
392
  id: "agent-teams",
392
393
  label: "Agent teams",
393
- flag: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
394
- env: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
395
- kind: "boolean",
396
- group: "agents",
397
- description:
398
- "Turn on agent teams, so the session can start teammates. Claude Code has no flag for this: the alias sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 before claude.",
399
- },
400
- {
401
- id: "teammate-mode",
402
- label: "Teammate panes",
403
394
  flag: "--teammate-mode",
395
+ env: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
404
396
  kind: "select",
405
397
  group: "agents",
406
- requires: "agent-teams",
407
398
  description:
408
- "Where teammates run. tmux opens each teammate in a split pane, but only when the session starts inside tmux. iterm2 uses iTerm2 panes, in-process keeps them in this terminal, auto picks for you. Requires agent teams.",
399
+ "Turn on agent teams and pick where teammates run. The alias sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 and passes --teammate-mode, because one does nothing without the other. tmux opens each teammate in a split pane, but only when the session starts inside tmux. iterm2 uses iTerm2 panes, in-process keeps them in this terminal, auto picks for you.",
409
400
  options: [
410
401
  { label: "tmux", value: "tmux" },
411
402
  { label: "iterm2", value: "iterm2" },
@@ -125,8 +125,9 @@ export function renderArgs(config: AliasConfig): Segment[] {
125
125
  const seenXor = new Set<string>();
126
126
 
127
127
  for (const flag of ALIAS_FLAGS) {
128
- // Env flags are assignments before `claude`, never argv: see renderEnv.
129
- if (flag.env) continue;
128
+ // A boolean env flag is only an assignment before `claude`: see renderEnv.
129
+ // A select env flag adds its argv here as well.
130
+ if (isEnvOnly(flag)) continue;
130
131
  if (flag.requires) {
131
132
  const dep = config.flags[flag.requires];
132
133
  if (!isFlagEnabled(dep)) continue;
@@ -187,6 +188,11 @@ export function renderArgsAsTokens(config: AliasConfig): string[] {
187
188
  return [...renderEnv(config), ...argv];
188
189
  }
189
190
 
191
+ /** A boolean env flag emits its assignment and no argv. */
192
+ function isEnvOnly(flag: AliasFlag): boolean {
193
+ return flag.env !== undefined && flag.kind === "boolean";
194
+ }
195
+
190
196
  function isFlagEnabled(value: FlagValue | undefined): boolean {
191
197
  if (!value) return false;
192
198
  switch (value.kind) {
@@ -860,6 +866,11 @@ export interface ParsedFlagsWithLeftovers {
860
866
  * catalog does not know lands in `leftovers` on its own, so adoption never
861
867
  * removes a line it cannot reproduce. Env flags are matched only in that
862
868
  * leading run, never among the argv after it.
869
+ *
870
+ * A select env flag is one setting written as an assignment plus
871
+ * `flag <value>`. Its assignment alone reads as the option Claude Code uses
872
+ * when no value is given (`auto`); its argv alone lands in `leftovers`,
873
+ * because the pair is the only form the writer reproduces.
863
874
  */
864
875
  export function argsToFlagValuesWithLeftovers(
865
876
  args: string[],
@@ -871,7 +882,7 @@ export function argsToFlagValuesWithLeftovers(
871
882
  for (const flag of ALIAS_FLAGS) {
872
883
  if (flag.env) {
873
884
  byEnv.set(flag.env, flag);
874
- continue;
885
+ if (isEnvOnly(flag)) continue;
875
886
  }
876
887
  byFlag.set(flag.flag, { flag, off: false });
877
888
  if (flag.triStateOff) {
@@ -881,11 +892,15 @@ export function argsToFlagValuesWithLeftovers(
881
892
 
882
893
  const out: Record<string, FlagValue> = {};
883
894
  const leftovers: string[] = [];
895
+ const envSeen = new Set<string>();
884
896
  let i = 0;
885
897
  while (i < args.length && ENV_ASSIGNMENT_RE.test(args[i])) {
886
898
  const envFlag = byEnv.get(args[i]);
887
- if (envFlag) out[envFlag.id] = { kind: "boolean", enabled: true };
899
+ if (envFlag) envSeen.add(envFlag.id);
888
900
  else leftovers.push(args[i]);
901
+ if (envFlag && isEnvOnly(envFlag)) {
902
+ out[envFlag.id] = { kind: "boolean", enabled: true };
903
+ }
889
904
  i += 1;
890
905
  }
891
906
  while (i < args.length) {
@@ -1041,6 +1056,20 @@ export function argsToFlagValuesWithLeftovers(
1041
1056
  };
1042
1057
  }
1043
1058
 
1059
+ // Pair each select env flag's assignment with its argv.
1060
+ for (const flag of ALIAS_FLAGS) {
1061
+ if (!flag.env || isEnvOnly(flag)) continue;
1062
+ const parsed = out[flag.id];
1063
+ const hasArgv = parsed !== undefined;
1064
+ if (envSeen.has(flag.id) && !hasArgv) {
1065
+ out[flag.id] = { kind: "select", enabled: true, value: "auto" };
1066
+ } else if (!envSeen.has(flag.id) && hasArgv) {
1067
+ delete out[flag.id];
1068
+ const value = parsed.kind === "select" ? parsed.value : "";
1069
+ leftovers.push(flag.flag, ...(value ? [value] : []));
1070
+ }
1071
+ }
1072
+
1044
1073
  return { flags: out, leftovers };
1045
1074
  }
1046
1075
 
@@ -267,6 +267,15 @@ export interface PluginApplyDeps {
267
267
  * that did not happen.
268
268
  */
269
269
  remove?: PluginRemoveDeps;
270
+ /**
271
+ * Absorb the live settings into the active profile and bring its record of
272
+ * them forward (`recordScopeActionInProfile`). A Plugins-screen scope action
273
+ * writes the live settings itself, through the `claude` CLI, so the profile
274
+ * must record it at once — otherwise the next magus command absorbs it as an
275
+ * edit the user made by hand. Optional so a double that does not model
276
+ * profiles skips it.
277
+ */
278
+ recordInProfile?: (projectPath: string) => Promise<void>;
270
279
  }
271
280
 
272
281
  /** The three calls a `remove` makes — see {@link applyRemove}. */
@@ -395,6 +404,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
395
404
  return Array.isArray(rows) ? rows : [];
396
405
  },
397
406
  remove: REAL_REMOVE_DEPS,
407
+ recordInProfile: recordScopeActionInProfile,
398
408
  };
399
409
 
400
410
  /**
@@ -1084,6 +1094,53 @@ export async function removeDeprecatedPlugin(
1084
1094
  return { problems, unlistedFrom: unlisted.unlistedFrom };
1085
1095
  }
1086
1096
 
1097
+ /**
1098
+ * Is a `withCliBookkeeping` run (`magus update`) in progress here? Its wrapper
1099
+ * reconciles before it renders, so a reconcile inside the run would write a
1100
+ * fresh marker, clear the bookkeeping flag, and turn the CLI's own install
1101
+ * records into edits the user made.
1102
+ */
1103
+ async function underCliBookkeeping(projectPath: string): Promise<boolean> {
1104
+ const marker = (await hasCurrentMarker(projectPath))
1105
+ ? await readActiveMarker(projectPath)
1106
+ : null;
1107
+ return marker?.cliBookkeeping === true;
1108
+ }
1109
+
1110
+ /**
1111
+ * Absorb the live settings into the active profile after a Plugins-screen
1112
+ * scope action, so the profile lists what the `claude` CLI just turned on or
1113
+ * off and its record of the live files matches them. A no-op when the live
1114
+ * files did not change or no profile is active, and left to the wrapper under
1115
+ * {@link underCliBookkeeping}. Refuses, writing nothing, on an unreadable live
1116
+ * file.
1117
+ *
1118
+ * The reconcile absorbs every pending edit to the live config, not only this
1119
+ * action's own change, exactly as the next magus command would. The TUI shows
1120
+ * no status for what it recorded.
1121
+ */
1122
+ async function recordScopeActionInProfile(projectPath: string): Promise<void> {
1123
+ if (await underCliBookkeeping(projectPath)) return;
1124
+ refuseOnProblems(await reconcileActiveProfile(projectPath));
1125
+ }
1126
+
1127
+ /**
1128
+ * Run `deps.recordInProfile`, rethrowing a failure as one that says the action
1129
+ * itself happened: the plugin is installed or removed, only the profile lags.
1130
+ */
1131
+ async function recordInProfileOrExplain(
1132
+ deps: PluginApplyDeps,
1133
+ projectPath: string,
1134
+ done: string,
1135
+ ): Promise<void> {
1136
+ try {
1137
+ await deps.recordInProfile?.(projectPath);
1138
+ } catch (error) {
1139
+ const why = error instanceof Error ? error.message : String(error);
1140
+ throw new Error(`${done}, but the profile does not record it: ${why}`);
1141
+ }
1142
+ }
1143
+
1087
1144
  /**
1088
1145
  * Stop EVERY profile in the project listing a deprecated plugin, keeping the
1089
1146
  * user's live edits — the profile half of both removals, {@link applyRemove}
@@ -1114,10 +1171,7 @@ async function unlistFromEveryProfile(
1114
1171
  const problems: unknown[] = [];
1115
1172
  const unlistedFrom: string[] = [];
1116
1173
  try {
1117
- const marker = (await hasCurrentMarker(projectPath))
1118
- ? await readActiveMarker(projectPath)
1119
- : null;
1120
- const wrapperReconciles = marker?.cliBookkeeping === true;
1174
+ const wrapperReconciles = await underCliBookkeeping(projectPath);
1121
1175
  // Refuses, writing nothing, on an unreadable live file.
1122
1176
  if (!wrapperReconciles) {
1123
1177
  refuseOnProblems(await reconcileActiveProfile(projectPath));
@@ -1321,6 +1375,7 @@ export async function applyPlugins(
1321
1375
  */
1322
1376
  async function applyOneInScope(
1323
1377
  run: (pluginId: string, scope: PluginScope) => Promise<void>,
1378
+ verb: "installed" | "updated",
1324
1379
  pluginId: string,
1325
1380
  scope: PluginScope,
1326
1381
  projectPath: string,
@@ -1338,6 +1393,11 @@ async function applyOneInScope(
1338
1393
  throw new Error(describeLoadNotForward(forward));
1339
1394
  }
1340
1395
  }
1396
+ await recordInProfileOrExplain(
1397
+ deps,
1398
+ projectPath,
1399
+ `${verb} at ${scope.toUpperCase()} scope`,
1400
+ );
1341
1401
  return actual;
1342
1402
  }
1343
1403
 
@@ -1359,7 +1419,14 @@ export async function installPluginInScope(
1359
1419
  projectPath: string,
1360
1420
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
1361
1421
  ): Promise<string | null> {
1362
- return applyOneInScope(deps.install, pluginId, scope, projectPath, deps);
1422
+ return applyOneInScope(
1423
+ deps.install,
1424
+ "installed",
1425
+ pluginId,
1426
+ scope,
1427
+ projectPath,
1428
+ deps,
1429
+ );
1363
1430
  }
1364
1431
 
1365
1432
  /**
@@ -1381,6 +1448,7 @@ export async function updatePluginInScope(
1381
1448
  async (id, sc) => {
1382
1449
  await advanceInScope(deps, id, sc, projectPath);
1383
1450
  },
1451
+ "updated",
1384
1452
  pluginId,
1385
1453
  scope,
1386
1454
  projectPath,
@@ -1454,12 +1522,13 @@ export async function uninstallPluginInScope(
1454
1522
  break;
1455
1523
  }
1456
1524
  }
1457
- if (scope !== "project" && !installedNowhere) return;
1458
- if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1459
- throw new Error(
1460
- `uninstalled at ${scope.toUpperCase()} scope, but the profile still lists it`,
1461
- );
1525
+ const done = `uninstalled at ${scope.toUpperCase()} scope`;
1526
+ if (scope === "project" || installedNowhere) {
1527
+ if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1528
+ throw new Error(`${done}, but the profile still lists it`);
1529
+ }
1462
1530
  }
1531
+ await recordInProfileOrExplain(deps, projectPath, done);
1463
1532
  }
1464
1533
 
1465
1534
  // -- binaries ----------------------------------------------------------------
@@ -61,6 +61,11 @@ export function InputModal({
61
61
  if (typeof value === "string") onSubmit(value);
62
62
  }}
63
63
  focused
64
+ // OpenTUI paints input text white by default, which vanishes on a
65
+ // light terminal. The terminal's own foreground reads on both.
66
+ textColor={theme.colors.text}
67
+ focusedTextColor={theme.colors.text}
68
+ placeholderColor={theme.colors.muted}
64
69
  width={50}
65
70
  />
66
71
  </box>