magus-cli 7.4.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -29,6 +29,10 @@ import {
29
29
  repoRootForPath,
30
30
  } from "./plugin-resolution.js";
31
31
  import {
32
+ SettingsParseError,
33
+ SettingsShapeError,
34
+ describeJsonKind,
35
+ isJsonObject,
32
36
  readSettingsFile,
33
37
  writableList,
34
38
  writableMap,
@@ -93,6 +97,30 @@ export async function ensureClaudeDir(projectPath?: string): Promise<string> {
93
97
  * file that was actually read.
94
98
  */
95
99
 
100
+ /**
101
+ * THE FOUR WRITERS all start here: the file as it is NOW must still be one this
102
+ * module would read, or nothing is written.
103
+ *
104
+ * Every caller reads before it writes, but not necessarily just before —
105
+ * `magus install` reads the local settings, puts a prompt per missing variable
106
+ * on screen, and writes when the last one is answered. Claude Code writes that
107
+ * same file while those prompts are up (a permission rule, whenever someone
108
+ * answers "don't ask again"), and `writeJson` would replace it with a document
109
+ * built from the pre-prompt read.
110
+ *
111
+ * Checking at write time is what makes the refusal hold for every writer instead
112
+ * of only the ones that happened to re-read — the same rule `writeManifest`
113
+ * applies to `.claude/profiles.json`. The read is for the throw alone; the
114
+ * contents are about to be replaced.
115
+ *
116
+ * It closes the gap, it does not close the race: the check and the write are two
117
+ * syscalls. The window is microseconds rather than however long a human takes to
118
+ * answer a prompt.
119
+ */
120
+ async function refuseUnwritableFile(filePath: string): Promise<void> {
121
+ await readSettingsFile(filePath);
122
+ }
123
+
96
124
  /** <project>/.claude/settings.json */
97
125
  export function getSettingsPath(projectPath?: string): string {
98
126
  return path.join(getClaudeDir(projectPath), SETTINGS_FILE);
@@ -120,6 +148,7 @@ export async function writeSettings(
120
148
  settings: ClaudeSettings,
121
149
  projectPath?: string,
122
150
  ): Promise<void> {
151
+ await refuseUnwritableFile(getSettingsPath(projectPath));
123
152
  const claudeDir = await ensureClaudeDir(projectPath);
124
153
  const settingsPath = path.join(claudeDir, SETTINGS_FILE);
125
154
  await fs.writeJson(settingsPath, settings, { spaces: 2 });
@@ -137,6 +166,7 @@ export async function writeLocalSettings(
137
166
  settings: ClaudeLocalSettings,
138
167
  projectPath?: string,
139
168
  ): Promise<void> {
169
+ await refuseUnwritableFile(getLocalSettingsPath(projectPath));
140
170
  const claudeDir = await ensureClaudeDir(projectPath);
141
171
  const localPath = path.join(claudeDir, LOCAL_SETTINGS_FILE);
142
172
  await fs.writeJson(localPath, settings, { spaces: 2 });
@@ -161,6 +191,7 @@ export async function writeMcpConfig(
161
191
  projectPath?: string,
162
192
  ): Promise<void> {
163
193
  const mcpPath = getMcpConfigPath(projectPath);
194
+ await refuseUnwritableFile(mcpPath);
164
195
  await fs.writeJson(mcpPath, config, { spaces: 2 });
165
196
  }
166
197
 
@@ -171,6 +202,7 @@ export async function readGlobalSettings(): Promise<ClaudeSettings> {
171
202
  export async function writeGlobalSettings(
172
203
  settings: ClaudeSettings,
173
204
  ): Promise<void> {
205
+ await refuseUnwritableFile(getGlobalSettingsPath());
174
206
  await fs.ensureDir(getGlobalClaudeDir());
175
207
  const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
176
208
  await fs.writeJson(settingsPath, settings, { spaces: 2 });
@@ -1130,13 +1162,13 @@ export async function migrateMarketplaceRename(
1130
1162
  }
1131
1163
 
1132
1164
  // 4. known_marketplaces.json — rename old keys + physical directory cleanup
1133
- const pluginsDir = path.join(
1165
+ const marketplacesDir = path.join(
1134
1166
  os.homedir(),
1135
1167
  ".claude",
1136
1168
  "plugins",
1137
1169
  "marketplaces",
1138
1170
  );
1139
- const newDir = path.join(pluginsDir, NEW_MARKETPLACE_NAME);
1171
+ const newDir = path.join(marketplacesDir, NEW_MARKETPLACE_NAME);
1140
1172
 
1141
1173
  try {
1142
1174
  const known = await readKnownMarketplaces();
@@ -1178,7 +1210,7 @@ export async function migrateMarketplaceRename(
1178
1210
 
1179
1211
  // 4b. Rename/remove old physical directories (runs even if keys were already migrated)
1180
1212
  for (const oldName of OLD_MARKETPLACE_NAMES) {
1181
- const oldDir = path.join(pluginsDir, oldName);
1213
+ const oldDir = path.join(marketplacesDir, oldName);
1182
1214
  try {
1183
1215
  if (await fs.pathExists(oldDir)) {
1184
1216
  if (!(await fs.pathExists(newDir))) {
@@ -1217,29 +1249,32 @@ export async function migrateMarketplaceRename(
1217
1249
 
1218
1250
  // 5. installed_plugins.json — rename plugin ID keys
1219
1251
  try {
1220
- const registry = await readInstalledPluginsRegistry();
1221
1252
  let regCount = 0;
1222
- const newPlugins: typeof registry.plugins = {};
1223
- for (const [pluginId, entries] of Object.entries(registry.plugins)) {
1224
- const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
1225
- pluginId.endsWith(`@${n}`),
1226
- );
1227
- if (oldName) {
1228
- const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
1229
- const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
1230
- if (!newPlugins[newKey]) {
1231
- newPlugins[newKey] = entries;
1253
+ await updateRegistryFile((registry) => {
1254
+ const newPlugins: typeof registry.plugins = {};
1255
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
1256
+ const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
1257
+ pluginId.endsWith(`@${n}`),
1258
+ );
1259
+ if (oldName) {
1260
+ const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
1261
+ const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
1262
+ if (!newPlugins[newKey]) {
1263
+ newPlugins[newKey] = entries;
1264
+ }
1265
+ regCount++;
1266
+ } else {
1267
+ newPlugins[pluginId] = entries;
1232
1268
  }
1233
- regCount++;
1234
- } else {
1235
- newPlugins[pluginId] = entries;
1236
1269
  }
1237
- }
1238
- if (regCount > 0) {
1270
+ if (regCount === 0) return false;
1239
1271
  registry.plugins = newPlugins;
1240
- await writeInstalledPluginsRegistry(registry);
1241
- result.registryMigrated = regCount;
1242
- }
1272
+ return true;
1273
+ });
1274
+ // After the write, never inside the mutation: a refused write (a corrupt
1275
+ // file, or one another session changed) must not report a migration that
1276
+ // did not land.
1277
+ result.registryMigrated = regCount;
1243
1278
  } catch {
1244
1279
  /* skip if unreadable */
1245
1280
  }
@@ -1494,7 +1529,17 @@ export async function resolveCacheProvenance(
1494
1529
  }
1495
1530
 
1496
1531
  /**
1497
- * Read installed_plugins.json registry
1532
+ * Read installed_plugins.json registry.
1533
+ *
1534
+ * Missing reads as an empty registry — nothing is lost by starting from one.
1535
+ * A file that EXISTS and cannot be used throws, exactly as the settings readers
1536
+ * do: this is the record of what every project on the machine has installed,
1537
+ * and "unreadable" is not the same answer as "nothing installed".
1538
+ *
1539
+ * It used to answer an empty registry for both, and the registry writers below
1540
+ * then wrote that empty copy back with one plugin in it — so a registry a
1541
+ * Claude Code write had truncated came back holding a single row, and every
1542
+ * other plugin on the machine read as uninstalled.
1498
1543
  *
1499
1544
  * @param root the plugins directory to read from — see {@link pluginsDir}
1500
1545
  */
@@ -1502,20 +1547,7 @@ export async function readInstalledPluginsRegistry(
1502
1547
  root: string = pluginsDir(),
1503
1548
  ): Promise<InstalledPluginsRegistry> {
1504
1549
  const file = installedPluginsFile(root);
1505
- const raw = await readRegistryBytes(file);
1506
- const parsed = parseRegistryBytes(raw);
1507
- if (parsed) return parsed;
1508
- if (raw !== null) {
1509
- // Corrupt rather than absent — keep a copy before any caller writes over it.
1510
- try {
1511
- const backup = await registryBackupPath(file, "corrupt");
1512
- await fs.copy(file, backup);
1513
- console.warn(`Corrupted registry backed up to: ${backup}`);
1514
- } catch {
1515
- // Ignore backup errors
1516
- }
1517
- }
1518
- return { version: 2, plugins: {} };
1550
+ return requireRegistry(file, await readRegistryBytes(file));
1519
1551
  }
1520
1552
 
1521
1553
  /**
@@ -1535,18 +1567,104 @@ async function readRegistryBytes(file: string): Promise<string | null> {
1535
1567
  }
1536
1568
  }
1537
1569
 
1538
- /** Parse + structure check. Null means "not a registry", including absent. */
1539
- function parseRegistryBytes(
1570
+ /** An empty, valid registry: what an absent file reads as. */
1571
+ function emptyRegistry(): InstalledPluginsRegistry {
1572
+ return { version: 2, plugins: {} };
1573
+ }
1574
+
1575
+ /**
1576
+ * Refuse a registry document that cannot be read AT ALL — it does not parse, or
1577
+ * its top level is not a JSON object — with the same refusal every settings file
1578
+ * gets, naming the file. Absent or whitespace-only passes (and answers `null`).
1579
+ *
1580
+ * The weaker of the two checks, and deliberately so: it is what a WRITER asks
1581
+ * before renaming over a file, where the question is only "is this a document
1582
+ * anything could have read", not "does it match the shape this version of magus
1583
+ * models". {@link requireRegistry} is the stronger one, asked by every reader.
1584
+ */
1585
+ function refuseUnreadableRegistry(
1586
+ file: string,
1540
1587
  raw: string | null,
1541
- ): InstalledPluginsRegistry | null {
1542
- if (raw === null) return null;
1588
+ ): Record<string, unknown> | null {
1589
+ if (raw === null || raw.trim() === "") return null;
1590
+
1591
+ let parsed: unknown;
1543
1592
  try {
1544
- const content = JSON.parse(raw);
1545
- if (!content?.version || !content?.plugins) return null;
1546
- return content as InstalledPluginsRegistry;
1547
- } catch {
1548
- return null;
1593
+ parsed = JSON.parse(raw);
1594
+ } catch (error) {
1595
+ throw new SettingsParseError(file, error);
1596
+ }
1597
+ if (!isJsonObject(parsed)) {
1598
+ throw new SettingsShapeError(
1599
+ file,
1600
+ "is not a JSON object",
1601
+ `its top level is ${describeJsonKind(parsed)}`,
1602
+ );
1549
1603
  }
1604
+ return parsed;
1605
+ }
1606
+
1607
+ /**
1608
+ * Parse + structure check, or throw.
1609
+ *
1610
+ * THE one place a registry document is judged for READING, so no two readers
1611
+ * can disagree about whether the file on disk is usable. Absent or
1612
+ * whitespace-only reads as an empty registry; anything else that is not
1613
+ * `{version, plugins: {…}}` throws a {@link SettingsParseError} — a
1614
+ * `SettingsShapeError` when it parsed but is the wrong shape — naming the file,
1615
+ * so every read-modify-write refuses at the same point and nothing reaches a
1616
+ * write.
1617
+ */
1618
+ function requireRegistry(
1619
+ file: string,
1620
+ raw: string | null,
1621
+ ): InstalledPluginsRegistry {
1622
+ const parsed = refuseUnreadableRegistry(file, raw);
1623
+ if (parsed === null) return emptyRegistry();
1624
+
1625
+ if (!parsed.version || !isJsonObject(parsed.plugins)) {
1626
+ throw new SettingsShapeError(
1627
+ file,
1628
+ "is not a plugin registry",
1629
+ `expected a "version" and a "plugins" object, got ${
1630
+ parsed.plugins === undefined
1631
+ ? 'no "plugins" key'
1632
+ : `"plugins" as ${describeJsonKind(parsed.plugins)}`
1633
+ }`,
1634
+ );
1635
+ }
1636
+ return parsed as unknown as InstalledPluginsRegistry;
1637
+ }
1638
+
1639
+ /**
1640
+ * Read the registry, change it, write it back — the ONE read-modify-write every
1641
+ * registry writer goes through.
1642
+ *
1643
+ * Both halves of the safety live here rather than in each writer: the read
1644
+ * refuses a document we cannot parse ({@link requireRegistry}), and the write
1645
+ * carries the exact bytes that were read as a precondition, so a Claude Code
1646
+ * install landing in between is refused instead of being silently dropped by our
1647
+ * snapshot (see {@link writeInstalledPluginsRegistry}).
1648
+ *
1649
+ * `mutate` answers whether there is anything to write: `false` writes nothing
1650
+ * and leaves the file untouched.
1651
+ *
1652
+ * {@link repairStaleLoad} is the one writer that does this by hand, because it
1653
+ * takes an undo backup between the read and the write and reports its refusals
1654
+ * as a result object instead of throwing. It reads and checks through the same
1655
+ * {@link requireRegistry}.
1656
+ */
1657
+ async function updateRegistryFile(
1658
+ mutate: (registry: InstalledPluginsRegistry) => Promise<boolean> | boolean,
1659
+ root: string = pluginsDir(),
1660
+ ): Promise<void> {
1661
+ const file = installedPluginsFile(root);
1662
+ // The BYTES first, and the snapshot parsed from those same bytes: they have
1663
+ // to describe one moment for the precondition to mean anything.
1664
+ const before = await readRegistryBytes(file);
1665
+ const registry = requireRegistry(file, before);
1666
+ if (!(await mutate(registry))) return;
1667
+ await writeInstalledPluginsRegistry(registry, root, before);
1550
1668
  }
1551
1669
 
1552
1670
  /** How many `installed_plugins.json.backup.*` files are kept. */
@@ -1625,10 +1743,23 @@ export class RegistryChangedError extends Error {
1625
1743
  *
1626
1744
  * ATOMIC — temp file in the same directory, then rename. This file is read by
1627
1745
  * every Claude Code session on the machine, so a write interrupted partway
1628
- * leaves a truncated JSON document that `readInstalledPluginsRegistry` treats as
1629
- * corrupt and replaces with an EMPTY registry: every plugin on the machine
1630
- * reads as not installed. `rename(2)` within one directory cannot be observed
1631
- * half-done, so a reader sees either the old file or the new one.
1746
+ * leaves a truncated JSON document nothing can read: every plugin on the machine
1747
+ * would read as not installed. `rename(2)` within one directory cannot be
1748
+ * observed half-done, so a reader sees either the old file or the new one.
1749
+ *
1750
+ * TWO preconditions, both checked as the last act before the rename, and the
1751
+ * second is not implied by the first:
1752
+ *
1753
+ * 1. the file is the one the caller read (`expectBytes`);
1754
+ * 2. the file on disk is still a document we can read at all — it parses, and
1755
+ * into an object.
1756
+ *
1757
+ * Byte-equality does not cover (2). A caller holding the raw bytes of a corrupt
1758
+ * file passes the compare exactly, and the rename then replaces a registry
1759
+ * nobody could read with one holding only the plugin being written — every other
1760
+ * project's installs gone. The strict read in {@link requireRegistry} is the
1761
+ * first line and stops every read-modify-write; this is the second, and it holds
1762
+ * for a direct caller too, including one that passes no `expectBytes`.
1632
1763
  *
1633
1764
  * ATOMICITY IS NOT ISOLATION, and conflating the two is how this silently lost
1634
1765
  * other sessions' work. A read-modify-write rewrites the WHOLE file from the
@@ -1664,10 +1795,12 @@ export async function writeInstalledPluginsRegistry(
1664
1795
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1665
1796
  try {
1666
1797
  await fs.writeJson(tmp, registry, { spaces: 2 });
1667
- if (expectBytes !== undefined) {
1668
- const current = await readRegistryBytes(file);
1669
- if (current !== expectBytes) throw new RegistryChangedError(file);
1798
+ // One read, both preconditions — they have to judge the same moment.
1799
+ const current = await readRegistryBytes(file);
1800
+ if (expectBytes !== undefined && current !== expectBytes) {
1801
+ throw new RegistryChangedError(file);
1670
1802
  }
1803
+ refuseUnreadableRegistry(file, current);
1671
1804
  await fs.rename(tmp, file);
1672
1805
  } catch (error) {
1673
1806
  await fs.remove(tmp).catch(() => {});
@@ -1703,65 +1836,73 @@ export async function updateInstalledPluginsRegistry(
1703
1836
  projectPath?: string,
1704
1837
  ): Promise<void> {
1705
1838
  try {
1706
- const registry = await readInstalledPluginsRegistry();
1707
-
1708
- // Get marketplace from plugin ID
1709
- const parsed = parsePluginId(pluginId);
1710
- if (!parsed) {
1711
- console.warn(`Invalid plugin ID: ${pluginId}, skipping registry update`);
1712
- return;
1713
- }
1714
-
1715
- const { marketplace } = parsed;
1839
+ await updateRegistryFile(async (registry) => {
1840
+ // Get marketplace from plugin ID
1841
+ const parsed = parsePluginId(pluginId);
1842
+ if (!parsed) {
1843
+ console.warn(
1844
+ `Invalid plugin ID: ${pluginId}, skipping registry update`,
1845
+ );
1846
+ return false;
1847
+ }
1716
1848
 
1717
- // Copy plugin files from source to cache
1718
- // This ensures the cache has the latest plugin version
1719
- const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
1849
+ const { marketplace } = parsed;
1720
1850
 
1721
- const installPath = getPluginCachePath(pluginId, version, marketplace);
1722
- const now = new Date().toISOString();
1851
+ // Copy plugin files from source to cache
1852
+ // This ensures the cache has the latest plugin version
1853
+ const copiedFrom = await copyPluginToCache(
1854
+ pluginId,
1855
+ version,
1856
+ marketplace,
1857
+ );
1723
1858
 
1724
- // Initialize plugin array if it doesn't exist
1725
- if (!registry.plugins[pluginId]) {
1726
- registry.plugins[pluginId] = [];
1727
- }
1859
+ const installPath = getPluginCachePath(pluginId, version, marketplace);
1860
+ const now = new Date().toISOString();
1728
1861
 
1729
- // Find existing entry for this scope and project
1730
- const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
1731
- if (entry.scope !== scope) return false;
1732
- if (scope === "user") return true;
1733
- return entry.projectPath === projectPath;
1734
- });
1862
+ // Initialize plugin array if it doesn't exist
1863
+ if (!registry.plugins[pluginId]) {
1864
+ registry.plugins[pluginId] = [];
1865
+ }
1735
1866
 
1736
- const entry: InstalledPluginEntry = {
1737
- scope,
1738
- projectPath,
1739
- installPath,
1740
- version,
1741
- installedAt:
1742
- existingIndex >= 0
1743
- ? registry.plugins[pluginId][existingIndex].installedAt
1744
- : now,
1745
- lastUpdated: now,
1746
- gitCommitSha: await resolveCacheProvenance(
1747
- copiedFrom,
1748
- existingIndex >= 0
1749
- ? registry.plugins[pluginId][existingIndex].gitCommitSha
1750
- : undefined,
1751
- ),
1752
- };
1867
+ // Find existing entry for this scope and project
1868
+ const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
1869
+ if (entry.scope !== scope) return false;
1870
+ if (scope === "user") return true;
1871
+ return entry.projectPath === projectPath;
1872
+ });
1753
1873
 
1754
- if (existingIndex >= 0) {
1755
- // Update existing entry
1756
- registry.plugins[pluginId][existingIndex] = entry;
1757
- } else {
1758
- // Add new entry
1759
- registry.plugins[pluginId].push(entry);
1760
- }
1874
+ const entry: InstalledPluginEntry = {
1875
+ scope,
1876
+ projectPath,
1877
+ installPath,
1878
+ version,
1879
+ installedAt:
1880
+ existingIndex >= 0
1881
+ ? registry.plugins[pluginId][existingIndex].installedAt
1882
+ : now,
1883
+ lastUpdated: now,
1884
+ gitCommitSha: await resolveCacheProvenance(
1885
+ copiedFrom,
1886
+ existingIndex >= 0
1887
+ ? registry.plugins[pluginId][existingIndex].gitCommitSha
1888
+ : undefined,
1889
+ ),
1890
+ };
1761
1891
 
1762
- await writeInstalledPluginsRegistry(registry);
1892
+ if (existingIndex >= 0) {
1893
+ // Update existing entry
1894
+ registry.plugins[pluginId][existingIndex] = entry;
1895
+ } else {
1896
+ // Add new entry
1897
+ registry.plugins[pluginId].push(entry);
1898
+ }
1899
+ return true;
1900
+ });
1763
1901
  } catch (error) {
1764
- // Log warning but don't block plugin operation
1902
+ // Say what was refused and why, then let the plugin operation carry on:
1903
+ // the install itself succeeded, and the registry is Claude Code's file to
1904
+ // repair. What must not happen is writing over it, which is why the read
1905
+ // throws rather than answering an empty registry.
1765
1906
  console.warn(
1766
1907
  `Failed to update registry for ${pluginId}:`,
1767
1908
  error instanceof Error ? error.message : "Unknown error",
@@ -1778,23 +1919,24 @@ export async function removeFromInstalledPluginsRegistry(
1778
1919
  projectPath?: string,
1779
1920
  ): Promise<void> {
1780
1921
  try {
1781
- const registry = await readInstalledPluginsRegistry();
1782
-
1783
- if (!registry.plugins[pluginId]) return;
1922
+ await updateRegistryFile((registry) => {
1923
+ if (!registry.plugins[pluginId]) return false;
1924
+
1925
+ // Remove entry matching scope and projectPath
1926
+ registry.plugins[pluginId] = registry.plugins[pluginId].filter(
1927
+ (entry) => {
1928
+ if (entry.scope !== scope) return true;
1929
+ if (scope === "user") return false;
1930
+ return entry.projectPath !== projectPath;
1931
+ },
1932
+ );
1784
1933
 
1785
- // Remove entry matching scope and projectPath
1786
- registry.plugins[pluginId] = registry.plugins[pluginId].filter((entry) => {
1787
- if (entry.scope !== scope) return true;
1788
- if (scope === "user") return false;
1789
- return entry.projectPath !== projectPath;
1934
+ // Remove plugin key if no entries remain
1935
+ if (registry.plugins[pluginId].length === 0) {
1936
+ delete registry.plugins[pluginId];
1937
+ }
1938
+ return true;
1790
1939
  });
1791
-
1792
- // Remove plugin key if no entries remain
1793
- if (registry.plugins[pluginId].length === 0) {
1794
- delete registry.plugins[pluginId];
1795
- }
1796
-
1797
- await writeInstalledPluginsRegistry(registry);
1798
1940
  } catch (error) {
1799
1941
  // Log warning but don't block plugin operation
1800
1942
  console.warn(
@@ -2128,12 +2270,17 @@ export async function repairStaleLoad(
2128
2270
  // precondition the write is checked against, so they have to describe one
2129
2271
  // moment — see `writeInstalledPluginsRegistry`.
2130
2272
  const before = await readRegistryBytes(file);
2131
- const registry = parseRegistryBytes(before);
2132
- if (!registry) {
2273
+ if (before === null) return fail(`${file} does not exist`);
2274
+ let registry: InstalledPluginsRegistry;
2275
+ try {
2276
+ registry = requireRegistry(file, before);
2277
+ } catch (error) {
2278
+ // The same check every other reader makes; a repair reports it as a result
2279
+ // rather than throwing, because it is one item in a plan of many.
2133
2280
  return fail(
2134
- before === null
2135
- ? `${file} does not exist`
2136
- : `${file} is not a readable registry — refusing to write over it`,
2281
+ `${file} is not a readable registry — refusing to write over it: ${
2282
+ error instanceof Error ? error.message : String(error)
2283
+ }`,
2137
2284
  );
2138
2285
  }
2139
2286
  const rows = registry.plugins[pluginId];
@@ -2226,7 +2373,18 @@ export async function repairStaleLoad(
2226
2373
 
2227
2374
  // Re-read from DISK, not from the object just mutated: the assertion has to
2228
2375
  // judge the file the loader will read, which also proves the write landed.
2229
- const after = await readInstalledPluginsRegistry(root);
2376
+ // A refusal here is reported like every other outcome — this function answers
2377
+ // with a result, and a plan of repairs must not be ended by a throw from one.
2378
+ let after: InstalledPluginsRegistry;
2379
+ try {
2380
+ after = await readInstalledPluginsRegistry(root);
2381
+ } catch (error) {
2382
+ return fail(
2383
+ `could not re-read ${file} to confirm the repair: ${
2384
+ error instanceof Error ? error.message : String(error)
2385
+ }`,
2386
+ );
2387
+ }
2230
2388
  const loaded =
2231
2389
  pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
2232
2390
  if (loaded === target) {
@@ -141,3 +141,33 @@ export function cliToolUninstall(
141
141
  });
142
142
  }
143
143
  }
144
+
145
+ /**
146
+ * Why magus cannot remove an install made this way — or null when it can.
147
+ *
148
+ * Derived from {@link cliToolUninstall}, never from a second list of methods:
149
+ * "is there a command" and "why is there no command" have to answer about the
150
+ * same thing, or the conflict fix offers a removal it then skips. That is the
151
+ * defect this exists for — the fix used to list every install as removable,
152
+ * `continue` past the ones with no command, and report success.
153
+ *
154
+ * The text is a clause, to be read after "the <method> install stays:".
155
+ */
156
+ export function uninstallBlocker(
157
+ tool: CliTool,
158
+ method: InstallMethod,
159
+ brewFormula?: string,
160
+ ): string | null {
161
+ if (cliToolUninstall(tool, method, brewFormula) !== null) return null;
162
+ switch (method) {
163
+ case "native":
164
+ // Claude Code's native installer. It updates itself and ships no
165
+ // uninstall command; inventing one would mean deleting another
166
+ // program's files by hand.
167
+ return "its own installer ships no uninstall command";
168
+ case "unknown":
169
+ return "magus could not tell which installer put it there";
170
+ default:
171
+ return `magus has no uninstall command for a ${method} install`;
172
+ }
173
+ }
@@ -258,6 +258,94 @@ export async function removeGitignoreEntries(
258
258
  });
259
259
  }
260
260
 
261
+ /** A `.gitignore` comment that heads a block magus wrote. */
262
+ const MAGUS_BLOCK_HEADER_RE = /^#\s*magus:/;
263
+
264
+ /**
265
+ * Edit magus's own block in a `.gitignore`, and nothing else in the file.
266
+ *
267
+ * A block is a `# magus: …` comment line and the entry lines under it, up to
268
+ * the next blank line or comment. Every magus release wrote its profile lines
269
+ * that way, so that block is the only part of the file magus may take lines
270
+ * OUT of: the same pattern elsewhere is a person's, and they put it there on
271
+ * purpose.
272
+ *
273
+ * - `add`: entries not already present anywhere in the file are appended to
274
+ * the block headed exactly by `header`, which is created at the end of the
275
+ * file when there is none.
276
+ * - `remove`: entries are removed from every `# magus:` block. Lines outside
277
+ * the blocks are never touched.
278
+ *
279
+ * Under the file lock, written atomically, a no-op when nothing changes.
280
+ */
281
+ export async function updateManagedGitignoreBlock(
282
+ gitignorePath: string,
283
+ header: string,
284
+ edits: { add?: readonly string[]; remove?: readonly string[] },
285
+ ): Promise<{ added: string[]; removed: string[]; modified: boolean }> {
286
+ const add = (edits.add ?? []).map((e) => e.trim()).filter(Boolean);
287
+ const remove = new Set(
288
+ (edits.remove ?? []).map((e) => e.trim()).filter((e) => !add.includes(e)),
289
+ );
290
+ if (add.length === 0 && remove.size === 0) {
291
+ return { added: [], removed: [], modified: false };
292
+ }
293
+
294
+ return withFileLock(gitignorePath, async () => {
295
+ const content = await readFileOrEmpty(gitignorePath);
296
+ const lines = content.length > 0 ? content.split("\n") : [];
297
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
298
+
299
+ // Pass 1: drop `remove` entries inside magus blocks only.
300
+ const removed: string[] = [];
301
+ const kept: string[] = [];
302
+ let inBlock = false;
303
+ for (const line of lines) {
304
+ const trimmed = line.trim();
305
+ if (MAGUS_BLOCK_HEADER_RE.test(trimmed)) {
306
+ inBlock = true;
307
+ kept.push(line);
308
+ continue;
309
+ }
310
+ if (trimmed === "" || trimmed.startsWith("#")) inBlock = false;
311
+ if (inBlock && remove.has(trimmed)) {
312
+ removed.push(trimmed);
313
+ continue;
314
+ }
315
+ kept.push(line);
316
+ }
317
+
318
+ // Pass 2: add what the file lacks, at the end of the named block.
319
+ const present = new Set(
320
+ kept.map((l) => l.trim()).filter((l) => l && !l.startsWith("#")),
321
+ );
322
+ const toAdd = add.filter((e) => !present.has(e));
323
+ let out = kept;
324
+ if (toAdd.length > 0) {
325
+ const at = kept.findIndex((l) => l.trim() === header.trim());
326
+ if (at === -1) {
327
+ out = [...kept];
328
+ if (out.length > 0 && out[out.length - 1]?.trim() !== "") out.push("");
329
+ out.push(header, ...toAdd);
330
+ } else {
331
+ let end = at + 1;
332
+ while (end < kept.length) {
333
+ const t = kept[end]?.trim() ?? "";
334
+ if (t === "" || t.startsWith("#")) break;
335
+ end++;
336
+ }
337
+ out = [...kept.slice(0, end), ...toAdd, ...kept.slice(end)];
338
+ }
339
+ }
340
+
341
+ if (toAdd.length === 0 && removed.length === 0) {
342
+ return { added: [], removed: [], modified: false };
343
+ }
344
+ await atomicWrite(gitignorePath, `${out.join("\n")}\n`);
345
+ return { added: toAdd, removed, modified: true };
346
+ });
347
+ }
348
+
261
349
  /**
262
350
  * Check which expected entries are missing from a .gitignore file.
263
351
  *