akm-cli 0.10.261018-beta → 0.10.261020-beta
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/CHANGELOG.md +25 -0
- package/dist/commands/health/plugin-staleness.js +126 -112
- package/dist/commands/sources/plugin-channels.js +147 -0
- package/dist/commands/sources/plugin-upgrade.js +341 -59
- package/dist/commands/sources/self-update.js +6 -3
- package/dist/commands/sources/sources-cli.js +15 -2
- package/dist/indexer/passes/metadata.js +8 -6
- package/dist/output/text/command-format.js +10 -3
- package/dist/scripts/akm-migrate-node.js +13 -7
- package/dist/scripts/akm-migrate.js +13 -7
- package/docs/architecture/internals/health-advisories.md +2 -2
- package/docs/architecture/testing/testing-workflow.md +13 -7
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/{0.10.0.md → 0.10.261019.md} +5 -2
- package/docs/migration/release-notes/0.10.261020.md +22 -0
- package/docs/migration/release-notes/README.md +2 -1
- package/docs/migration/v0.9-to-v0.10.md +2 -2
- package/docs/reference/cli.md +37 -9
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261020] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `akm upgrade --channel latest|next` switches the CLI and installed Claude,
|
|
14
|
+
Codex and OpenCode plugins together, preserving unrelated native settings and
|
|
15
|
+
writing backups. `--check` shows channel mismatches; an older target requires
|
|
16
|
+
`--allow-downgrade` (#1285).
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Claude plugin health uses native installation, scope and enabled metadata
|
|
21
|
+
instead of choosing the highest cached version. It reads `AKM_MIN_VERSION`
|
|
22
|
+
as well as the legacy range, and reports uncertain selections as unknown (#1286).
|
|
23
|
+
- Repeated fenced identifiers contribute once per document to search content,
|
|
24
|
+
reducing repetition bias while retaining distinct code names. Existing
|
|
25
|
+
content projections and embedding inputs refresh on the next index pass (#1284).
|
|
26
|
+
|
|
27
|
+
## [0.10.261019] - 2026-10-29
|
|
28
|
+
|
|
29
|
+
The first stable release of the 0.10 line, published to npm `latest`. Its
|
|
30
|
+
changes are in the 0.10 sections below; the upgrade from 0.9 is in
|
|
31
|
+
[the 0.9 to 0.10 migration guide](docs/migration/v0.9-to-v0.10.md) and
|
|
32
|
+
`akm help migrate 0.10.261019`.
|
|
33
|
+
|
|
9
34
|
## [0.10.261018] - 2026-10-29
|
|
10
35
|
|
|
11
36
|
### Fixed
|
|
@@ -2,51 +2,11 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
10
|
-
* three days stale relative to the fix, and its own `AKM_VERSION_RANGE` gate
|
|
11
|
-
* had nothing to do with it — the plugin was simply running old code. Every
|
|
12
|
-
* fact needed to reach that conclusion in one step was already on disk:
|
|
13
|
-
*
|
|
14
|
-
* - installed plugin version: `~/.claude/plugins/cache/<marketplace>/akm/<version>/.claude-plugin/plugin.json`
|
|
15
|
-
* - the plugin's own akm-cli compatibility contract: `<pluginDir>/shared/akm-version.ts`'s `AKM_VERSION_RANGE`
|
|
16
|
-
* - the running CLI's version: `../../version.ts`'s `pkgVersion`
|
|
17
|
-
*
|
|
18
|
-
* Nothing correlated them, so a stale plugin was indistinguishable from a
|
|
19
|
-
* broken CLI. This module closes that gap with three checks, one per
|
|
20
|
-
* detected plugin:
|
|
21
|
-
*
|
|
22
|
-
* 1. report the installed version;
|
|
23
|
-
* 2. compare it against the newest tag published to the plugin's git
|
|
24
|
-
* remote, and warn (naming the update command) when behind;
|
|
25
|
-
* 3. the sharp one — check whether the *installed plugin's* declared
|
|
26
|
-
* `AKM_VERSION_RANGE` admits the *running* CLI version. When it does
|
|
27
|
-
* not, the plugin has silently disabled itself (both surfaces log
|
|
28
|
-
* `version_out_of_range` / `akm_version_mismatch` and degrade quietly)
|
|
29
|
-
* and there was previously no way to know that from the CLI side.
|
|
30
|
-
*
|
|
31
|
-
* Read-only: this never fetches, writes, or mutates the plugin cache or
|
|
32
|
-
* marketplace clone. Check 2 is one of two deliberate exceptions to "`akm
|
|
33
|
-
* health` makes no network call" (see `./health-advisories.md`; the other is
|
|
34
|
-
* the `cli-version` advisory in `./version-drift.ts`, #950): a plugin's
|
|
35
|
-
* local marketplace clone is not proof of what is newest upstream — the
|
|
36
|
-
* incident above involved a clone that hadn't seen the fix's tag at all — so
|
|
37
|
-
* the only way to ever detect drift is to ask the remote what tags exist.
|
|
38
|
-
* That query is a `git ls-remote --symref` (lists refs; fetches nothing,
|
|
39
|
-
* writes nothing) with a short timeout, and any failure (offline, no
|
|
40
|
-
* remote, timeout) degrades to "installed version reported, no staleness
|
|
41
|
-
* claim" rather than a false positive or a hang. Like `cli-version`, this
|
|
42
|
-
* check is gated behind `--probe`/`--no-probe`: with probing off the remote is
|
|
43
|
-
* not asked, the row reports `unknown` ("not probed") and only the local range
|
|
44
|
-
* check (3) runs.
|
|
45
|
-
*
|
|
46
|
-
* Every collector here is best-effort and silent on missing/unreadable
|
|
47
|
-
* input: no Claude plugin installed, no marketplace clone, an unreadable
|
|
48
|
-
* manifest, or a malformed version range must never crash `akm health` and
|
|
49
|
-
* must never produce a false "stale" or "inactive" warning.
|
|
5
|
+
* Report the installations Claude's native listing selects, not the versions
|
|
6
|
+
* left in its cache. The listing owns scope and merged enabled settings; it
|
|
7
|
+
* cannot prove what an already-running session has loaded. Ambiguous scoped
|
|
8
|
+
* installations and unavailable compatibility contracts are reported as unknown.
|
|
9
|
+
* Remote release checks remain optional under --no-probe.
|
|
50
10
|
*/
|
|
51
11
|
import { spawnSync } from "node:child_process";
|
|
52
12
|
import fs from "node:fs";
|
|
@@ -64,7 +24,9 @@ import { openCodeConfigSpec } from "../sources/plugin-upgrade.js";
|
|
|
64
24
|
* `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
|
|
65
25
|
*/
|
|
66
26
|
function claudePluginsDir() {
|
|
67
|
-
return process.env.AKM_CLAUDE_PLUGINS_DIR ??
|
|
27
|
+
return (process.env.AKM_CLAUDE_PLUGINS_DIR ??
|
|
28
|
+
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ??
|
|
29
|
+
path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "plugins"));
|
|
68
30
|
}
|
|
69
31
|
/**
|
|
70
32
|
* Root of OpenCode's package cache, same override pattern as
|
|
@@ -159,74 +121,90 @@ function buildOpencodeBundledAdvisory(plugin, bundled, cliVersion) {
|
|
|
159
121
|
evidence: bundled.spec ? { bundledVersion, cliVersion, spec: bundled.spec } : { bundledVersion, cliVersion },
|
|
160
122
|
};
|
|
161
123
|
}
|
|
162
|
-
/**
|
|
163
|
-
* Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
|
|
164
|
-
* `akm` plugin cache entry, picking the highest cached version per
|
|
165
|
-
* marketplace when more than one is present. Returns `[]` (never throws)
|
|
166
|
-
* when the cache directory is absent, empty, or unreadable — that is the
|
|
167
|
-
* ordinary "no Claude plugin installed" case, not an error.
|
|
168
|
-
*/
|
|
169
124
|
function detectInstalledPlugins(pluginsRoot) {
|
|
170
|
-
|
|
171
|
-
|
|
125
|
+
if (!fs.existsSync(path.join(pluginsRoot, "installed_plugins.json")) &&
|
|
126
|
+
!fs.existsSync(path.join(pluginsRoot, "cache")))
|
|
127
|
+
return [];
|
|
172
128
|
try {
|
|
173
|
-
|
|
129
|
+
const listed = spawnSync("claude", ["plugin", "list", "--json"], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
timeout: 30_000,
|
|
132
|
+
env: {
|
|
133
|
+
...process.env,
|
|
134
|
+
...(process.env.AKM_CLAUDE_PLUGINS_DIR
|
|
135
|
+
? { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR ?? path.dirname(pluginsRoot) }
|
|
136
|
+
: {}),
|
|
137
|
+
CLAUDE_CODE_PLUGIN_CACHE_DIR: pluginsRoot,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (listed.status !== 0)
|
|
141
|
+
return undefined;
|
|
142
|
+
const rows = JSON.parse(listed.stdout);
|
|
143
|
+
if (!Array.isArray(rows))
|
|
144
|
+
return undefined;
|
|
145
|
+
const detected = rows.flatMap((row) => {
|
|
146
|
+
if (typeof row?.id !== "string" || !row.id.startsWith("akm@"))
|
|
147
|
+
return [];
|
|
148
|
+
const applicable = typeof row.projectPath !== "string" ||
|
|
149
|
+
path.resolve(process.cwd()) === path.resolve(row.projectPath) ||
|
|
150
|
+
path.resolve(process.cwd()).startsWith(`${path.resolve(row.projectPath)}${path.sep}`);
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
harness: "claude",
|
|
154
|
+
marketplace: row.id.slice(4),
|
|
155
|
+
pluginName: "akm",
|
|
156
|
+
version: typeof row.folderVersion === "string"
|
|
157
|
+
? row.folderVersion
|
|
158
|
+
: typeof row.version === "string"
|
|
159
|
+
? row.version
|
|
160
|
+
: "unknown",
|
|
161
|
+
pluginDir: typeof row.readFromFolder === "string"
|
|
162
|
+
? row.readFromFolder
|
|
163
|
+
: typeof row.installPath === "string"
|
|
164
|
+
? row.installPath
|
|
165
|
+
: "",
|
|
166
|
+
applicable,
|
|
167
|
+
selectionKnown: false,
|
|
168
|
+
scope: typeof row.scope === "string" ? row.scope : "unknown",
|
|
169
|
+
enabled: typeof row.enabled === "boolean" ? row.enabled : undefined,
|
|
170
|
+
...(typeof row.projectPath === "string" ? { projectPath: row.projectPath } : {}),
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
});
|
|
174
|
+
for (const plugin of detected) {
|
|
175
|
+
const applicable = detected.filter((p) => p.marketplace === plugin.marketplace && p.applicable);
|
|
176
|
+
plugin.selectionKnown =
|
|
177
|
+
plugin.applicable &&
|
|
178
|
+
isExactSemver(plugin.version) &&
|
|
179
|
+
plugin.scope !== "unknown" &&
|
|
180
|
+
plugin.pluginDir !== "" &&
|
|
181
|
+
new Set(applicable.map((p) => `${p.version}:${p.pluginDir}`)).size === 1;
|
|
182
|
+
}
|
|
183
|
+
return detected;
|
|
174
184
|
}
|
|
175
185
|
catch {
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
const detected = [];
|
|
179
|
-
for (const marketplace of marketplaces) {
|
|
180
|
-
const pluginDir = path.join(cacheDir, marketplace, "akm");
|
|
181
|
-
let versions;
|
|
182
|
-
try {
|
|
183
|
-
versions = fs.readdirSync(pluginDir).filter(isExactSemver);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (versions.length === 0)
|
|
189
|
-
continue;
|
|
190
|
-
const latest = highestVersion(versions);
|
|
191
|
-
if (!latest)
|
|
192
|
-
continue;
|
|
193
|
-
const versionDir = path.join(pluginDir, latest);
|
|
194
|
-
const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
|
|
195
|
-
try {
|
|
196
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
197
|
-
if (typeof manifest.version !== "string")
|
|
198
|
-
continue;
|
|
199
|
-
detected.push({
|
|
200
|
-
harness: "claude",
|
|
201
|
-
marketplace,
|
|
202
|
-
pluginName: "akm",
|
|
203
|
-
version: manifest.version,
|
|
204
|
-
pluginDir: versionDir,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// Unreadable/malformed manifest — skip this entry rather than crash.
|
|
209
|
-
}
|
|
186
|
+
return undefined;
|
|
210
187
|
}
|
|
211
|
-
return detected;
|
|
212
188
|
}
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
* `shared/akm-version.ts`. Returns `undefined` when the file is missing,
|
|
216
|
-
* unreadable, or does not contain the expected declaration — callers must
|
|
217
|
-
* treat that as "compatibility unknown", never as a mismatch.
|
|
218
|
-
*/
|
|
219
|
-
function readVersionRange(pluginDir) {
|
|
220
|
-
const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
|
|
189
|
+
/** Read the contract, never execute plugin source. A minimum admits later prereleases too. */
|
|
190
|
+
function readCompatibility(pluginDir, cliVersion) {
|
|
221
191
|
let text;
|
|
222
192
|
try {
|
|
223
|
-
text = fs.readFileSync(
|
|
193
|
+
text = fs.readFileSync(path.join(pluginDir, "shared", "akm-version.ts"), "utf8");
|
|
224
194
|
}
|
|
225
195
|
catch {
|
|
226
|
-
return
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
const minimum = text.match(/export\s+const\s+AKM_MIN_VERSION\s*=\s*["']([^"']+)["']/)?.[1];
|
|
199
|
+
if (minimum !== undefined) {
|
|
200
|
+
if (!isExactSemver(minimum) || !isExactSemver(cliVersion))
|
|
201
|
+
return {};
|
|
202
|
+
return { versionRange: `>=${minimum}`, admitted: highestVersion([minimum, cliVersion]) === cliVersion };
|
|
227
203
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
204
|
+
const range = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/)?.[1];
|
|
205
|
+
if (!range || !isSemverRange(range) || !isExactSemver(cliVersion))
|
|
206
|
+
return {};
|
|
207
|
+
return { versionRange: range, admitted: satisfiesRange(cliVersion, range) };
|
|
230
208
|
}
|
|
231
209
|
const LS_REMOTE_TIMEOUT_MS = 5_000;
|
|
232
210
|
/**
|
|
@@ -311,7 +289,17 @@ export function collectPluginStalenessAdvisories(options) {
|
|
|
311
289
|
const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
|
|
312
290
|
const plugins = detectInstalledPlugins(pluginsRoot);
|
|
313
291
|
const probe = options.probe ?? true;
|
|
314
|
-
const results = plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
|
|
292
|
+
const results = (plugins ?? []).map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
|
|
293
|
+
if (plugins === undefined && fs.existsSync(pluginsRoot)) {
|
|
294
|
+
results.push({
|
|
295
|
+
name: "plugin-version",
|
|
296
|
+
kind: "deterministic",
|
|
297
|
+
status: "unknown",
|
|
298
|
+
confidence: "high",
|
|
299
|
+
message: "Claude plugin installation and enabled state could not be resolved with `claude plugin list --json`; cached versions do not establish the selected installation.",
|
|
300
|
+
evidence: { harness: "claude", installedVersion: null, enabled: null, admitted: null },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
315
303
|
const opencodeCacheRoot = options.opencodeCacheRoot ?? defaultOpencodeCacheRoot();
|
|
316
304
|
for (const plugin of OPENCODE_PLUGIN_LOCATIONS) {
|
|
317
305
|
for (const bundled of findBundledAkm(plugin, opencodeCacheRoot)) {
|
|
@@ -326,7 +314,9 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
326
314
|
const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
|
|
327
315
|
let availableVersion;
|
|
328
316
|
try {
|
|
329
|
-
const tags = probe && fs.existsSync(marketplaceDir)
|
|
317
|
+
const tags = probe && plugin.selectionKnown && plugin.enabled !== false && fs.existsSync(marketplaceDir)
|
|
318
|
+
? listRemoteTags(marketplaceDir)
|
|
319
|
+
: undefined;
|
|
330
320
|
availableVersion = tags && tags.length > 0 ? highestVersion(tags) : undefined;
|
|
331
321
|
}
|
|
332
322
|
catch {
|
|
@@ -336,10 +326,20 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
336
326
|
plugin.version !== availableVersion &&
|
|
337
327
|
highestVersion([plugin.version, availableVersion]) === availableVersion;
|
|
338
328
|
// Point 3: does the plugin's own declared range admit the running CLI?
|
|
339
|
-
const versionRange =
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
329
|
+
const { versionRange, admitted } = readCompatibility(plugin.pluginDir, cliVersion);
|
|
330
|
+
const messageParts = [
|
|
331
|
+
`${pluginRef}: installed ${plugin.version} (${plugin.scope}${plugin.projectPath ? `: ${plugin.projectPath}` : ""})`,
|
|
332
|
+
];
|
|
333
|
+
if (!plugin.applicable)
|
|
334
|
+
messageParts.push("installed for another project; not selected in this directory");
|
|
335
|
+
else if (!plugin.selectionKnown)
|
|
336
|
+
messageParts.push("effective selection unknown across native installations");
|
|
337
|
+
if (plugin.enabled === false)
|
|
338
|
+
messageParts.push("disabled in Claude settings");
|
|
339
|
+
else if (plugin.enabled === undefined)
|
|
340
|
+
messageParts.push("enabled state unknown");
|
|
341
|
+
if (admitted === undefined)
|
|
342
|
+
messageParts.push("CLI compatibility unknown");
|
|
343
343
|
if (!probe)
|
|
344
344
|
messageParts.push("newest tag was not probed");
|
|
345
345
|
if (availableVersion !== undefined) {
|
|
@@ -347,15 +347,23 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
347
347
|
}
|
|
348
348
|
if (stale)
|
|
349
349
|
messageParts.push(`-> claude plugin update ${pluginRef}`);
|
|
350
|
-
if (
|
|
351
|
-
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED
|
|
350
|
+
if (plugin.selectionKnown && plugin.enabled === true && admitted === false) {
|
|
351
|
+
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED by the plugin compatibility contract`);
|
|
352
352
|
}
|
|
353
353
|
return {
|
|
354
354
|
name: "plugin-version",
|
|
355
355
|
kind: "deterministic",
|
|
356
356
|
// Not probed => staleness is unknown, not "up to date"; only the local
|
|
357
357
|
// range check can still warn.
|
|
358
|
-
status:
|
|
358
|
+
status: plugin.enabled === false || !plugin.applicable
|
|
359
|
+
? "pass"
|
|
360
|
+
: !plugin.selectionKnown || plugin.enabled === undefined
|
|
361
|
+
? "unknown"
|
|
362
|
+
: stale || admitted === false
|
|
363
|
+
? "warn"
|
|
364
|
+
: probe && availableVersion !== undefined && admitted !== undefined && plugin.enabled === true
|
|
365
|
+
? "pass"
|
|
366
|
+
: "unknown",
|
|
359
367
|
confidence: "high",
|
|
360
368
|
message: messageParts.join(" — "),
|
|
361
369
|
evidence: {
|
|
@@ -363,6 +371,12 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
363
371
|
marketplace: plugin.marketplace,
|
|
364
372
|
plugin: plugin.pluginName,
|
|
365
373
|
installedVersion: plugin.version,
|
|
374
|
+
scope: plugin.scope,
|
|
375
|
+
applicable: plugin.applicable,
|
|
376
|
+
selectionKnown: plugin.selectionKnown,
|
|
377
|
+
projectPath: plugin.projectPath ?? null,
|
|
378
|
+
enabled: plugin.enabled ?? null,
|
|
379
|
+
installPath: plugin.pluginDir,
|
|
366
380
|
availableVersion: availableVersion ?? null,
|
|
367
381
|
...(probe ? {} : { probed: false }),
|
|
368
382
|
stale,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
8
|
+
import { parse as parseToml } from "smol-toml";
|
|
9
|
+
import { existingFileMode, parseJsonc, timestampForFilename, writeFileAtomic } from "../../core/common.js";
|
|
10
|
+
/** Preserve unrelated settings and comments; only the named native key changes. */
|
|
11
|
+
export function editChannelJson(file, keys, value, content) {
|
|
12
|
+
const before = content ?? fs.readFileSync(file, "utf8");
|
|
13
|
+
parseJsonc(before);
|
|
14
|
+
const after = applyEdits(before, modify(before, keys, value, { formattingOptions: { insertSpaces: true, tabSize: 2 } }));
|
|
15
|
+
parseJsonc(after);
|
|
16
|
+
return { file, content: after };
|
|
17
|
+
}
|
|
18
|
+
export function backupChannelFile(file) {
|
|
19
|
+
const backup = `${file}.akm-channel-${timestampForFilename()}-${process.pid}.bak`;
|
|
20
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
21
|
+
fs.chmodSync(backup, existingFileMode(file) ?? 0o600);
|
|
22
|
+
return backup;
|
|
23
|
+
}
|
|
24
|
+
export class ChannelWriteError extends Error {
|
|
25
|
+
backups;
|
|
26
|
+
writtenFiles;
|
|
27
|
+
constructor(cause, backups, writtenFiles) {
|
|
28
|
+
super(`Channel settings could not all be written: ${String(cause)}. Backups: ${backups.join(", ") || "none"}`);
|
|
29
|
+
this.backups = backups;
|
|
30
|
+
this.writtenFiles = writtenFiles;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function writeChannelEdits(edits) {
|
|
34
|
+
const backups = [];
|
|
35
|
+
const writtenFiles = [];
|
|
36
|
+
try {
|
|
37
|
+
const changed = edits.filter(({ file, content }) => fs.readFileSync(file, "utf8") !== content);
|
|
38
|
+
for (const { file } of changed)
|
|
39
|
+
backups.push(backupChannelFile(file));
|
|
40
|
+
for (const { file, content } of changed) {
|
|
41
|
+
writeFileAtomic(file, content, existingFileMode(file));
|
|
42
|
+
writtenFiles.push(file);
|
|
43
|
+
}
|
|
44
|
+
return backups;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new ChannelWriteError(error, backups, writtenFiles);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Claude has no command to change a registered marketplace's ref in place. Never remove the marketplace. */
|
|
51
|
+
export function claudeChannelEdits(configDir, pluginsRoot, channel, projectPaths = []) {
|
|
52
|
+
const ref = channel === "next" ? "next" : undefined;
|
|
53
|
+
const knownFile = path.join(pluginsRoot, "known_marketplaces.json");
|
|
54
|
+
const known = parseJsonc(fs.readFileSync(knownFile, "utf8"));
|
|
55
|
+
const source = known["akm-plugins"]?.source;
|
|
56
|
+
if (!source || !["github", "git"].includes(source.source ?? "")) {
|
|
57
|
+
throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
|
|
58
|
+
}
|
|
59
|
+
const edits = [editChannelJson(knownFile, ["akm-plugins", "source", "ref"], ref)];
|
|
60
|
+
const settingsFiles = [
|
|
61
|
+
path.join(configDir, "settings.json"),
|
|
62
|
+
...[process.cwd(), ...projectPaths].flatMap((project) => [
|
|
63
|
+
path.join(project, ".claude", "settings.json"),
|
|
64
|
+
path.join(project, ".claude", "settings.local.json"),
|
|
65
|
+
]),
|
|
66
|
+
];
|
|
67
|
+
for (const file of new Set(settingsFiles)) {
|
|
68
|
+
if (!fs.existsSync(file))
|
|
69
|
+
continue;
|
|
70
|
+
const settings = parseJsonc(fs.readFileSync(file, "utf8"));
|
|
71
|
+
if (!settings.extraKnownMarketplaces?.["akm-plugins"])
|
|
72
|
+
continue;
|
|
73
|
+
edits.push(editChannelJson(file, ["extraKnownMarketplaces", "akm-plugins", "source", "ref"], ref));
|
|
74
|
+
}
|
|
75
|
+
return edits;
|
|
76
|
+
}
|
|
77
|
+
/** Read native TOML, including quoted keys and inline tables. */
|
|
78
|
+
export function codexMarketplace(file) {
|
|
79
|
+
const config = parseToml(fs.readFileSync(file, "utf8"));
|
|
80
|
+
const marketplaces = config.marketplaces;
|
|
81
|
+
const marketplace = marketplaces?.["akm-plugins"];
|
|
82
|
+
return marketplace && typeof marketplace === "object" ? marketplace : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Codex's add refuses a changed ref on an existing source; preserve the installed plugin and edit only its ref. */
|
|
85
|
+
export function codexChannelEdit(file, channel) {
|
|
86
|
+
const before = fs.readFileSync(file, "utf8");
|
|
87
|
+
const expected = parseToml(before);
|
|
88
|
+
const entry = expected.marketplaces?.["akm-plugins"];
|
|
89
|
+
if (!entry || entry.source_type !== "git")
|
|
90
|
+
throw new Error("the akm-plugins marketplace has no Git source whose channel can be switched");
|
|
91
|
+
if (channel === "next")
|
|
92
|
+
entry.ref = "next";
|
|
93
|
+
else
|
|
94
|
+
delete entry.ref;
|
|
95
|
+
const headers = [...before.matchAll(/^[ \t]*\[(?!\[)([^\]\r\n]+)\][ \t]*(?:#[^\r\n]*)?\r?$/gm)];
|
|
96
|
+
let after = before;
|
|
97
|
+
let edited = false;
|
|
98
|
+
for (const [index, header] of headers.entries()) {
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = parseToml(`[${header[1]}]`);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const target = parsed.marketplaces?.["akm-plugins"];
|
|
107
|
+
if (!target || Object.keys(target).length !== 0)
|
|
108
|
+
continue;
|
|
109
|
+
const start = header.index + header[0].length;
|
|
110
|
+
const end = headers[index + 1]?.index ?? before.length;
|
|
111
|
+
const section = before.slice(start, end);
|
|
112
|
+
const refLine = /^[ \t]*(?:ref|"ref"|'ref')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')([ \t]*(?:#[^\r\n]*)?)(\r?\n|$)/m;
|
|
113
|
+
const newline = before.includes("\r\n") ? "\r\n" : "\n";
|
|
114
|
+
const nextSection = refLine.test(section)
|
|
115
|
+
? section.replace(refLine, (_line, comment, end) => channel === "next" ? `ref = "next"${comment}${end}` : comment.trim() ? `${comment.trimStart()}${end}` : "")
|
|
116
|
+
: channel === "next"
|
|
117
|
+
? `${newline}ref = "next"${section}`
|
|
118
|
+
: section;
|
|
119
|
+
after = before.slice(0, start) + nextSection + before.slice(end);
|
|
120
|
+
edited = true;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (!edited) {
|
|
124
|
+
// Inline tables have no section to insert into. Change just this table's ref and retain every other byte.
|
|
125
|
+
const inline = /((?:"akm-plugins"|'akm-plugins'|akm-plugins)\s*=\s*\{)([^\n]*?)(\})/g;
|
|
126
|
+
for (const match of before.matchAll(inline)) {
|
|
127
|
+
const [, open, body, close] = match;
|
|
128
|
+
const ref = /(?:^|,)\s*(?:ref|"ref"|'ref')\s*=\s*(?:"(?:\\.|[^"\\])*"|'[^']*')\s*/;
|
|
129
|
+
const stripped = body.replace(ref, "").replace(/^\s*,/, "");
|
|
130
|
+
const next = channel === "next" ? `${stripped.trimEnd()}${stripped.trim() ? ", " : ""}ref = "next" ` : stripped;
|
|
131
|
+
const candidate = before.slice(0, match.index) + `${open}${next}${close}` + before.slice(match.index + match[0].length);
|
|
132
|
+
try {
|
|
133
|
+
if (isDeepStrictEqual(parseToml(candidate), expected)) {
|
|
134
|
+
after = candidate;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* Try the next table with this name. */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Validate the whole semantic document: an unusual layout is never partially rewritten.
|
|
144
|
+
if (!isDeepStrictEqual(parseToml(after), expected))
|
|
145
|
+
throw new Error("could not locate only the akm-plugins ref in Codex config.toml; no settings were changed");
|
|
146
|
+
return { file, content: after };
|
|
147
|
+
}
|