magus-cli 7.11.3 → 7.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
7
+ import { planReport } from "../cli/update-view.js";
8
+ import type { PluginScope } from "../services/claude-cli.js";
9
+ import { readManifest, writeManifest } from "../services/manifest.js";
10
+ import type { PluginInfo } from "../services/plugin-manager.js";
11
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
12
+ import { activateProfile } from "../services/profile-activate.js";
13
+ import { withCliBookkeeping } from "../services/profile-state.js";
14
+ import {
15
+ pluginsAnyProfileLists,
16
+ resolveProfile,
17
+ } from "../services/resolver.js";
18
+ import {
19
+ type ApplyReporter,
20
+ type PluginApplyDeps,
21
+ REAL_REMOVE_DEPS,
22
+ applyPlugins,
23
+ describeRemoved,
24
+ } from "../services/update-engine.js";
25
+ import {
26
+ type PluginUpdateItem,
27
+ type UpdatePlan,
28
+ classifyPluginRows,
29
+ planIsBehind,
30
+ planPluginUpdates,
31
+ } from "../services/update-plan.js";
32
+ import type { ProfileManifest } from "../types/index.js";
33
+
34
+ /**
35
+ * `magus update` and the Plugins screen plan a deprecated plugin that only a
36
+ * profile which is NOT active lists.
37
+ *
38
+ * The profile render writes `"<id>": false` into `.claude/settings.json` for
39
+ * every id ANY profile lists, and the Plugins screen shows that key as a
40
+ * deprecated row. The planner used to see only the active profile's pins and
41
+ * the plugins installed somewhere, so `magus update --check` answered "Nothing
42
+ * missing and nothing behind." while the row stayed, and `magus update` never
43
+ * removed it. When the active profile did list it, the removal dropped it from
44
+ * that profile only, so another profile kept it and the `false` came back on
45
+ * the next render.
46
+ *
47
+ * CONTRACT:
48
+ * - `pluginsAnyProfileLists(manifest)` (resolver.ts): every plugin id some
49
+ * profile turns on, `extends` resolved.
50
+ * - `planPluginUpdates(pins, catalog, requiredBy, listedByProfiles)` and
51
+ * `classifyPluginRows(plugins, pins, requiredBy, listedByProfiles)`: an id
52
+ * in `listedByProfiles` that is neither pinned nor installed gets a
53
+ * `remove` item with `scopes: []` when it is deprecated, and NO item
54
+ * otherwise (published, moved, still required, marketplace unread).
55
+ * - applying a `remove` drops the id from EVERY profile listing it, absorbing
56
+ * the live files first, so the user's live edits survive and the key is
57
+ * gone from settings.json afterwards.
58
+ */
59
+
60
+ const ID = "code-analysis@magus";
61
+
62
+ /** The reporter's shape: the active profile does not list it, two others do. */
63
+ const manifest = (activeListsIt = false): ProfileManifest => ({
64
+ version: 2,
65
+ profiles: {
66
+ default: {
67
+ name: "Default",
68
+ plugins: {
69
+ "dev@magus": "latest",
70
+ ...(activeListsIt ? { [ID]: "latest" } : {}),
71
+ },
72
+ },
73
+ maintainer: {
74
+ name: "Maintainer",
75
+ plugins: { "dev@magus": "latest", [ID]: "latest" },
76
+ },
77
+ lean: { name: "Lean", plugins: { [ID]: "latest" } },
78
+ minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
79
+ },
80
+ });
81
+
82
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
83
+ return {
84
+ name: over.id.split("@")[0] as string,
85
+ version: null,
86
+ description: "",
87
+ marketplace: over.id.split("@")[1] ?? "magus",
88
+ marketplaceDisplay: "",
89
+ ...over,
90
+ };
91
+ }
92
+
93
+ /** Published and installed here — what makes "magus" a marketplace we read. */
94
+ const DEV = info({
95
+ id: "dev@magus",
96
+ version: "1.0.0",
97
+ projectScope: { installed: true, version: "1.0.0" },
98
+ });
99
+ /** Published, installed nowhere. */
100
+ const SETUP = info({ id: "setup@magus", version: "2.0.0" });
101
+
102
+ /** The id's catalog entry as the screen sees it: a `false` key, an orphan. */
103
+ const ORPHAN = info({ id: ID, isOrphaned: true });
104
+
105
+ const CATALOG: PluginInfo[] = [DEV, SETUP, ORPHAN];
106
+
107
+ function itemsFor(items: PluginUpdateItem[], id: string): PluginUpdateItem[] {
108
+ return items.filter((i) => i.pluginId === id);
109
+ }
110
+
111
+ afterEach(() => setColorEnabled(null));
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // 1. The planner
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe("planner: a plugin only an inactive profile lists", () => {
118
+ // REGRESSION: `magus update --check` said "Nothing missing and nothing behind." while a deprecated plugin only other profiles list stayed a row — MTL-332
119
+ test("should plan one remove with no scopes for a deprecated id", () => {
120
+ const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
121
+ ID,
122
+ "dev@magus",
123
+ "setup@magus",
124
+ ]);
125
+
126
+ const found = itemsFor(items, ID);
127
+ expect(found).toHaveLength(1);
128
+ expect({
129
+ action: found[0]?.action,
130
+ reason: found[0]?.reason,
131
+ scopes: found[0]?.scopes,
132
+ }).toEqual({ action: "remove", reason: "deprecated", scopes: [] });
133
+ });
134
+
135
+ test("should plan a deprecated id with no catalog entry at all", () => {
136
+ const items = planPluginUpdates({ "dev@magus": "latest" }, [DEV], {}, [ID]);
137
+ expect(itemsFor(items, ID).map((i) => i.action)).toEqual(["remove"]);
138
+ });
139
+
140
+ test("should plan nothing for a PUBLISHED id only an inactive profile lists", () => {
141
+ const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
142
+ ID,
143
+ "setup@magus",
144
+ ]);
145
+ expect(itemsFor(items, "setup@magus")).toEqual([]);
146
+ });
147
+
148
+ test("should not plan a remove for one that moved to another marketplace", () => {
149
+ const moved = info({ id: "code-analysis@other", version: "3.0.0" });
150
+ const items = planPluginUpdates(
151
+ { "dev@magus": "latest" },
152
+ [...CATALOG, moved],
153
+ {},
154
+ [ID],
155
+ );
156
+ expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
157
+ [],
158
+ );
159
+ expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
160
+ [],
161
+ );
162
+ });
163
+
164
+ test("should not plan a remove for one a staying plugin still requires", () => {
165
+ const items = planPluginUpdates(
166
+ { "dev@magus": "latest" },
167
+ CATALOG,
168
+ { [ID]: ["dev@magus"] },
169
+ [ID],
170
+ );
171
+ expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
172
+ [],
173
+ );
174
+ expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
175
+ [],
176
+ );
177
+ });
178
+
179
+ test("should plan nothing when the id's marketplace was not read", () => {
180
+ const unread = "thing@unread-market";
181
+ const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
182
+ unread,
183
+ ]);
184
+ expect(itemsFor(items, unread)).toEqual([]);
185
+ });
186
+
187
+ test("should make --check behind and report it under the removed wording", () => {
188
+ setColorEnabled(false);
189
+ const plan: UpdatePlan = {
190
+ profileId: "default",
191
+ plugins: planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [ID]),
192
+ bins: [],
193
+ skills: [],
194
+ binSpecs: new Map(),
195
+ };
196
+ expect(planIsBehind(plan)).toBe(true);
197
+ const report = planReport(plan).map(stripAnsi).join("\n");
198
+ expect(report).toContain("code-analysis");
199
+ expect(describeRemoved([ID])).toBe(
200
+ "Removed code-analysis — no longer published",
201
+ );
202
+ });
203
+ });
204
+
205
+ describe("pluginsAnyProfileLists", () => {
206
+ test("should list every id some profile turns on, and none turned off", () => {
207
+ const m = manifest();
208
+ const entry = m.profiles.minimal;
209
+ if (entry) entry.plugins = { ...entry.plugins, "off@magus": false };
210
+ expect([...pluginsAnyProfileLists(m)].sort()).toEqual([
211
+ ID,
212
+ "dev@magus",
213
+ "setup@magus",
214
+ ]);
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // 2. Parity: the CLI's plan and the screen's rows
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe("parity: magus update and the Plugins screen", () => {
223
+ test("should agree on a deprecated id only an inactive profile lists", () => {
224
+ const pins = { "dev@magus": "latest" };
225
+ const listed = pluginsAnyProfileLists(manifest());
226
+ const cli = planPluginUpdates(pins, CATALOG, {}, listed);
227
+ const rows = classifyPluginRows(CATALOG, pins, {}, listed);
228
+
229
+ expect(rows.get(ID)).toEqual(itemsFor(cli, ID)[0]);
230
+ expect(rows.get(ID)?.action).toBe("remove");
231
+ expect(rows.has("setup@magus")).toBe(
232
+ itemsFor(cli, "setup@magus").length > 0,
233
+ );
234
+ expect(rows.has("setup@magus")).toBe(false);
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // 3 + 4. Applying the plan on a real project
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
243
+ const wrongScope = (scope: PluginScope) =>
244
+ new Error(
245
+ `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
246
+ );
247
+
248
+ const SILENT: ApplyReporter = {
249
+ begin: () => {},
250
+ finish: () => {},
251
+ detach: (work) => work(),
252
+ };
253
+
254
+ /** The real profile half; Claude Code's commands are doubles. */
255
+ function deps(): PluginApplyDeps {
256
+ const unused = async () => {
257
+ throw new Error("a removal must not install, update or repair anything");
258
+ };
259
+ return {
260
+ install: unused,
261
+ update: unused,
262
+ repair: unused,
263
+ readInstalled: unused,
264
+ saveInstalled: unused,
265
+ repairStaleLoad: unused,
266
+ registryRows: async () => [],
267
+ remove: {
268
+ ...REAL_REMOVE_DEPS,
269
+ uninstall: async (_id, scope) => {
270
+ throw wrongScope(scope);
271
+ },
272
+ isInstalled: async () => false,
273
+ forgetVersion: async () => {},
274
+ },
275
+ };
276
+ }
277
+
278
+ /** Both callers: `magus update` (under bookkeeping) and the Plugins screen. */
279
+ const SURFACES = [
280
+ ["magus update", true],
281
+ ["the Plugins screen", false],
282
+ ] as const;
283
+
284
+ describe("applying the plan removes it from every profile", () => {
285
+ let project: string;
286
+ let log: ReturnType<typeof spyOn>;
287
+ const settingsPath = () => join(project, ".claude", "settings.json");
288
+ const profilesPath = () => join(project, ".claude", "profiles.json");
289
+
290
+ async function setUp(activeListsIt: boolean) {
291
+ await writeManifest(manifest(activeListsIt), project);
292
+ await activateProfile(manifest(activeListsIt), "default", project, {
293
+ onDisplace: () => {},
294
+ });
295
+ }
296
+
297
+ /** The plan exactly as `magus update` makes it. */
298
+ async function plan(): Promise<PluginUpdateItem[]> {
299
+ const m = await readManifest(project);
300
+ const closure = await resolveProfile(m, "default");
301
+ return planPluginUpdates(
302
+ closure.plugins,
303
+ CATALOG,
304
+ {},
305
+ pluginsAnyProfileLists(m),
306
+ ).filter((i) => i.action === "remove");
307
+ }
308
+
309
+ async function apply(items: PluginUpdateItem[], bookkept: boolean) {
310
+ const run = () =>
311
+ applyPlugins(items, project, deps(), SILENT, { profile: "default" });
312
+ return bookkept ? withCliBookkeeping(project, run) : run();
313
+ }
314
+
315
+ beforeEach(async () => {
316
+ project = await mkdtemp(join(tmpdir(), "update-deprecated-others-"));
317
+ log = spyOn(console, "log").mockImplementation(() => {});
318
+ });
319
+ afterEach(async () => {
320
+ log.mockRestore();
321
+ await rm(project, { recursive: true, force: true });
322
+ });
323
+
324
+ for (const [surface, bookkept] of SURFACES) {
325
+ // REGRESSION: `magus update` never removed a deprecated plugin only inactive profiles list; its `false` key stayed in settings.json — MTL-332
326
+ test(`${surface}: no profile lists it, untouched profiles are byte-equal, no settings key`, async () => {
327
+ await setUp(false);
328
+ // Seeded red: the render wrote the key that makes the row.
329
+ expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(
330
+ false,
331
+ );
332
+ const before = (await fs.readJson(profilesPath())).profiles;
333
+
334
+ const items = await plan();
335
+ expect(items.map((i) => i.pluginId)).toEqual([ID]);
336
+ const result = await apply(items, bookkept);
337
+
338
+ expect(result.failed).toEqual([]);
339
+ expect(result.removed).toEqual([ID]);
340
+ const after = (await readManifest(project)).profiles;
341
+ for (const id of Object.keys(after)) {
342
+ expect(after[id]?.plugins?.[ID]).toBeUndefined();
343
+ }
344
+ expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
345
+ const raw = (await fs.readJson(profilesPath())).profiles;
346
+ expect(raw.default).toEqual(before.default);
347
+ expect(raw.minimal).toEqual(before.minimal);
348
+
349
+ const live = (await fs.readJson(settingsPath())).enabledPlugins;
350
+ expect(Object.hasOwn(live, ID)).toBe(false);
351
+ expect(live["dev@magus"]).toBe(true);
352
+
353
+ // It does not come back on the next entry point.
354
+ await reconcileActiveProfile(project);
355
+ expect(
356
+ Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
357
+ ).toBe(false);
358
+ expect(await plan()).toEqual([]);
359
+ });
360
+
361
+ // REGRESSION: removing a deprecated plugin must not lose live edits made after the profile was activated — MTL-332
362
+ test(`${surface}: a live edit survives the removal`, async () => {
363
+ await setUp(false);
364
+ const settings = await fs.readJson(settingsPath());
365
+ settings.permissions = { allow: ["Bash(ls:*)"] };
366
+ settings.enabledPlugins["other@magus"] = true;
367
+ await fs.writeJson(settingsPath(), settings, { spaces: 2 });
368
+
369
+ const result = await apply(await plan(), bookkept);
370
+
371
+ expect(result.failed).toEqual([]);
372
+ const live = await fs.readJson(settingsPath());
373
+ expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
374
+ expect(live.enabledPlugins["other@magus"]).toBe(true);
375
+ expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
376
+ for (const entry of Object.values(
377
+ (await readManifest(project)).profiles,
378
+ )) {
379
+ expect(entry.plugins?.[ID]).toBeUndefined();
380
+ }
381
+ });
382
+
383
+ // REGRESSION: removing a deprecated plugin the active profile lists dropped it from that profile only, so another profile kept it — MTL-332
384
+ test(`${surface}: the active profile and another both list it, neither does afterwards`, async () => {
385
+ await setUp(true);
386
+ const items = await plan();
387
+ expect(items.map((i) => i.pluginId)).toEqual([ID]);
388
+
389
+ const result = await apply(items, bookkept);
390
+
391
+ expect(result.failed).toEqual([]);
392
+ const after = (await readManifest(project)).profiles;
393
+ expect(after.default?.plugins?.[ID]).toBeUndefined();
394
+ expect(after.maintainer?.plugins?.[ID]).toBeUndefined();
395
+ expect(after.lean?.plugins?.[ID]).toBeUndefined();
396
+ expect(
397
+ Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
398
+ ).toBe(false);
399
+ });
400
+ }
401
+ });
@@ -51,7 +51,6 @@ import { badge, bold, dim } from "./ansi.js";
51
51
  import { askYesNo, isPromptAborted, promptValue } from "./prompt.js";
52
52
  import { selectOption } from "./select.js";
53
53
  import {
54
- type ScopeName,
55
54
  fail as failLine,
56
55
  field,
57
56
  info as infoLine,
@@ -285,11 +284,10 @@ function printAdoptionPlan(result: AdoptResult): void {
285
284
  `${bold(result.profileId)} ${badge("NEW", brand.success)}`,
286
285
  ),
287
286
  );
288
- const scope: ScopeName = result.source === "user" ? "user" : "project";
289
287
  console.log(
290
288
  field(
291
289
  "plugins",
292
- `${bold(String(result.counts.plugins))} ${scopeBadge(scope)}`,
290
+ `${bold(String(result.counts.plugins))} ${scopeBadge("project")}`,
293
291
  ),
294
292
  );
295
293
  printList(Object.keys(result.entry.plugins ?? {}).map(pluginLabel));
@@ -300,20 +298,7 @@ function printAdoptionPlan(result: AdoptResult): void {
300
298
  }
301
299
  console.log("");
302
300
 
303
- const warned =
304
- result.source === "user" ||
305
- result.skipped.length > 0 ||
306
- result.machinePaths.length > 0;
307
- if (result.source === "user") {
308
- // Never let this be invisible: the profile is meant to be committed, and
309
- // these are one person's global set, not anything this project chose.
310
- console.log(warnLine("This project has no plugins of its own"));
311
- console.log(
312
- noteLine(
313
- "These are your USER plugins, on in every project. Check them before committing.",
314
- ),
315
- );
316
- }
301
+ const warned = result.skipped.length > 0 || result.machinePaths.length > 0;
317
302
  for (const id of result.skipped) {
318
303
  console.log(
319
304
  warnLine(`${id} can't be shared — it isn't from a marketplace`),
@@ -424,9 +409,8 @@ export async function ensureManifest(
424
409
  // `--yes` alone is NOT consent to author this file. It means "do not ask me
425
410
  // about the thing I invoked", and someone running `magus update --yes` in
426
411
  // CI invoked an update, not the creation of their team's committed contract —
427
- // which this would otherwise write unreviewed, possibly from the machine's
428
- // user-scope plugin set. Creating it needs either a human at the prompt or
429
- // the command whose whole purpose is to create it.
412
+ // which this would otherwise write unreviewed. Creating it needs either a
413
+ // human at the prompt or the command whose whole purpose is to create it.
430
414
  if (opts.allowAdopt) {
431
415
  // fall through to the write
432
416
  } else if (!process.stdin.isTTY) {
package/src/cli/update.ts CHANGED
@@ -63,7 +63,11 @@ import {
63
63
  switchHint,
64
64
  withCliBookkeeping,
65
65
  } from "../services/profile-state.js";
66
- import { requirersHere, resolveProfile } from "../services/resolver.js";
66
+ import {
67
+ pluginsAnyProfileLists,
68
+ requirersHere,
69
+ resolveProfile,
70
+ } from "../services/resolver.js";
67
71
  import { getInstalledSkillNames } from "../services/skills-manager.js";
68
72
  import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
69
73
  import { managedBins } from "../services/toolchain.js";
@@ -503,7 +507,15 @@ async function updateProfile(
503
507
  );
504
508
  const plan: UpdatePlan = {
505
509
  profileId: targetId,
506
- plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
510
+ // Every profile's plugins, not only this one's: a deprecated plugin a
511
+ // profile that is not active lists is still a row the render keeps, and
512
+ // is planned for removal.
513
+ plugins: planPluginUpdates(
514
+ closure.plugins,
515
+ catalog,
516
+ requiredBy,
517
+ pluginsAnyProfileLists(current),
518
+ ),
507
519
  bins: planBinUpdates(tools, toolFacts),
508
520
  skills: planSkillUpdates(skillRefs, installedSkills),
509
521
  binSpecs: new Map(tools.map((b) => [b.name, b])),
@@ -21,7 +21,7 @@ import type {
21
21
  } from "../types/index.js";
22
22
  import { readManifest, validateManifest } from "./manifest.js";
23
23
  import { activeProfile } from "./profile-live.js";
24
- import { resolveProfile } from "./resolver.js";
24
+ import { pluginsAnyProfileLists, resolveProfile } from "./resolver.js";
25
25
 
26
26
  /**
27
27
  * The profile to use: the explicit one, else the active one, else the only one.
@@ -45,6 +45,12 @@ export function selectProfileId(
45
45
  export interface ActiveClosure {
46
46
  profileId: string;
47
47
  closure: ResolvedClosure;
48
+ /**
49
+ * Every plugin id ANY profile in the manifest lists
50
+ * (`pluginsAnyProfileLists`) — the planner's `listedByProfiles`, the same
51
+ * set `magus update` passes it.
52
+ */
53
+ listedByProfiles: string[];
48
54
  }
49
55
 
50
56
  export interface ActiveClosureDeps {
@@ -110,7 +116,11 @@ export async function loadActiveClosure(
110
116
  profileId,
111
117
  opts.resolveBins === false ? { binResolver: NO_BINS } : {},
112
118
  );
113
- return { profileId, closure };
119
+ return {
120
+ profileId,
121
+ closure,
122
+ listedByProfiles: pluginsAnyProfileLists(manifest),
123
+ };
114
124
  } catch {
115
125
  return null;
116
126
  }
@@ -271,10 +271,21 @@ export async function uninstallPluginViaCli(
271
271
  /**
272
272
  * Is `error` the CLI's answer that the plugin is not installed at the scope it
273
273
  * was asked about — the one failure of an uninstall that means "nothing to do".
274
+ *
275
+ * Claude Code has two wordings for it. With no row anywhere: `Plugin "<id>" is
276
+ * not installed in <scope> scope`. With rows only at another scope or in other
277
+ * folders (measured, Claude Code 2.1.312): `Plugin "<id>" is installed in
278
+ * <other> scope, not <scope>. Use --scope <other> to uninstall.` Both mean the
279
+ * asked scope holds nothing here; anything else is a real failure.
274
280
  */
275
281
  export function isNotInstalledAnswer(error: unknown): boolean {
276
282
  const message = error instanceof Error ? error.message : String(error);
277
- return /not installed in (user|project|local) scope/i.test(message);
283
+ return (
284
+ /not installed in (user|project|local) scope/i.test(message) ||
285
+ /is installed in (user|project|local) scope, not (user|project|local)\b/i.test(
286
+ message,
287
+ )
288
+ );
278
289
  }
279
290
 
280
291
  /**
@@ -20,7 +20,6 @@ import type {
20
20
  } from "../types/index.js";
21
21
  import {
22
22
  getEnabledPlugins,
23
- getGlobalEnabledPlugins,
24
23
  readMcpConfig,
25
24
  readSettings,
26
25
  } from "./claude-settings.js";
@@ -53,7 +52,7 @@ export function freeProfileId(manifest: ProfileManifest): string {
53
52
  }
54
53
 
55
54
  export interface AdoptInput {
56
- /** Union of enabled plugins across every scope: id -> enabled. */
55
+ /** The project's own enabled plugins (PROJECT scope): id -> enabled. */
57
56
  enabledPlugins: Record<string, boolean>;
58
57
  /** Project `.claude/settings.json`, verbatim. */
59
58
  settings: Record<string, unknown>;
@@ -144,45 +143,33 @@ export interface AdoptResult {
144
143
  profileId: string;
145
144
  entry: ProfileManifestEntry;
146
145
  counts: { plugins: number; mcpServers: number; settingsKeys: number };
147
- /** Which settings file the plugin list came from. */
148
- source: AdoptSource;
149
146
  /** Enabled plugin ids skipped because they name no marketplace. */
150
147
  skipped: string[];
151
148
  /** Carried-over settings values naming an absolute path under $HOME. */
152
149
  machinePaths: string[];
153
150
  }
154
151
 
155
- /** Which settings file the adopted plugin list came from. */
156
- export type AdoptSource = "project" | "user";
157
-
158
152
  /**
159
- * Find the plugins to adopt, preferring PROJECT scope.
153
+ * Find the plugins to adopt: the ones THIS project turns on, at PROJECT scope.
160
154
  *
161
155
  * The generated manifest is meant to be committed, so its plugin list must
162
156
  * describe the repo, not the machine. `.claude/settings.json` is exactly that:
163
157
  * the repo's own committed choice. User scope (`~/.claude/settings.json`) is
164
158
  * one developer's global set and local scope is gitignored personal state —
165
- * adopting either silently would publish a personal setup as the team's
166
- * contract.
159
+ * adopting either would publish a personal setup as the team's contract.
167
160
  *
168
- * User scope is still offered as a fallback, because a developer who enables
169
- * everything globally would otherwise have nothing to adopt. The caller is told
170
- * which source was used and says so, so the choice is never invisible.
161
+ * USER plugins are already on in every project, so they stay USER. Copying
162
+ * them into the profile would render them into the project's settings too,
163
+ * and they would then show as installed at PROJECT as well. A project that
164
+ * turns nothing on adopts an empty profile, which changes nothing it runs.
171
165
  */
172
166
  export async function collectEnabledPlugins(
173
167
  projectPath: string,
174
- ): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
175
- // No catch on either read. A project settings file that does not parse is
176
- // not "no project plugins": falling through to user scope there would adopt
177
- // one developer's global set as the team's contract, the exact thing the
178
- // project-first order exists to prevent. The SettingsParseError names the
179
- // file, and adoption stops.
180
- const project = await getEnabledPlugins(projectPath);
181
- if (Object.values(project).some(Boolean)) {
182
- return { plugins: project, source: "project" };
183
- }
184
- const user = await getGlobalEnabledPlugins();
185
- return { plugins: user, source: "user" };
168
+ ): Promise<Record<string, boolean>> {
169
+ // No catch. A project settings file that does not parse is not "no project
170
+ // plugins": adopting an empty profile there would silently drop what the
171
+ // repo chose. The SettingsParseError names the file, and adoption stops.
172
+ return getEnabledPlugins(projectPath);
186
173
  }
187
174
 
188
175
  /**
@@ -206,8 +193,7 @@ export async function adoptCurrentProject(
206
193
  /** Profile id to write. Defaults to the adopted-profile id. */
207
194
  id: string = ADOPTED_PROFILE_ID,
208
195
  ): Promise<AdoptResult> {
209
- const { plugins: enabledPlugins, source } =
210
- await collectEnabledPlugins(projectPath);
196
+ const enabledPlugins = await collectEnabledPlugins(projectPath);
211
197
  const settings = (await readSettings(projectPath)) as unknown as Record<
212
198
  string,
213
199
  unknown
@@ -250,7 +236,6 @@ export async function adoptCurrentProject(
250
236
  manifest,
251
237
  profileId: id,
252
238
  entry,
253
- source,
254
239
  counts: {
255
240
  plugins: Object.keys(entry.plugins ?? {}).length,
256
241
  mcpServers: Object.keys(entry.mcpServers ?? {}).length,
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
- import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
29
+ import type { ProfileManifest } from "../types/index.js";
30
30
  import { selectProfileId } from "./active-closure.js";
31
31
  import { updateManagedGitignoreBlock } from "./conventions-manager.js";
32
32
  import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
@@ -74,7 +74,7 @@ import {
74
74
  skillFolder,
75
75
  skillIgnoreLine,
76
76
  } from "./profile-skills.js";
77
- import { resolveExtends } from "./resolver.js";
77
+ import { profileLists } from "./resolver.js";
78
78
  import type { SettingsParseError } from "./settings-file.js";
79
79
 
80
80
  export type ActiveProfileCheck =
@@ -674,16 +674,6 @@ export async function withCliBookkeeping<T>(
674
674
  }
675
675
  }
676
676
 
677
- /** Whether a profile entry, `extends` included, turns the plugin on. */
678
- function profileLists(
679
- entry: ProfileManifestEntry | undefined,
680
- pluginId: string,
681
- ): boolean {
682
- if (!entry) return false;
683
- const value = resolveExtends(entry).plugins?.[pluginId];
684
- return value !== undefined && value !== false;
685
- }
686
-
687
677
  /**
688
678
  * Stop a profile listing `pluginId`, so its next activation does not turn the
689
679
  * plugin back on. Answers whether the profile no longer lists it — read back