akm-cli 0.9.28-alpha.3 → 0.9.28-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.28-alpha.5] - 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - **`akm upgrade --next` installs the `@next` prerelease of akm and its OpenCode plugin.** The CLI target is
14
+ the `next` dist-tag of `akm-cli` when it is newer than the latest stable release, else the stable release
15
+ (never a downgrade); npm/Bun/pnpm installs name that exact version and binary installs take its GitHub
16
+ release. The OpenCode lockstep and cache refresh follow `akm-opencode@next` when the OpenCode config's
17
+ `plugin` list names it (akm never edits that config); with a bare `"akm-opencode"` the entry is `skipped`
18
+ with a message saying to set `"plugin": ["akm-opencode@next"]`, and lockstep stays on the `@latest` pin.
19
+ Missing, older-than-`@latest` or unpinned `@next` fails closed like the stable lockstep. Claude Code and
20
+ Codex are unchanged (no prerelease channel). The result gains `channel`. Works with `--check`, `--force`
21
+ and `-q`. See [`akm upgrade`](docs/reference/cli.md#prereleases-next).
22
+
23
+ ## [0.9.28-alpha.4] - 2026-10-08
24
+
25
+ ### Added
26
+
27
+ - **`akm upgrade` also updates the akm plugin of each installed harness (#1007).** After the CLI step
28
+ it refreshes Claude Code (`claude plugin marketplace update`, then `claude plugin update`) and
29
+ Codex (`codex plugin marketplace upgrade`) when the `akm-plugins` marketplace is configured and the
30
+ plugin is installed, and replaces a stale cached `akm-opencode` (moved to the trash, then re-fetched
31
+ by `opencode debug config`) unless OpenCode is running, in which case it is `deferred`. It only
32
+ updates: a missing plugin is never installed. The result gains `plugins` (one `outcome` per harness:
33
+ `updated`, `current`, `skipped`, `deferred`, `failed`; under `--check`, which changes nothing,
34
+ `pending` for OpenCode and `unknown` for Claude Code and Codex, whose check needs a fetch) and a failed plugin exits 1. With the OpenCode plugin present the CLI moves to the akm-cli
35
+ that `akm-opencode@latest` pins instead of the newest release, reported under `lockstep`; if the pin
36
+ cannot be read the CLI is held where it is rather than moved ahead of the plugin. The
37
+ Containers entrypoint and the Codex hook trust entries are documented under
38
+ [`akm upgrade`](docs/reference/cli.md#upgrade).
39
+
40
+ ### Fixed
41
+
42
+ - **A skill's reference file with a shell `$1` is no longer indexed as a command (#1063).** A file under
43
+ `skills/**` other than `SKILL.md` that showed `local var="$1"` in a code block was retyped to
44
+ `commands/skills/<name>/...`. A skill's folder now counts as a declared context, as `memories/` and the other typed
45
+ directories already did, so the file stays a skill resource (`knowledge/skills/<name>/...`). A `$1` under
46
+ `commands/` or in a loose file is still a command.
47
+
9
48
  ## [0.9.28-alpha.3] - 2026-10-08
10
49
 
11
50
  ### Changed
@@ -329,8 +329,9 @@ akm bundle list # List all sources
329
329
  akm lint # Structural lint over the bundle; exits 0 regardless of findings
330
330
  akm lint --fix # Auto-fix Tier 1 issues
331
331
  akm lint --fail-on-flagged # Exit non-zero when summary.flagged > 0 (CI-friendly)
332
- akm upgrade # Upgrade akm using its install method
333
- akm upgrade --check # Check for updates
332
+ akm upgrade # Upgrade akm using its install method, then update installed harness plugins
333
+ akm upgrade --check # Report pending CLI and plugin updates, changing nothing
334
+ akm upgrade --next # Follow prereleases (@next); OpenCode needs "akm-opencode@next" in its plugin list
334
335
  akm help migrate 0.6.0 # Print migration notes for a release (or: latest)
335
336
  akm help bundle # Print options and subcommands for one command
336
337
  akm help agents --full # Print this reference
@@ -0,0 +1,413 @@
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
+ /**
5
+ * The plugin half of `akm upgrade` (#1007): refresh the akm plugin of each
6
+ * installed agent harness, and keep the CLI in version lockstep with the
7
+ * OpenCode plugin. Update only: a plugin that is not installed is never
8
+ * installed, and a harness without one is skipped. Every external command
9
+ * runs with a timeout and its failure is captured on that harness's entry,
10
+ * so a broken harness never aborts the CLI upgrade.
11
+ */
12
+ import * as childProcess from "node:child_process";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { IS_WINDOWS, stripJsonComments } from "../../core/common.js";
17
+ import { moveToTrash } from "../../core/trash.js";
18
+ import { semverOrder } from "../../runtime.js";
19
+ const MARKETPLACE = "akm-plugins";
20
+ const PLUGIN_ID = `akm@${MARKETPLACE}`;
21
+ const OPENCODE_PACKAGE = "akm-opencode";
22
+ const OPENCODE_NEXT_SPEC = `${OPENCODE_PACKAGE}@next`;
23
+ const READ_TIMEOUT_MS = 30_000;
24
+ const REFRESH_TIMEOUT_MS = 120_000;
25
+ const PREFETCH_TIMEOUT_MS = 180_000;
26
+ function runCommand(command, args, timeoutMs, opts) {
27
+ const result = childProcess.spawnSync(command, args, {
28
+ encoding: "utf8",
29
+ env: process.env,
30
+ stdio: "pipe",
31
+ timeout: timeoutMs,
32
+ killSignal: "SIGKILL",
33
+ cwd: opts?.cwd,
34
+ });
35
+ const label = `${command} ${args.join(" ")}`;
36
+ if (result.error) {
37
+ const code = result.error.code;
38
+ if (code === "ETIMEDOUT")
39
+ return { ok: false, missing: false, error: `\`${label}\` timed out after ${timeoutMs / 1000}s` };
40
+ return { ok: false, missing: code === "ENOENT", error: `\`${label}\` could not run: ${result.error.message}` };
41
+ }
42
+ if (result.status !== 0) {
43
+ const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
44
+ return { ok: false, missing: false, error: `\`${label}\` failed: ${detail}` };
45
+ }
46
+ return { ok: true, stdout: result.stdout ?? "" };
47
+ }
48
+ function parseJson(text) {
49
+ try {
50
+ return JSON.parse(text);
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function skipped(harness, message) {
57
+ return { harness, outcome: "skipped", message };
58
+ }
59
+ function failed(harness, message) {
60
+ return { harness, outcome: "failed", message };
61
+ }
62
+ /** An entry for a refresh that ran: `updated` when the installed version moved, else `current`. */
63
+ function refreshed(harness, from, to) {
64
+ const moved = from !== undefined && to !== undefined && from !== to;
65
+ return { harness, outcome: moved ? "updated" : "current", from, to };
66
+ }
67
+ /** Unknown, in `--check`: knowing whether a newer build exists needs a marketplace fetch, which `--check` does not do. */
68
+ function unknownRefresh(harness, from) {
69
+ return {
70
+ harness,
71
+ outcome: "unknown",
72
+ from,
73
+ message: "checking needs a fetch of the akm-plugins marketplace, which --check does not do; `akm upgrade` refreshes it",
74
+ };
75
+ }
76
+ function claudePluginVersion() {
77
+ const listed = runCommand("claude", ["plugin", "list", "--json"], READ_TIMEOUT_MS);
78
+ if (!listed.ok)
79
+ return { error: listed };
80
+ const plugins = parseJson(listed.stdout);
81
+ const plugin = Array.isArray(plugins) ? plugins.find((p) => p?.id === PLUGIN_ID) : undefined;
82
+ return { version: plugin ? String(plugin.version ?? "") : undefined };
83
+ }
84
+ function detectClaudeCode() {
85
+ const marketplaces = runCommand("claude", ["plugin", "marketplace", "list", "--json"], READ_TIMEOUT_MS);
86
+ if (!marketplaces.ok) {
87
+ return { entry: skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error) };
88
+ }
89
+ const list = parseJson(marketplaces.stdout);
90
+ if (!Array.isArray(list) || !list.some((m) => m?.name === MARKETPLACE)) {
91
+ return { entry: skipped("claude-code", `the ${MARKETPLACE} marketplace is not configured`) };
92
+ }
93
+ const plugin = claudePluginVersion();
94
+ if ("error" in plugin)
95
+ return { entry: skipped("claude-code", plugin.error.error) };
96
+ if (plugin.version === undefined)
97
+ return { entry: skipped("claude-code", "the akm plugin is not installed") };
98
+ return { version: plugin.version };
99
+ }
100
+ function upgradeClaudeCode(dryRun) {
101
+ const detected = detectClaudeCode();
102
+ if ("entry" in detected)
103
+ return detected.entry;
104
+ if (dryRun)
105
+ return unknownRefresh("claude-code", detected.version);
106
+ const marketplace = runCommand("claude", ["plugin", "marketplace", "update", MARKETPLACE], REFRESH_TIMEOUT_MS);
107
+ if (!marketplace.ok)
108
+ return failed("claude-code", marketplace.error);
109
+ const update = runCommand("claude", ["plugin", "update", PLUGIN_ID], REFRESH_TIMEOUT_MS);
110
+ if (!update.ok)
111
+ return failed("claude-code", update.error);
112
+ const after = claudePluginVersion();
113
+ return refreshed("claude-code", detected.version, "version" in after ? after.version : undefined);
114
+ }
115
+ // ── Codex ───────────────────────────────────────────────────────────────────
116
+ function codexPluginVersion() {
117
+ const listed = runCommand("codex", ["plugin", "list", "--marketplace", MARKETPLACE, "--json"], READ_TIMEOUT_MS);
118
+ if (!listed.ok)
119
+ return { error: listed };
120
+ const parsed = parseJson(listed.stdout);
121
+ const plugin = parsed?.installed?.find((p) => p?.pluginId === PLUGIN_ID);
122
+ return { version: plugin ? String(plugin.version ?? "") : undefined };
123
+ }
124
+ function upgradeCodex(dryRun) {
125
+ const before = codexPluginVersion();
126
+ if ("error" in before)
127
+ return skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
128
+ if (before.version === undefined) {
129
+ return skipped("codex", `the akm plugin is not installed from the ${MARKETPLACE} marketplace`);
130
+ }
131
+ if (dryRun)
132
+ return unknownRefresh("codex", before.version);
133
+ // Also refreshes the installed plugin's cache, so no re-add is needed.
134
+ const upgrade = runCommand("codex", ["plugin", "marketplace", "upgrade", MARKETPLACE], REFRESH_TIMEOUT_MS);
135
+ if (!upgrade.ok)
136
+ return failed("codex", upgrade.error);
137
+ const after = codexPluginVersion();
138
+ return refreshed("codex", before.version, "version" in after ? after.version : undefined);
139
+ }
140
+ function homeDir() {
141
+ return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
142
+ }
143
+ /**
144
+ * Whether the user's global OpenCode config asks for `akm-opencode@next`.
145
+ * A bare `akm-opencode` resolves to `@latest` when OpenCode prefetches it, so
146
+ * only a config that names the tag can follow prereleases. The config is only
147
+ * read, never written; project configs are not looked at (akm does not know
148
+ * which project OpenCode runs in).
149
+ */
150
+ export function openCodeConfigRequestsNext() {
151
+ const configHome = process.env.XDG_CONFIG_HOME?.trim() || path.join(homeDir(), ".config");
152
+ const files = [
153
+ process.env.OPENCODE_CONFIG?.trim(),
154
+ path.join(configHome, "opencode", "opencode.json"),
155
+ path.join(configHome, "opencode", "opencode.jsonc"),
156
+ ];
157
+ for (const file of files) {
158
+ if (!file)
159
+ continue;
160
+ try {
161
+ const config = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8")));
162
+ if (!Array.isArray(config.plugin))
163
+ continue;
164
+ // An entry is a spec string or a `[spec, options]` pair.
165
+ if (config.plugin.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === OPENCODE_NEXT_SPEC))
166
+ return true;
167
+ }
168
+ catch {
169
+ // Missing or unparseable: this file does not request it.
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+ /**
175
+ * The cached `akm-opencode` OpenCode installed on first use, or undefined when
176
+ * there is none. OpenCode names the folder after the spec it resolved, so a
177
+ * bare `akm-opencode` lands in `@latest` and `akm-opencode@next` in `@next`.
178
+ */
179
+ export function detectOpenCodeCache(tag = "latest") {
180
+ const cacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(homeDir(), ".cache");
181
+ const dir = path.join(cacheHome, "opencode", "packages", `${OPENCODE_PACKAGE}@${tag}`);
182
+ if (!fs.existsSync(dir))
183
+ return undefined;
184
+ const readVersion = (file) => {
185
+ try {
186
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
187
+ return typeof pkg.version === "string" ? pkg.version : undefined;
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ };
193
+ return { dir, version: readVersion(path.join(dir, "node_modules", OPENCODE_PACKAGE, "package.json")) };
194
+ }
195
+ /** What npm's `akm-opencode@<tag>` is, and which akm-cli it pins. */
196
+ export function lookupOpenCodeLatest(tag = "latest") {
197
+ const spec = `${OPENCODE_PACKAGE}@${tag}`;
198
+ const view = runCommand(IS_WINDOWS ? "npm.cmd" : "npm", ["view", spec, "version", "dependencies.akm-cli", "--json"], READ_TIMEOUT_MS);
199
+ if (!view.ok)
200
+ return { error: view.error };
201
+ const parsed = parseJson(view.stdout);
202
+ if (typeof parsed?.version !== "string")
203
+ return { error: `npm did not report a version for ${spec}` };
204
+ const akmCli = parsed["dependencies.akm-cli"];
205
+ return { version: parsed.version, akmCli: typeof akmCli === "string" ? akmCli : undefined };
206
+ }
207
+ /** `akm-opencode@next`, which must exist and be no older than `@latest`: a prerelease channel that trails the stable one is not a target. */
208
+ export function lookupOpenCodeNext() {
209
+ const next = lookupOpenCodeLatest("next");
210
+ if ("error" in next)
211
+ return next;
212
+ const latest = lookupOpenCodeLatest("latest");
213
+ if ("error" in latest)
214
+ return { error: `could not compare with ${OPENCODE_PACKAGE}@latest: ${latest.error}` };
215
+ if (semverOrder(next.version, latest.version) < 0) {
216
+ return {
217
+ error: `${OPENCODE_NEXT_SPEC} (${next.version}) is older than ${OPENCODE_PACKAGE}@latest (${latest.version})`,
218
+ };
219
+ }
220
+ return next;
221
+ }
222
+ /** Whether an OpenCode process is running (its prefetch would be replaced under it). `undefined` when that cannot be told. */
223
+ function openCodeRunning() {
224
+ if (IS_WINDOWS) {
225
+ const tasks = runCommand("tasklist", ["/FI", "IMAGENAME eq opencode.exe", "/NH"], READ_TIMEOUT_MS);
226
+ return tasks.ok ? /opencode\.exe/i.test(tasks.stdout) : undefined;
227
+ }
228
+ // `-f` because a Node-wrapped opencode shows up as `node …/opencode`; the
229
+ // pattern needs `opencode` to be a whole path segment so `akm-opencode`
230
+ // in some other command's arguments does not match.
231
+ const pgrep = runCommand("pgrep", ["-f", "(^|/)opencode( |$)"], READ_TIMEOUT_MS);
232
+ if (pgrep.ok)
233
+ return true;
234
+ if (!pgrep.missing)
235
+ return false; // pgrep exits 1 when nothing matched
236
+ if (process.platform !== "linux")
237
+ return undefined;
238
+ try {
239
+ return fs.readdirSync("/proc").some((pid) => {
240
+ if (!/^\d+$/.test(pid) || Number(pid) === process.pid)
241
+ return false;
242
+ try {
243
+ const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
244
+ return argv.slice(0, 2).some((arg) => path.basename(arg) === "opencode");
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ });
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ }
255
+ const NOT_FOLLOWING_NEXT = `--next: OpenCode resolves a bare "${OPENCODE_PACKAGE}" to @latest, so it is not updated to a prerelease; ` +
256
+ `set "plugin": ["${OPENCODE_NEXT_SPEC}"] in your OpenCode config to follow prereleases`;
257
+ function upgradeOpenCode(dryRun, cache, latest, tag, notFollowingNext) {
258
+ if (!cache || !latest)
259
+ return skipped("opencode", `no cached ${OPENCODE_PACKAGE}@${tag} plugin`);
260
+ if ("error" in latest)
261
+ return failed("opencode", latest.error);
262
+ if (notFollowingNext)
263
+ return skipped("opencode", NOT_FOLLOWING_NEXT);
264
+ if (cache.version === latest.version)
265
+ return { harness: "opencode", outcome: "current", from: cache.version, to: latest.version };
266
+ const base = { harness: "opencode", from: cache.version, to: latest.version };
267
+ const running = openCodeRunning();
268
+ if (running !== false) {
269
+ return {
270
+ ...base,
271
+ outcome: "deferred",
272
+ message: running === true
273
+ ? "OpenCode is running; the plugin cache is replaced by a later `akm upgrade` once it has exited"
274
+ : "could not tell whether OpenCode is running; the plugin cache is replaced by a later `akm upgrade`",
275
+ };
276
+ }
277
+ if (dryRun)
278
+ return { ...base, outcome: "pending" };
279
+ // OpenCode must be runnable before the cache goes: it does the prefetch.
280
+ const probe = runCommand("opencode", ["--version"], READ_TIMEOUT_MS);
281
+ if (!probe.ok)
282
+ return skipped("opencode", probe.missing ? "opencode is not on PATH" : probe.error);
283
+ try {
284
+ moveToTrash(cache.dir);
285
+ }
286
+ catch (error) {
287
+ return failed("opencode", `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`);
288
+ }
289
+ // Any OpenCode command that resolves the config installs the plugin again;
290
+ // from a temp dir, so no project's opencode.json is read.
291
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-opencode-prefetch-"));
292
+ try {
293
+ const prefetch = runCommand("opencode", ["debug", "config"], PREFETCH_TIMEOUT_MS, { cwd: workDir });
294
+ if (!prefetch.ok)
295
+ return failed("opencode", `${prefetch.error} (the old cache is in the trash)`);
296
+ }
297
+ finally {
298
+ fs.rmSync(workDir, { recursive: true, force: true });
299
+ }
300
+ const after = detectOpenCodeCache(tag);
301
+ if (!after)
302
+ return failed("opencode", "opencode did not re-create the plugin cache (the old cache is in the trash)");
303
+ return { ...base, outcome: "updated", to: after.version ?? latest.version };
304
+ }
305
+ // ── Orchestration ───────────────────────────────────────────────────────────
306
+ const NO_PRERELEASE_CHANNEL = "--next: no prerelease channel; the akm-plugins marketplace is followed as usual and works with any 0.9.x akm";
307
+ /** The note `--next` adds to a harness whose plugin has no prerelease channel. */
308
+ function withNextNote(entry) {
309
+ if (entry.outcome === "skipped")
310
+ return entry;
311
+ return { ...entry, message: entry.message ? `${entry.message}; ${NO_PRERELEASE_CHANNEL}` : NO_PRERELEASE_CHANNEL };
312
+ }
313
+ export function upgradePlugins(opts) {
314
+ const { cache, latest, tag, notFollowingNext } = opts.openCode;
315
+ const claude = upgradeClaudeCode(opts.dryRun);
316
+ const codex = upgradeCodex(opts.dryRun);
317
+ return [
318
+ opts.next ? withNextNote(claude) : claude,
319
+ opts.next ? withNextNote(codex) : codex,
320
+ upgradeOpenCode(opts.dryRun, cache, latest, tag, notFollowingNext),
321
+ ];
322
+ }
323
+ /**
324
+ * When the OpenCode plugin is present the CLI target is the akm-cli that
325
+ * `akm-opencode@latest` pins, not the newest release: the plugin runs that
326
+ * exact akm in-process, against databases a newer CLI may already have
327
+ * migrated. The CLI never moves backwards to meet the pin.
328
+ *
329
+ * It fails closed: when the plugin is present but its pin cannot be read (the
330
+ * npm lookup failed, or the package declares no akm-cli), the CLI is held where
331
+ * it is (`updateAvailable: false`, `latestVersion` = the current version) and
332
+ * `lockstep.reason` says why. Moving the CLI ahead of a pin nobody could read is
333
+ * what lockstep exists to prevent; the OpenCode entry reports the failure.
334
+ */
335
+ export function applyLockstep(check, latest, tag = "latest") {
336
+ if (!latest)
337
+ return check;
338
+ // Held back only when there is a release this upgrade would otherwise install.
339
+ const wouldInstall = semverOrder(check.currentVersion, check.latestVersion) < 0;
340
+ const pinned = "error" in latest ? undefined : latest.akmCli;
341
+ if (!pinned) {
342
+ const reason = "error" in latest
343
+ ? `could not read the akm-cli pin of ${OPENCODE_PACKAGE}@${tag}: ${latest.error}`
344
+ : `${OPENCODE_PACKAGE}@${tag} declares no akm-cli dependency`;
345
+ return {
346
+ ...check,
347
+ latestVersion: check.currentVersion,
348
+ updateAvailable: false,
349
+ lockstep: {
350
+ plugin: OPENCODE_PACKAGE,
351
+ pinnedVersion: null,
352
+ newestVersion: check.latestVersion,
353
+ heldBack: wouldInstall,
354
+ reason,
355
+ },
356
+ };
357
+ }
358
+ const heldBack = semverOrder(pinned, check.latestVersion) < 0 && wouldInstall;
359
+ const lockstep = {
360
+ plugin: OPENCODE_PACKAGE,
361
+ pinnedVersion: pinned,
362
+ newestVersion: check.latestVersion,
363
+ heldBack,
364
+ };
365
+ if (!heldBack)
366
+ return { ...check, lockstep };
367
+ return {
368
+ ...check,
369
+ latestVersion: pinned,
370
+ updateAvailable: semverOrder(check.currentVersion, pinned) < 0,
371
+ lockstep,
372
+ };
373
+ }
374
+ /**
375
+ * Which OpenCode plugin build the CLI is held to, and which cache refreshes.
376
+ * Under `--next` that is `akm-opencode@next`, but only when the user's OpenCode
377
+ * config names that tag: otherwise OpenCode keeps resolving `@latest`, so the
378
+ * lockstep stays against the `@latest` pin and the entry is reported as skipped.
379
+ */
380
+ function resolveOpenCodeTarget(next) {
381
+ const followNext = next && openCodeConfigRequestsNext();
382
+ const tag = followNext ? "next" : "latest";
383
+ const cache = detectOpenCodeCache(tag);
384
+ const latest = cache ? (followNext ? lookupOpenCodeNext() : lookupOpenCodeLatest()) : undefined;
385
+ return { cache, latest, tag, notFollowingNext: next && !followNext };
386
+ }
387
+ /** `akm upgrade`: the CLI step (held to the OpenCode plugin's akm), then the plugins. */
388
+ export async function runUpgrade(args, currentVersion, deps) {
389
+ const next = args.next === true;
390
+ const channel = next ? "next" : "latest";
391
+ const openCode = resolveOpenCodeTarget(next);
392
+ const check = {
393
+ ...applyLockstep(await deps.checkForUpdate(currentVersion, channel), openCode.latest, openCode.tag),
394
+ channel,
395
+ };
396
+ if (args.check) {
397
+ return { mode: "check", result: { ...check, plugins: upgradePlugins({ dryRun: true, next, openCode }) } };
398
+ }
399
+ const upgraded = await deps.performUpgrade(check, {
400
+ force: args.force,
401
+ skipPostUpgrade: args.skipPostUpgrade,
402
+ // A package manager install must name the version, or `@latest` goes past the pin
403
+ // (or past the prerelease that `--next` chose).
404
+ ...(check.lockstep?.heldBack || (next && check.latestVersion) ? { targetVersion: check.latestVersion } : {}),
405
+ });
406
+ const plugins = upgradePlugins({ dryRun: false, next, openCode });
407
+ const result = { ...upgraded, channel, ...(check.lockstep ? { lockstep: check.lockstep } : {}), plugins };
408
+ // The install may have succeeded, but an upgrade whose migration is
409
+ // blocked or could not run is not done, and neither is one whose plugin
410
+ // step failed.
411
+ const migrationFailed = upgraded.migration?.status === "blocked" || upgraded.migration?.status === "failed";
412
+ return { mode: "upgrade", result, failed: migrationFailed || plugins.some((p) => p.outcome === "failed") };
413
+ }
@@ -174,7 +174,24 @@ export function getAkmBinaryName() {
174
174
  return "akm-windows-x64.exe";
175
175
  throw new ConfigError(`Unsupported platform for binary upgrade: ${platform}/${arch}`, "UNSUPPORTED_PLATFORM");
176
176
  }
177
- export async function checkForUpdate(currentVersion, fetchOptions) {
177
+ /** The `next` dist-tag of this package on the npm registry, or undefined when none is published. */
178
+ async function lookupNextVersion(fetchOptions) {
179
+ const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(getInstalledPackageName())}/dist-tags`;
180
+ const response = await fetchWithRetry(url, { headers: { accept: "application/json" } }, fetchOptions);
181
+ if (!response.ok) {
182
+ throw new Error(`Failed to check for the next prerelease: ${response.status} ${response.statusText}`);
183
+ }
184
+ const tags = JSON.parse(await readBodyWithByteCap(response, MAX_CHECKSUM_METADATA_BYTES));
185
+ return typeof tags.next === "string" && tags.next !== "" ? tags.next : undefined;
186
+ }
187
+ /**
188
+ * The newest release. With `channel: "next"` that includes prereleases: the
189
+ * `next` dist-tag when it is newer than the latest stable release, else the
190
+ * stable release (a prerelease older than a stable one is never a target).
191
+ * A binary install downloads the GitHub release tagged `v<version>`, which a
192
+ * prerelease has too, so the version is all the install step needs.
193
+ */
194
+ export async function checkForUpdate(currentVersion, fetchOptions, channel = "latest") {
178
195
  const installMethod = detectInstallMethod();
179
196
  const url = `https://api.github.com/repos/${REPO}/releases/latest`;
180
197
  const response = await fetchWithRetry(url, { headers: githubHeaders() }, fetchOptions);
@@ -183,7 +200,12 @@ export async function checkForUpdate(currentVersion, fetchOptions) {
183
200
  }
184
201
  const release = JSON.parse(await readBodyWithByteCap(response, MAX_CHECKSUM_METADATA_BYTES));
185
202
  const latestTag = release.tag_name ?? "";
186
- const latestVersion = latestTag.replace(/^v/, "");
203
+ let latestVersion = latestTag.replace(/^v/, "");
204
+ if (channel === "next") {
205
+ const next = await lookupNextVersion(fetchOptions);
206
+ if (next && (latestVersion === "" || semverOrder(latestVersion, next) < 0))
207
+ latestVersion = next;
208
+ }
187
209
  return {
188
210
  currentVersion,
189
211
  latestVersion,
@@ -237,7 +259,7 @@ export async function performUpgrade(check, opts, dependencies) {
237
259
  migration: await runMigrationStep(runTool),
238
260
  };
239
261
  }
240
- const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod);
262
+ const packageManagerCommand = getPackageManagerUpgradeCommand(installMethod, undefined, opts?.targetVersion);
241
263
  if (packageManagerCommand) {
242
264
  return runPackageManagerUpgrade({
243
265
  packageManagerCommand,
@@ -574,8 +596,8 @@ function resolveNodePackageManagerCommand(name) {
574
596
  const adjacent = path.join(path.dirname(process.execPath), `${name}${extension}`);
575
597
  return fs.existsSync(adjacent) ? adjacent : name;
576
598
  }
577
- export function getPackageManagerUpgradeCommand(installMethod, packageName = getInstalledPackageName()) {
578
- const pkgRef = `${packageName}@latest`;
599
+ export function getPackageManagerUpgradeCommand(installMethod, packageName = getInstalledPackageName(), version = "latest") {
600
+ const pkgRef = `${packageName}@${version}`;
579
601
  if (installMethod === "bun") {
580
602
  return {
581
603
  command: "bun",
@@ -33,31 +33,43 @@ import { UsageError } from "../../core/errors.js";
33
33
  import { appendEvent } from "../../core/events.js";
34
34
  import { resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
35
35
  import { pkgVersion } from "../../version.js";
36
+ import { runUpgrade } from "./plugin-upgrade.js";
36
37
  import { checkForUpdate, performUpgrade } from "./self-update.js";
37
38
  import { akmClone } from "./source-clone.js";
38
39
  export const upgradeCommand = defineJsonCommand({
39
- meta: { name: "upgrade", description: "Upgrade akm to the latest release" },
40
+ meta: {
41
+ name: "upgrade",
42
+ description: "Upgrade akm to the latest release, then update the akm plugin of each installed harness " +
43
+ "(Claude Code, Codex, OpenCode). With the OpenCode plugin installed, akm moves to the version that plugin pins.",
44
+ },
40
45
  args: {
41
- check: { type: "boolean", description: "Check for updates without installing", default: false },
46
+ check: {
47
+ type: "boolean",
48
+ description: "Report pending updates, CLI and per-harness plugins, without changing anything",
49
+ default: false,
50
+ },
42
51
  force: { type: "boolean", description: "Force upgrade even if on latest", default: false },
43
52
  "skip-post-upgrade": {
44
53
  type: "boolean",
45
54
  description: "Skip the post-upgrade index rebuild",
46
55
  default: false,
47
56
  },
57
+ next: {
58
+ type: "boolean",
59
+ description: "Install the newest prerelease (the @next npm tag) of akm and, if OpenCode's config names akm-opencode@next, of its plugin",
60
+ default: false,
61
+ },
48
62
  },
49
63
  async run({ args }) {
50
- const check = await checkForUpdate(pkgVersion);
51
- if (args.check) {
52
- output("upgrade", check);
64
+ const run = await runUpgrade({ check: args.check, force: args.force, skipPostUpgrade: args["skip-post-upgrade"], next: args.next }, pkgVersion, {
65
+ checkForUpdate: (version, channel) => checkForUpdate(version, undefined, channel),
66
+ performUpgrade: (check, opts) => performUpgrade(check, opts),
67
+ });
68
+ if (run.mode === "check") {
69
+ output("upgrade", run.result);
53
70
  return;
54
71
  }
55
- const skipPostUpgrade = args["skip-post-upgrade"];
56
- const result = await performUpgrade(check, { force: args.force, skipPostUpgrade });
57
- // The install may have succeeded, but an upgrade whose migration is
58
- // blocked or could not run is not done: exit like `akm migrate apply` does.
59
- const migrationFailed = result.migration?.status === "blocked" || result.migration?.status === "failed";
60
- outputWithExitCode("upgrade", result, migrationFailed ? EXIT_CODES.GENERAL : undefined);
72
+ outputWithExitCode("upgrade", run.result, run.failed ? EXIT_CODES.GENERAL : undefined);
61
73
  },
62
74
  });
63
75
  // `sync` body, standalone so the git-commit/push logic stays in one place.
@@ -0,0 +1,73 @@
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 * as childProcess from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ const TRASH_TOOL_TIMEOUT_MS = 30_000;
9
+ function homeDir() {
10
+ return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
11
+ }
12
+ /** `[command, ...args]` trash tools tried in order on Linux; the target path is appended. */
13
+ const LINUX_TRASH_TOOLS = [["trash-put"], ["gio", "trash"]];
14
+ /**
15
+ * FreeDesktop trash without a helper tool: move `target` into
16
+ * `$XDG_DATA_HOME/Trash/files` and write the `.trashinfo` that lets a file
17
+ * manager restore it. A plain rename, so it fails (rather than copying) when
18
+ * the trash is on another filesystem.
19
+ */
20
+ function moveToXdgTrash(target) {
21
+ const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(homeDir(), ".local", "share");
22
+ const filesDir = path.join(dataHome, "Trash", "files");
23
+ const infoDir = path.join(dataHome, "Trash", "info");
24
+ fs.mkdirSync(filesDir, { recursive: true });
25
+ fs.mkdirSync(infoDir, { recursive: true });
26
+ const base = path.basename(target);
27
+ let name = base;
28
+ for (let n = 1; fs.existsSync(path.join(filesDir, name)) || fs.existsSync(path.join(infoDir, `${name}.trashinfo`)); n++) {
29
+ name = `${base}.${n}`;
30
+ }
31
+ const deletedAt = new Date().toISOString().slice(0, 19);
32
+ fs.writeFileSync(path.join(infoDir, `${name}.trashinfo`), `[Trash Info]\nPath=${encodeURI(path.resolve(target))}\nDeletionDate=${deletedAt}\n`);
33
+ try {
34
+ fs.renameSync(target, path.join(filesDir, name));
35
+ }
36
+ catch (error) {
37
+ fs.unlinkSync(path.join(infoDir, `${name}.trashinfo`));
38
+ throw error;
39
+ }
40
+ }
41
+ /**
42
+ * Move `target` to the operating system's trash. Never deletes permanently:
43
+ * when no trash is available the target is left where it is and this throws.
44
+ * Linux tries `trash-put`, then `gio trash`, then the FreeDesktop trash
45
+ * directory; macOS moves into `~/.Trash`; Windows has no supported trash here.
46
+ */
47
+ export function moveToTrash(target, tools = LINUX_TRASH_TOOLS) {
48
+ if (!fs.existsSync(target))
49
+ return;
50
+ if (process.platform === "win32") {
51
+ throw new Error("moving to the Recycle Bin is not supported on Windows; the directory was left in place");
52
+ }
53
+ if (process.platform === "darwin") {
54
+ const trashDir = path.join(homeDir(), ".Trash");
55
+ fs.mkdirSync(trashDir, { recursive: true });
56
+ fs.renameSync(target, path.join(trashDir, `${path.basename(target)}.${Date.now()}`));
57
+ return;
58
+ }
59
+ for (const [command, ...args] of tools) {
60
+ const result = childProcess.spawnSync(command, [...args, target], {
61
+ // The live environment, as the harness commands get it: Bun's default is the env the process started with.
62
+ env: process.env,
63
+ encoding: "utf8",
64
+ stdio: "pipe",
65
+ timeout: TRASH_TOOL_TIMEOUT_MS,
66
+ });
67
+ if (!result.error && result.status === 0 && !fs.existsSync(target))
68
+ return;
69
+ // A tool that is missing falls through to the next; one that ran and
70
+ // failed does too, because the target is still in place.
71
+ }
72
+ moveToXdgTrash(target);
73
+ }
@@ -159,11 +159,12 @@ function matchDirectoryHint(dirName, ctx, specificity) {
159
159
  * True when some ancestor directory already DECLARES this file's type via
160
160
  * `DIR_TYPE_MAP` — the same walk `classifyByDirectory` performs. Derived from
161
161
  * path fields alone, so `smartMdPathCandidates` can apply it without reading
162
- * bytes.
162
+ * bytes. A skill's folder is a declared context too: a file in it is a skill
163
+ * resource, so a shell `$1` in one of its snippets must not retype it (#1063).
163
164
  */
164
165
  function hasDeclaredDirType(ctx) {
165
166
  if (isNestedSkillResource(ctx))
166
- return false;
167
+ return true;
167
168
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
168
169
  }
169
170
  function classifyByExtension(ctx) {
@@ -662,12 +662,38 @@ export function formatUpdatePlain(r) {
662
662
  }
663
663
  return lines.length > 0 ? lines.join("\n") : `update: nothing to update`;
664
664
  }
665
+ const HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
666
+ /** The plugin lines worth printing: nothing for a plugin that is current or has no plugin to update. */
667
+ function formatUpgradePluginLines(r) {
668
+ const lines = [];
669
+ const lockstep = r.lockstep;
670
+ if (lockstep?.heldBack) {
671
+ lines.push(lockstep.pinnedVersion
672
+ ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version`
673
+ : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
674
+ }
675
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
676
+ for (const p of plugins) {
677
+ if (p.outcome === "current" || p.outcome === "skipped")
678
+ continue;
679
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} → v${p.to}` : "";
680
+ const note = p.message ? ` (${p.message})` : "";
681
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
682
+ }
683
+ return lines;
684
+ }
665
685
  export function formatUpgradePlain(r) {
686
+ const lines = formatUpgradePluginLines(r);
687
+ const head = formatUpgradeHead(r);
688
+ const all = head === null ? lines : [head, ...lines];
689
+ return all.length > 0 ? all.join("\n") : null;
690
+ }
691
+ function formatUpgradeHead(r) {
666
692
  if (r.upgraded === true) {
667
693
  return `akm upgraded: v${r.currentVersion} → v${r.newVersion}`;
668
694
  }
669
695
  if (r.updateAvailable === true) {
670
- return `akm v${r.currentVersion} → v${r.latestVersion} available (run 'akm upgrade' to install)`;
696
+ return `akm v${r.currentVersion} → v${r.latestVersion} available (run 'akm upgrade${r.channel === "next" ? " --next" : ""}' to install)`;
671
697
  }
672
698
  if (r.updateAvailable === false && r.latestVersion) {
673
699
  return `akm v${r.currentVersion} is already the latest version`;
@@ -30464,12 +30464,36 @@ function formatUpdatePlain(r) {
30464
30464
  return lines.length > 0 ? lines.join(`
30465
30465
  `) : `update: nothing to update`;
30466
30466
  }
30467
+ var HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
30468
+ function formatUpgradePluginLines(r) {
30469
+ const lines = [];
30470
+ const lockstep = r.lockstep;
30471
+ if (lockstep?.heldBack) {
30472
+ lines.push(lockstep.pinnedVersion ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version` : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
30473
+ }
30474
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
30475
+ for (const p of plugins) {
30476
+ if (p.outcome === "current" || p.outcome === "skipped")
30477
+ continue;
30478
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} → v${p.to}` : "";
30479
+ const note = p.message ? ` (${p.message})` : "";
30480
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
30481
+ }
30482
+ return lines;
30483
+ }
30467
30484
  function formatUpgradePlain(r) {
30485
+ const lines = formatUpgradePluginLines(r);
30486
+ const head = formatUpgradeHead(r);
30487
+ const all = head === null ? lines : [head, ...lines];
30488
+ return all.length > 0 ? all.join(`
30489
+ `) : null;
30490
+ }
30491
+ function formatUpgradeHead(r) {
30468
30492
  if (r.upgraded === true) {
30469
30493
  return `akm upgraded: v${r.currentVersion} → v${r.newVersion}`;
30470
30494
  }
30471
30495
  if (r.updateAvailable === true) {
30472
- return `akm v${r.currentVersion} → v${r.latestVersion} available (run 'akm upgrade' to install)`;
30496
+ return `akm v${r.currentVersion} → v${r.latestVersion} available (run 'akm upgrade${r.channel === "next" ? " --next" : ""}' to install)`;
30473
30497
  }
30474
30498
  if (r.updateAvailable === false && r.latestVersion) {
30475
30499
  return `akm v${r.currentVersion} is already the latest version`;
@@ -40449,7 +40473,7 @@ function matchDirectoryHint(dirName, ctx, specificity) {
40449
40473
  }
40450
40474
  function hasDeclaredDirType(ctx) {
40451
40475
  if (isNestedSkillResource(ctx))
40452
- return false;
40476
+ return true;
40453
40477
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
40454
40478
  }
40455
40479
  function classifyByExtension(ctx) {
@@ -29792,12 +29792,36 @@ function formatUpdatePlain(r) {
29792
29792
  return lines.length > 0 ? lines.join(`
29793
29793
  `) : `update: nothing to update`;
29794
29794
  }
29795
+ var HARNESS_LABELS = { "claude-code": "Claude Code", codex: "Codex", opencode: "OpenCode" };
29796
+ function formatUpgradePluginLines(r) {
29797
+ const lines = [];
29798
+ const lockstep = r.lockstep;
29799
+ if (lockstep?.heldBack) {
29800
+ lines.push(lockstep.pinnedVersion ? `akm is held at v${lockstep.pinnedVersion} (v${lockstep.newestVersion} is out): the OpenCode plugin pins that version` : `akm is not upgraded to v${lockstep.newestVersion}: ${lockstep.reason}`);
29801
+ }
29802
+ const plugins = Array.isArray(r.plugins) ? r.plugins : [];
29803
+ for (const p of plugins) {
29804
+ if (p.outcome === "current" || p.outcome === "skipped")
29805
+ continue;
29806
+ const versions = p.from && p.to && p.from !== p.to ? ` v${p.from} \u2192 v${p.to}` : "";
29807
+ const note = p.message ? ` (${p.message})` : "";
29808
+ lines.push(`${HARNESS_LABELS[String(p.harness)] ?? p.harness} plugin ${p.outcome}${versions}${note}`);
29809
+ }
29810
+ return lines;
29811
+ }
29795
29812
  function formatUpgradePlain(r) {
29813
+ const lines = formatUpgradePluginLines(r);
29814
+ const head = formatUpgradeHead(r);
29815
+ const all = head === null ? lines : [head, ...lines];
29816
+ return all.length > 0 ? all.join(`
29817
+ `) : null;
29818
+ }
29819
+ function formatUpgradeHead(r) {
29796
29820
  if (r.upgraded === true) {
29797
29821
  return `akm upgraded: v${r.currentVersion} \u2192 v${r.newVersion}`;
29798
29822
  }
29799
29823
  if (r.updateAvailable === true) {
29800
- return `akm v${r.currentVersion} \u2192 v${r.latestVersion} available (run 'akm upgrade' to install)`;
29824
+ return `akm v${r.currentVersion} \u2192 v${r.latestVersion} available (run 'akm upgrade${r.channel === "next" ? " --next" : ""}' to install)`;
29801
29825
  }
29802
29826
  if (r.updateAvailable === false && r.latestVersion) {
29803
29827
  return `akm v${r.currentVersion} is already the latest version`;
@@ -39777,7 +39801,7 @@ function matchDirectoryHint(dirName, ctx, specificity) {
39777
39801
  }
39778
39802
  function hasDeclaredDirType(ctx) {
39779
39803
  if (isNestedSkillResource(ctx))
39780
- return false;
39804
+ return true;
39781
39805
  return ctx.ancestorDirs.some((dir) => matchDirectoryHint(dir, ctx, 0) !== null);
39782
39806
  }
39783
39807
  function classifyByExtension(ctx) {
@@ -186,8 +186,13 @@ migration didn't finish is not done. This makes plain `akm upgrade` (no
186
186
  flags) a safe, idempotent container entrypoint step on every boot: on an
187
187
  already-current install with nothing pending it is a fast no-op that exits 0.
188
188
 
189
+ After the install step it also updates the akm plugin of each agent harness
190
+ present in the image (Claude Code, Codex, OpenCode), reported under `plugins`;
191
+ see [`akm upgrade`](../reference/cli.md#upgrade) for the rules, the OpenCode
192
+ version lockstep and the Codex hook-trust entries a headless image needs.
193
+
189
194
  **`--check`** skips the migration step entirely — it only compares versions
190
- and reports `updateAvailable`. Use it for a version-drift alert, not as your
195
+ and reports `updateAvailable` (plus the pending per-harness plugin updates). Use it for a version-drift alert, not as your
191
196
  boot check.
192
197
 
193
198
  ## `akm health`
@@ -1336,20 +1336,133 @@ computed, with a 256 MiB binary limit. Release/checksum metadata is capped at
1336
1336
  1 MiB; an oversized response is cancelled and the staged file is removed.
1337
1337
 
1338
1338
  ```sh
1339
- akm upgrade # Install a newer release if there is one, then run every pending migration
1340
- akm upgrade --check # Check for updates without installing (no migration step)
1339
+ akm upgrade # Install a newer release if there is one, run every pending migration, then update the harness plugins
1340
+ akm upgrade --check # Report pending updates, CLI and plugins, without changing anything (no migration step)
1341
1341
  akm upgrade --force # Force the install even if already on latest
1342
+ akm upgrade --next # Install the newest prerelease (@next) instead of the latest stable release
1342
1343
  ```
1343
1344
 
1344
1345
  | Flag | Description |
1345
1346
  | --- | --- |
1346
- | `--check` | Check for updates without installing |
1347
+ | `--check` | Report pending updates (CLI and per-harness plugins) without changing anything |
1347
1348
  | `--force` | Force upgrade even if on latest version |
1348
1349
  | `--skip-post-upgrade` | Skip the post-upgrade index rebuild |
1350
+ | `--next` | Follow the `next` prerelease channel: install the newest prerelease of akm (and of the OpenCode plugin, see [Prereleases](#prereleases-next)). Works with `--check`, `--force` and `-q` |
1349
1351
 
1350
1352
  Offline, or to migrate without a release check, run `akm migrate apply`
1351
1353
  directly: it is the same step.
1352
1354
 
1355
+ #### Harness plugins
1356
+
1357
+ After the CLI step, `akm upgrade` updates the akm plugin of each agent harness
1358
+ it finds. It only updates: a plugin that is not installed is never installed,
1359
+ and a harness without the akm plugin is skipped. Each external command runs
1360
+ with a timeout, and a failure is recorded on that harness's entry without
1361
+ stopping the CLI upgrade.
1362
+
1363
+ | Harness | Updated when | What runs |
1364
+ | --- | --- | --- |
1365
+ | Claude Code | `claude` is on `PATH`, the `akm-plugins` marketplace is configured and `akm@akm-plugins` is installed | `claude plugin marketplace update akm-plugins`, then `claude plugin update akm@akm-plugins` |
1366
+ | Codex | `codex` is on `PATH` and `akm@akm-plugins` is installed from the `akm-plugins` marketplace | `codex plugin marketplace upgrade akm-plugins` (this also refreshes the installed plugin cache) |
1367
+ | OpenCode | `akm-opencode` is cached at `$XDG_CACHE_HOME/opencode/packages/akm-opencode@latest` (default `~/.cache/opencode/...`) and `opencode` is on `PATH` | If npm's `akm-opencode@latest` differs from the cached version and no OpenCode process is running: the cache directory is moved to the trash (never deleted), then `opencode debug config` is run from a temporary directory to fetch the new version. While OpenCode runs the update is `deferred` to a later `akm upgrade`. |
1368
+
1369
+ Claude Code does not update third-party marketplaces on its own (the Claude
1370
+ desktop app turns plugin updates off), and OpenCode never re-checks a cached
1371
+ plugin, so neither moves without this step. Codex refreshes its git
1372
+ marketplaces at every start.
1373
+
1374
+ The result gains a `plugins` array, one entry per harness:
1375
+ `{ "harness": "claude-code" | "codex" | "opencode", "outcome": ..., "from"?, "to"?, "message"? }`.
1376
+ `outcome` is `updated`, `current`, `skipped`, `deferred` or `failed`; under
1377
+ `--check` it can also be `pending` or `unknown`. `--check` does not fetch the
1378
+ Claude Code or Codex marketplaces, so for them it reports `unknown` (whether a
1379
+ newer build exists needs a fetch it does not do) rather than guessing; for
1380
+ OpenCode it compares the cached version with npm, so it reports `pending` when
1381
+ an update is due. A `failed` plugin makes `akm upgrade` exit `1` (not
1382
+ `--check`). On an up-to-date machine the text output prints nothing for
1383
+ plugins.
1384
+
1385
+ **Version lockstep.** The OpenCode plugin runs its own exact-pinned akm-cli
1386
+ in-process, so a CLI newer than the plugin's pin would run the plugin against
1387
+ databases it did not migrate. When the OpenCode plugin is present, the CLI
1388
+ target is the akm-cli that `akm-opencode@latest` depends on
1389
+ (`npm view akm-opencode@latest dependencies.akm-cli`), not the newest release.
1390
+ The result then carries `lockstep: { plugin, pinnedVersion, newestVersion, heldBack }`,
1391
+ `latestVersion` is the pinned version, the install names that exact version
1392
+ rather than `@latest`, and the text output says the CLI is held back. The CLI
1393
+ is never moved backwards to meet the pin. Claude Code and Codex have no
1394
+ in-process copy and are not part of this rule.
1395
+
1396
+ Lockstep fails closed. When the OpenCode plugin is cached but the pin cannot
1397
+ be read (the npm lookup failed, or `akm-opencode@latest` declares no
1398
+ `akm-cli`), the CLI is not upgraded: `updateAvailable` is `false`,
1399
+ `latestVersion` is the current version, and `lockstep` is
1400
+ `{ plugin, pinnedVersion: null, newestVersion, heldBack: true, reason }`. The
1401
+ OpenCode entry is `failed` and the run exits `1`; the next `akm upgrade`
1402
+ retries.
1403
+
1404
+ #### Prereleases (`--next`)
1405
+
1406
+ `akm upgrade --next` means "newest available, prereleases included". The result
1407
+ carries `channel: "next"` (`"latest"` otherwise); no other field changes.
1408
+
1409
+ - **CLI.** The target is the `next` dist-tag of `akm-cli` (read from the npm
1410
+ registry, so it works for binary installs too) when that is newer than the
1411
+ latest stable release; otherwise the latest stable release. It never moves
1412
+ backwards. npm, Bun and pnpm installs run `<manager> add|install -g akm-cli@<that exact version>`;
1413
+ standalone binaries download the GitHub release tagged `v<that version>`
1414
+ (prereleases are GitHub releases too) and verify its checksum as usual.
1415
+ - **OpenCode.** OpenCode resolves a bare `"akm-opencode"` in its `plugin` list
1416
+ to `@latest`, and caches `@next` in its own folder
1417
+ (`$XDG_CACHE_HOME/opencode/packages/akm-opencode@next`). akm does not edit
1418
+ your OpenCode config, so it can only follow `@next` when you ask for it:
1419
+
1420
+ ```json
1421
+ { "plugin": ["akm-opencode@next"] }
1422
+ ```
1423
+
1424
+ With that line in the global OpenCode config (`~/.config/opencode/opencode.json`
1425
+ or `.jsonc`, or the file named by `OPENCODE_CONFIG`), lockstep and the cache
1426
+ refresh use `akm-opencode@next` (its version and its `akm-cli` pin) instead of
1427
+ `@latest`. If `akm-opencode@next` is missing, older than `@latest`, or its
1428
+ `akm-cli` pin is unreadable, the CLI is held where it is and the OpenCode entry
1429
+ is `failed`, exactly as in the stable lockstep above.
1430
+
1431
+ Without that line the OpenCode entry is `skipped`, its message names the line
1432
+ to add, and lockstep stays against the `@latest` pin, so the CLI does not go
1433
+ past what OpenCode will run.
1434
+ - **Claude Code and Codex.** Unchanged: their plugin comes from the `akm-plugins`
1435
+ git marketplace, which has no prerelease channel and accepts any 0.9.x akm,
1436
+ prereleases included. Their entries carry a note saying so.
1437
+ - `--check --next` reports all of this and changes nothing.
1438
+
1439
+ #### Containers
1440
+
1441
+ With the plugin step, a container entrypoint needs only:
1442
+
1443
+ ```sh
1444
+ akm upgrade -q || true
1445
+ ( while sleep 86400; do akm upgrade -q; done ) & # long-running containers only
1446
+ exec "$@"
1447
+ ```
1448
+
1449
+ Codex hooks need one-time trust, and nobody can open `/hooks` in a container.
1450
+ Bake the trust entries into the image's `~/.codex/config.toml`, one per hook the
1451
+ plugin declares (today `session_start` and `user_prompt_submit`):
1452
+
1453
+ ```toml
1454
+ [hooks.state."akm@akm-plugins:plugin.json#hooks[0]:session_start:0:0"]
1455
+ trusted_hash = "sha256:<hash>"
1456
+
1457
+ [hooks.state."akm@akm-plugins:plugin.json#hooks[0]:user_prompt_submit:0:0"]
1458
+ trusted_hash = "sha256:<hash>"
1459
+ ```
1460
+
1461
+ Copy the two `trusted_hash` values from a machine where you have trusted the
1462
+ hooks in `/hooks` (they are in that machine's `~/.codex/config.toml`). The
1463
+ hashes stay the same across plugin version bumps and change only when a hook's
1464
+ command changes, so an upgraded plugin keeps running without a prompt.
1465
+
1353
1466
  Checksum verification is not optional and has no flag. If a release's
1354
1467
  `checksums.txt` is genuinely unreachable, the recovery hatch is the
1355
1468
  `AKM_UPGRADE_SKIP_CHECKSUM=1` environment variable (Internal — deliberately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.28-alpha.3",
3
+ "version": "0.9.28-alpha.5",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [