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 +39 -0
- package/dist/assets/hints/cli-hints-full.md +3 -2
- package/dist/commands/sources/plugin-upgrade.js +413 -0
- package/dist/commands/sources/self-update.js +27 -5
- package/dist/commands/sources/sources-cli.js +23 -11
- package/dist/core/trash.js +73 -0
- package/dist/indexer/walk/matchers.js +3 -2
- package/dist/output/text/command-format.js +27 -1
- package/dist/scripts/akm-migrate-node.js +26 -2
- package/dist/scripts/akm-migrate.js +26 -2
- package/docs/integration/bundling-akm.md +6 -1
- package/docs/reference/cli.md +116 -3
- package/package.json +1 -1
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 #
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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: {
|
|
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: {
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
package/docs/reference/cli.md
CHANGED
|
@@ -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,
|
|
1340
|
-
akm upgrade --check #
|
|
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` |
|
|
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
|
+
"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": [
|