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.
- package/README.md +9 -8
- package/package.json +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +152 -44
- package/src/cli/install.ts +92 -127
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- 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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
newPlugins[newKey]
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1539
|
-
function
|
|
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
|
-
):
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
}
|
|
1548
|
-
|
|
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
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
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
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
1725
|
-
|
|
1726
|
-
registry.plugins[pluginId] = [];
|
|
1727
|
-
}
|
|
1859
|
+
const installPath = getPluginCachePath(pluginId, version, marketplace);
|
|
1860
|
+
const now = new Date().toISOString();
|
|
1728
1861
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
return
|
|
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
|
-
|
|
2132
|
-
|
|
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
|
-
|
|
2135
|
-
?
|
|
2136
|
-
|
|
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
|
-
|
|
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
|
*
|