akm-cli 0.10.261015-beta → 0.10.261016-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +6 -4
- package/dist/assets/hints/cli-hints-short.md +4 -2
- package/dist/assets/opencode/akm-plugin.js +16 -8
- package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
- package/dist/commands/health/plugin-staleness.js +86 -22
- package/dist/commands/read/search-cli.js +2 -2
- package/dist/commands/read/show.js +15 -3
- package/dist/core/asset/markdown.js +9 -0
- package/dist/core/config/config-walker.js +35 -32
- package/dist/core/config/schema/connections.js +6 -3
- package/dist/core/config/schema/harnesses.js +8 -1
- package/dist/core/config/schema/primitives.js +12 -1
- package/dist/core/config/schema/sources-bundles.js +2 -2
- package/dist/core/npm-package.js +48 -13
- package/dist/output/renderers.js +2 -2
- package/dist/output/shapes/helpers.js +2 -0
- package/dist/output/text/show-format.js +7 -0
- package/dist/registry/semver.js +12 -0
- package/dist/scripts/akm-migrate-node.js +21629 -5738
- package/dist/scripts/akm-migrate.js +21632 -5741
- package/dist/tasks/backends/cron.js +19 -0
- package/docs/architecture/internals/health-advisories.md +1 -1
- package/docs/architecture/specs/ref.md +4 -3
- package/docs/architecture/testing/testing-workflow.md +7 -8
- package/docs/guides/discover-and-load.md +12 -4
- package/docs/guides/getting-started.md +3 -2
- package/docs/guides/use-with-any-agent.md +4 -1
- package/docs/maintainers/release-checklist.md +15 -8
- package/docs/reference/cli.md +22 -3
- package/package.json +4 -4
- package/schemas/akm-config.json +535 -302
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261016] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `akm show` returns a Markdown document's outline as `toc` (#1259): one
|
|
14
|
+
`{ level, text, slug, startLine }` entry per heading, at every `--detail`
|
|
15
|
+
level. `akm show <ref> --detail brief` is the outline without the body, and
|
|
16
|
+
every `slug` selects its section with `akm show <ref>#<slug>`, repeated
|
|
17
|
+
headings included. The outline is read from the file the section is cut
|
|
18
|
+
from. Fragment shows, non-Markdown assets and documents with no headings
|
|
19
|
+
carry no `toc`. The knowledge `action`, `akm show --help`, the unmatched
|
|
20
|
+
fragment error, `akm hints` and the OpenCode runtime's `akm_show` tool (which
|
|
21
|
+
takes `detail: "brief"`) now point agents at the outline first, then the
|
|
22
|
+
sections they need.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The Release workflow waits for CI on the commit it publishes, up to 25 minutes,
|
|
27
|
+
instead of failing when that run has not finished, so Release can be dispatched
|
|
28
|
+
right after the release commit is pushed. It still fails when CI failed or was
|
|
29
|
+
cancelled, and only the newest push or manual CI run counts: a pull request's
|
|
30
|
+
run tests a merge and, on a draft, skips the upgrade rehearsal.
|
|
31
|
+
`tests/release-check.sh` no longer repeats the upgrade rehearsal that CI runs on
|
|
32
|
+
the same commit.
|
|
33
|
+
- `bun run check:ci` runs the pre-push check the way CI does (in a Docker
|
|
34
|
+
container with the Bun and Node versions `ci.yml` pins, a clean frozen install,
|
|
35
|
+
`CI=true` and four test shards); `.github/CONTRIBUTING.md` says when to run it.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Two processes fetching the OpenCode binary akm's own runtime runs (or the local
|
|
40
|
+
embedder's ONNX Runtime files) into an empty cache at once no longer rename
|
|
41
|
+
their copies over each other (#1266). Each downloaded the package and renamed
|
|
42
|
+
its file into place, which unlinks a binary the other process is already
|
|
43
|
+
running (it then fails to start it again). One fetch now runs per cache
|
|
44
|
+
directory at a time, under a `fetch.lock` file in it (a dead holder's lock is
|
|
45
|
+
reclaimed); a process that finds the files in place once it holds the lock
|
|
46
|
+
downloads nothing, and a rename never replaces a file that is already whole.
|
|
47
|
+
- `akm health`'s `plugin-version` advisory no longer reports a Claude Code
|
|
48
|
+
plugin on the `next` channel as stale against the 0.9 stable release. It read
|
|
49
|
+
the newest version with a semver rule that skips every prerelease. It now reads
|
|
50
|
+
the channel the marketplace clone follows: on `next`, the release tag on that
|
|
51
|
+
branch's head; on the default branch, the newest stable tag; on a marketplace
|
|
52
|
+
added at a tag, that tag.
|
|
53
|
+
- When `crontab -l` cannot read the crontab spool (permission denied) in a
|
|
54
|
+
process started with no-new-privileges (an agent sandbox, a container, systemd
|
|
55
|
+
`NoNewPrivileges=`), akm says why: `crontab` reads the spool through its setgid
|
|
56
|
+
(or setuid) bit, which such a process does not grant. `akm health` and
|
|
57
|
+
`akm task` commands run from such a process used to repeat a bare
|
|
58
|
+
`fopen: Permission denied`. A refusal without no-new-privileges is reported as
|
|
59
|
+
before.
|
|
60
|
+
- `akm health`'s `opencode-plugin-version` advisory reads the OpenCode 1 plugin
|
|
61
|
+
cache of the spec the global OpenCode config names (#1265). It read only
|
|
62
|
+
`packages/akm-opencode`, but OpenCode 1 caches each spec in its own folder, so
|
|
63
|
+
a host with `"plugin": ["akm-opencode@next"]` (cached in
|
|
64
|
+
`packages/akm-opencode@next`) showed no row at all. It now reads the one folder
|
|
65
|
+
of the spec under `plugin`: a bare name is `akm-opencode@latest`, as
|
|
66
|
+
`akm upgrade` reads it, and `akm-opencode@next` is its own folder. A version
|
|
67
|
+
pin (`akm-opencode@<version>`) is reported as pinned, with no staleness
|
|
68
|
+
verdict. With no entry, or no folder for it, the check reads
|
|
69
|
+
`packages/akm-opencode` as before; other cached specs are not scanned.
|
|
70
|
+
|
|
71
|
+
### Security
|
|
72
|
+
|
|
73
|
+
- Zod moves from 3.25.76 to 4.6.7 (`zod` `^4.6.6`), closing GHSA-mrhx-c7xc-m3w5 /
|
|
74
|
+
CVE-2023-54404 (high): Zod before 4.6.6 can exhaust memory validating a large array,
|
|
75
|
+
and no Zod 3 release has the fix. akm validates its config files with Zod. Visible
|
|
76
|
+
changes: an integer above 2^53 - 1 in a config file is now rejected; validation
|
|
77
|
+
messages are Zod 4's (an unknown enum value reads `Invalid option: expected one of ...`);
|
|
78
|
+
`schemas/akm-config.json` is generated with `z.toJSONSchema` (the `zod-to-json-schema`
|
|
79
|
+
dev dependency is gone): `additionalProperties: true` is written `{}` (the same), integers
|
|
80
|
+
state the new `maximum: 9007199254740991`, and every map states its `propertyNames`.
|
|
81
|
+
|
|
9
82
|
## [0.10.261015] - 2026-10-27
|
|
10
83
|
|
|
11
84
|
### Changed
|
package/STABILITY.md
CHANGED
|
@@ -201,7 +201,8 @@ enumeration of the whole `proposal` noun group.
|
|
|
201
201
|
- `#fragment` is **input-only** and never stored. On markdown-document items
|
|
202
202
|
the core resolves it as a section selector — `akm show <ref>#<heading-slug>`
|
|
203
203
|
returns that one section, no fragment returns the whole item, and an
|
|
204
|
-
unmatched fragment lists the available slugs.
|
|
204
|
+
unmatched fragment lists the available slugs. A whole document's `show`
|
|
205
|
+
carries `toc`, every heading with the slug that selects it. Elsewhere it is an
|
|
205
206
|
adapter-owned selector opaque to the core. See
|
|
206
207
|
[`docs/architecture/specs/ref.md`](https://github.com/itlackey/akm/blob/main/docs/architecture/specs/ref.md).
|
|
207
208
|
- **Refs in prose** must be fully qualified (`bundle//conceptId`) or a native
|
|
@@ -49,7 +49,9 @@ akm curate "review architecture" --type workflow # Restrict to one asset type
|
|
|
49
49
|
## Show
|
|
50
50
|
|
|
51
51
|
Display an asset by ref from the local index and materialized bundle files only.
|
|
52
|
-
On a markdown document `#fragment` selects one section by heading slug.
|
|
52
|
+
On a markdown document `#fragment` selects one section by heading slug. For a
|
|
53
|
+
large document, `akm show <ref> --detail brief` returns its outline (`toc`, no
|
|
54
|
+
body); then `akm show <ref>#<slug>` reads only the sections you need.
|
|
53
55
|
|
|
54
56
|
```sh
|
|
55
57
|
akm show scripts/deploy.sh # Show script (returns run command)
|
|
@@ -57,9 +59,9 @@ akm show skills/code-review # Show skill (returns full content
|
|
|
57
59
|
akm show commands/release # Show command (returns template)
|
|
58
60
|
akm show agents/architect # Show agent (returns system prompt)
|
|
59
61
|
akm show workflows/ship-release # Show parsed workflow steps
|
|
60
|
-
akm show knowledge/guide # Whole document
|
|
62
|
+
akm show knowledge/guide # Whole document, plus its `toc`
|
|
63
|
+
akm show knowledge/guide --detail brief # Outline only: `toc` (level, text, slug, startLine), no body
|
|
61
64
|
akm show knowledge/guide#auth # Just the "Auth" section
|
|
62
|
-
akm show knowledge/guide#nope # Lists the available fragment slugs
|
|
63
65
|
akm show knowledge/my-doc # Show materialized local content
|
|
64
66
|
```
|
|
65
67
|
|
|
@@ -69,7 +71,7 @@ akm show knowledge/my-doc # Show materialized local content
|
|
|
69
71
|
| skill | `content` (full SKILL.md) |
|
|
70
72
|
| command | `template`, `description`, `parameters` |
|
|
71
73
|
| agent | `prompt`, `description`, `modelHint` |
|
|
72
|
-
| knowledge | `content` (whole document, or one section via `#fragment`) |
|
|
74
|
+
| knowledge | `content` (whole document, or one section via `#fragment`); `toc` (each heading's `slug`) on a whole document |
|
|
73
75
|
| workflow | `workflowTitle`, `workflowParameters`, `steps` |
|
|
74
76
|
| memory | `content` (recalled context) |
|
|
75
77
|
| env | `keys` (key names only — values and comment text never returned) |
|
|
@@ -6,7 +6,7 @@ You have access to a searchable library of scripts, skills, commands, agents, kn
|
|
|
6
6
|
|
|
7
7
|
For any task, follow this loop:
|
|
8
8
|
1. `akm curate "<task>"` — find the best matching asset
|
|
9
|
-
2. `akm show <ref>` — read the schema (field names and structure)
|
|
9
|
+
2. `akm show <ref>` — read the schema (field names and structure). For a large document, `akm show <ref> --detail brief` returns its outline (`toc`, no body); then `akm show <ref>#<slug>` reads only the sections you need.
|
|
10
10
|
3. Edit the workspace file using schema field names + task-specific values from your README
|
|
11
11
|
4. `akm feedback <ref> --positive` — record that the asset helped (it raises its ranking and does not trigger a rewrite). Record negative feedback only when the asset's content is wrong or stale, and say what is wrong and what it should say. `akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags it: the next improve run may repair its description, title or `when_to_use` from your reason, but not its text. A note that simply did not fit your task is not negative feedback: record nothing for it. Once you have verified the correct fact, add the exact fix: `--replace "<exact current text>" --with "<corrected text>" --source "<URL, command or file>"`. A note that is outdated can be marked with `--outdated`, or `--superseded-by <ref>` when another note replaces it. A failed akm command (e.g. `akm show` erroring) is not feedback on the asset — don't record it.
|
|
12
12
|
|
|
@@ -27,6 +27,8 @@ akm search "<query>" --type workflow # Filter to workflow assets
|
|
|
27
27
|
akm search "memories/projectA/" # List a subtree (conceptId prefix; trailing slash required)
|
|
28
28
|
akm search "<query>" --from all # Also search registries
|
|
29
29
|
akm show <ref> # View asset details
|
|
30
|
+
akm show <ref> --detail brief # Outline only: a document's `toc` (each heading's slug), no body
|
|
31
|
+
akm show <ref>#<slug> # One section of a document
|
|
30
32
|
akm help <command> # Show a command's options and subcommands
|
|
31
33
|
akm workflow run <ref> # Start a new run and execute it
|
|
32
34
|
akm remember "Deployment needs VPN access" # Record a memory in your bundle
|
|
@@ -58,7 +60,7 @@ akm search "<query>" --from registry # Search all registries (registry
|
|
|
58
60
|
| skill | Instructions to follow (read the full content) |
|
|
59
61
|
| command | A prompt template with placeholders to fill in |
|
|
60
62
|
| agent | A system prompt with model and tool hints |
|
|
61
|
-
| knowledge | A reference doc
|
|
63
|
+
| knowledge | A reference doc; `toc` lists its sections and `<ref>#<slug>` reads one |
|
|
62
64
|
| workflow | Parsed steps plus workflow-specific execution commands |
|
|
63
65
|
| memory | Recalled context (read the content for background information) |
|
|
64
66
|
| env | A `.env` file of configuration an app loads together (many vars; sensitive or not — all protected); key names only. Inject with `akm env run <ref> -- <cmd>` (the agent-safe path — values stay on disk). |
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// it in the inline config's `plugins` list (src/integrations/harnesses/opencode/runtime/). It runs inside OpenCode, so
|
|
7
7
|
// it imports no package: only Node's own child_process, to call back into akm.
|
|
8
8
|
//
|
|
9
|
-
// - akm's three read tools, each a run of the akm that started OpenCode, with `--detail agent --format json
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// that run's.
|
|
9
|
+
// - akm's three read tools, each a run of the akm that started OpenCode, with `--detail agent --format json`
|
|
10
|
+
// (`--detail brief` when akm_show asks for a document's outline). akm passes the argv that runs it in
|
|
11
|
+
// `AKM_OPENCODE_CLI`, a JSON array: the executable alone for a compiled akm, the runtime and the script for one
|
|
12
|
+
// run as a script (Node and `dist/akm`, say), so no shebang or shell is needed on any platform. The run's
|
|
13
|
+
// environment is the call's, so a stamped run's reads (`AKM_EVENT_SOURCE`) are recorded as that run's.
|
|
14
14
|
// - The environment of the commands the agent runs: the variables akm set for the OpenCode process (its roots and
|
|
15
15
|
// its OpenCode settings) are put back to what akm runs in, or removed, so the agent's shell sees the environment a
|
|
16
16
|
// harness's child would.
|
|
@@ -59,8 +59,16 @@ const TOOLS = [
|
|
|
59
59
|
name: "akm_show",
|
|
60
60
|
command: "show",
|
|
61
61
|
positional: "ref",
|
|
62
|
-
description:
|
|
63
|
-
|
|
62
|
+
description:
|
|
63
|
+
'Read one akm asset by its ref, as returned by akm_curate or akm_search. For a large document, call it with detail "brief" for its outline (toc, no body), then with ref#<slug> for only the sections you need.',
|
|
64
|
+
input: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
ref: { type: "string" },
|
|
68
|
+
detail: { type: "string", enum: ["brief"], description: "brief: the outline (toc) without the body" },
|
|
69
|
+
},
|
|
70
|
+
required: ["ref"],
|
|
71
|
+
},
|
|
64
72
|
},
|
|
65
73
|
];
|
|
66
74
|
|
|
@@ -78,7 +86,7 @@ function cliArguments(tool, input) {
|
|
|
78
86
|
...(type && tool.positional !== "ref" ? ["--type", type] : []),
|
|
79
87
|
...(limit && tool.positional !== "ref" ? ["--limit", limit] : []),
|
|
80
88
|
"--detail",
|
|
81
|
-
"agent",
|
|
89
|
+
tool.positional === "ref" && input?.detail === "brief" ? "brief" : "agent",
|
|
82
90
|
"--format",
|
|
83
91
|
"json",
|
|
84
92
|
];
|
|
@@ -9,7 +9,9 @@ Ground rules for this unit:
|
|
|
9
9
|
|
|
10
10
|
1. Pull knowledge on demand instead of guessing: `akm search '<query>'` to find
|
|
11
11
|
relevant assets, `akm show <ref>` to read one, `akm curate '<query>'` to let
|
|
12
|
-
akm select the best match. Only pull what this unit actually needs
|
|
12
|
+
akm select the best match. Only pull what this unit actually needs: for a
|
|
13
|
+
large document, `akm show <ref> --detail brief` returns its outline (`toc`,
|
|
14
|
+
no body), then `akm show <ref>#<slug>` reads one section.
|
|
13
15
|
2. Environment values and secrets are provided through your process
|
|
14
16
|
environment when the workflow declares them. Never print secret values to
|
|
15
17
|
stdout or embed them in your answer. If you need an env file path, use
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* local marketplace clone is not proof of what is newest upstream — the
|
|
36
36
|
* incident above involved a clone that hadn't seen the fix's tag at all — so
|
|
37
37
|
* the only way to ever detect drift is to ask the remote what tags exist.
|
|
38
|
-
* That query is a `git ls-remote --
|
|
38
|
+
* That query is a `git ls-remote --symref` (lists refs; fetches nothing,
|
|
39
39
|
* writes nothing) with a short timeout, and any failure (offline, no
|
|
40
40
|
* remote, timeout) degrades to "installed version reported, no staleness
|
|
41
41
|
* claim" rather than a false positive or a hang. Like `cli-version`, this
|
|
@@ -54,7 +54,8 @@ import os from "node:os";
|
|
|
54
54
|
import path from "node:path";
|
|
55
55
|
import { OPENCODE_V1, OPENCODE_V2, readPackageVersion, } from "../../integrations/harnesses/opencode/plugin-layout.js";
|
|
56
56
|
import { opencodeCacheDir } from "../../integrations/harnesses/opencode/roots.js";
|
|
57
|
-
import { isExactSemver,
|
|
57
|
+
import { highestVersion, isExactSemver, isPrerelease, isSemverRange, satisfiesRange } from "../../registry/semver.js";
|
|
58
|
+
import { openCodeConfigSpec } from "../sources/plugin-upgrade.js";
|
|
58
59
|
/**
|
|
59
60
|
* Root directory holding Claude Code's plugin cache + marketplace clones.
|
|
60
61
|
* Resolved per call (not memoized) so `AKM_CLAUDE_PLUGINS_DIR` can be set
|
|
@@ -78,12 +79,26 @@ function defaultOpencodeCacheRoot() {
|
|
|
78
79
|
}
|
|
79
80
|
const OPENCODE_PLUGIN_LOCATIONS = [
|
|
80
81
|
{
|
|
81
|
-
// OpenCode 1: <cache>/packages
|
|
82
|
+
// OpenCode 1: <cache>/packages/<spec>/node_modules/akm-cli, the one spec the global config names (#1265).
|
|
82
83
|
flavor: OPENCODE_V1,
|
|
83
84
|
advisory: "opencode-plugin-version",
|
|
84
85
|
label: "OpenCode's",
|
|
85
86
|
remedy: "update the OpenCode plugin",
|
|
86
|
-
folders: (cacheRoot) =>
|
|
87
|
+
folders: (cacheRoot) => {
|
|
88
|
+
// OpenCode caches each spec in its own folder, and a bare name is its `@latest`, as `akm upgrade` reads it. With
|
|
89
|
+
// no config entry, or no folder for the spec, the bare `packages/akm-opencode` is read, as this check always has.
|
|
90
|
+
const { pkg } = OPENCODE_V1;
|
|
91
|
+
const bare = { folder: path.join(cacheRoot, OPENCODE_V1.cacheFolder(pkg)) };
|
|
92
|
+
const named = openCodeConfigSpec(OPENCODE_V1)?.spec;
|
|
93
|
+
if (named === undefined)
|
|
94
|
+
return [bare];
|
|
95
|
+
const spec = named === pkg ? `${pkg}@latest` : named;
|
|
96
|
+
const folder = path.join(cacheRoot, OPENCODE_V1.cacheFolder(spec));
|
|
97
|
+
if (!fs.existsSync(folder))
|
|
98
|
+
return [bare];
|
|
99
|
+
const version = spec.slice(pkg.length + 1);
|
|
100
|
+
return [{ spec, folder, ...(isExactSemver(version) ? { pinned: version } : {}) }];
|
|
101
|
+
},
|
|
87
102
|
},
|
|
88
103
|
{
|
|
89
104
|
// OpenCode 2: <cache>/npm/akm-opencode-v2@<spec>/<epoch-ms>/node_modules/akm-cli, newest build per spec.
|
|
@@ -110,11 +125,11 @@ const OPENCODE_PLUGIN_LOCATIONS = [
|
|
|
110
125
|
*/
|
|
111
126
|
function findBundledAkm(plugin, cacheRoot) {
|
|
112
127
|
const found = [];
|
|
113
|
-
for (const { spec, folder } of plugin.folders(cacheRoot)) {
|
|
128
|
+
for (const { spec, pinned, folder } of plugin.folders(cacheRoot)) {
|
|
114
129
|
const nodeModules = plugin.flavor.nodeModulesDir(folder);
|
|
115
130
|
const version = nodeModules && readPackageVersion(path.join(nodeModules, "akm-cli", "package.json"));
|
|
116
131
|
if (version !== undefined)
|
|
117
|
-
found.push(spec ? { spec,
|
|
132
|
+
found.push({ ...(spec ? { spec } : {}), ...(pinned ? { pinned } : {}), version });
|
|
118
133
|
}
|
|
119
134
|
return found;
|
|
120
135
|
}
|
|
@@ -122,6 +137,17 @@ function buildOpencodeBundledAdvisory(plugin, bundled, cliVersion) {
|
|
|
122
137
|
const bundledVersion = bundled.version;
|
|
123
138
|
const stale = bundledVersion !== cliVersion;
|
|
124
139
|
const where = bundled.spec ? ` (${bundled.spec})` : "";
|
|
140
|
+
if (bundled.pinned) {
|
|
141
|
+
// The config chose this build: there is nothing to update it to, so no staleness verdict.
|
|
142
|
+
return {
|
|
143
|
+
name: plugin.advisory,
|
|
144
|
+
kind: "deterministic",
|
|
145
|
+
status: "pass",
|
|
146
|
+
confidence: "high",
|
|
147
|
+
message: `${plugin.label} plugin is pinned to v${bundled.pinned}${where}; its bundled akm-cli is v${bundledVersion}, and a pinned plugin is not compared with the running CLI v${cliVersion}.`,
|
|
148
|
+
evidence: { bundledVersion, cliVersion, spec: bundled.spec, pinnedVersion: bundled.pinned },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
125
151
|
return {
|
|
126
152
|
name: plugin.advisory,
|
|
127
153
|
kind: "deterministic",
|
|
@@ -161,7 +187,7 @@ function detectInstalledPlugins(pluginsRoot) {
|
|
|
161
187
|
}
|
|
162
188
|
if (versions.length === 0)
|
|
163
189
|
continue;
|
|
164
|
-
const latest =
|
|
190
|
+
const latest = highestVersion(versions);
|
|
165
191
|
if (!latest)
|
|
166
192
|
continue;
|
|
167
193
|
const versionDir = path.join(pluginDir, latest);
|
|
@@ -204,16 +230,31 @@ function readVersionRange(pluginDir) {
|
|
|
204
230
|
}
|
|
205
231
|
const LS_REMOTE_TIMEOUT_MS = 5_000;
|
|
206
232
|
/**
|
|
207
|
-
* `git ls-remote --
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
233
|
+
* `git ls-remote --symref origin` against the marketplace clone's configured remote (lists refs only, fetches no
|
|
234
|
+
* objects, writes no local refs), read for the channel the clone follows:
|
|
235
|
+
*
|
|
236
|
+
* - **The remote's default branch** (stable): every stable release tag; the newest is the highest of them.
|
|
237
|
+
* - **Any other branch** (a channel such as `next`): the release tags on that branch's head. A channel branch is
|
|
238
|
+
* reset to each build it publishes, so its head IS the newest build of the channel, whatever semver order says
|
|
239
|
+
* about other tags (retired build schemes can sort above current builds).
|
|
240
|
+
* - **No branch** (a marketplace added at a tag ref, which Claude Code clones detached): the release tag the clone
|
|
241
|
+
* is pinned to. A pinned marketplace never offers anything newer, so `claude plugin update` could not act on a
|
|
242
|
+
* newer tag elsewhere. A detached clone at an untagged commit reads like the default branch.
|
|
243
|
+
*
|
|
244
|
+
* Returns `undefined` (never throws) when the directory is not a git checkout, has no `origin` remote, or the
|
|
245
|
+
* command fails/times out (e.g. offline): all "cannot determine availability", not "up to date". A channel head
|
|
246
|
+
* with no release tag returns `[]`, which reads the same way.
|
|
212
247
|
*/
|
|
213
248
|
const realListRemoteTags = (marketplaceDir) => {
|
|
214
249
|
let result;
|
|
250
|
+
let branch;
|
|
215
251
|
try {
|
|
216
|
-
|
|
252
|
+
branch =
|
|
253
|
+
spawnSync("git", ["-C", marketplaceDir, "branch", "--show-current"], {
|
|
254
|
+
encoding: "utf8",
|
|
255
|
+
timeout: LS_REMOTE_TIMEOUT_MS,
|
|
256
|
+
}).stdout?.trim() ?? "";
|
|
257
|
+
result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--symref", "origin"], {
|
|
217
258
|
encoding: "utf8",
|
|
218
259
|
timeout: LS_REMOTE_TIMEOUT_MS,
|
|
219
260
|
});
|
|
@@ -223,16 +264,39 @@ const realListRemoteTags = (marketplaceDir) => {
|
|
|
223
264
|
}
|
|
224
265
|
if (result.status !== 0 || !result.stdout)
|
|
225
266
|
return undefined;
|
|
226
|
-
const
|
|
267
|
+
const refs = result.stdout
|
|
227
268
|
.split("\n")
|
|
228
269
|
.map((line) => line.trim())
|
|
229
270
|
.filter(Boolean)
|
|
230
|
-
.map((line) => line.split("\t")
|
|
231
|
-
|
|
232
|
-
.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
271
|
+
.map((line) => line.split("\t"));
|
|
272
|
+
const defaultBranch = refs
|
|
273
|
+
.find(([value, name]) => name === "HEAD" && value?.startsWith("ref: refs/heads/"))?.[0]
|
|
274
|
+
?.replace(/^ref: refs\/heads\//, "");
|
|
275
|
+
const tags = refs
|
|
276
|
+
.filter(([, name]) => typeof name === "string" && name.startsWith("refs/tags/"))
|
|
277
|
+
.map(([sha, name]) => ({
|
|
278
|
+
sha: sha,
|
|
279
|
+
version: name
|
|
280
|
+
.replace(/^refs\/tags\//, "")
|
|
281
|
+
.replace(/\^\{\}$/, "")
|
|
282
|
+
.replace(/^v/, ""),
|
|
283
|
+
}))
|
|
284
|
+
.filter((tag) => isExactSemver(tag.version));
|
|
285
|
+
const tagsAt = (sha) => [
|
|
286
|
+
...new Set(tags.filter((tag) => tag.sha === sha).map((tag) => tag.version)),
|
|
287
|
+
];
|
|
288
|
+
const onChannelBranch = branch !== "" && defaultBranch !== undefined && branch !== defaultBranch;
|
|
289
|
+
if (onChannelBranch)
|
|
290
|
+
return tagsAt(refs.find(([, name]) => name === `refs/heads/${branch}`)?.[0]);
|
|
291
|
+
if (branch === "") {
|
|
292
|
+
const pinned = tagsAt(spawnSync("git", ["-C", marketplaceDir, "rev-parse", "HEAD"], {
|
|
293
|
+
encoding: "utf8",
|
|
294
|
+
timeout: LS_REMOTE_TIMEOUT_MS,
|
|
295
|
+
}).stdout?.trim());
|
|
296
|
+
if (pinned.length > 0)
|
|
297
|
+
return pinned;
|
|
298
|
+
}
|
|
299
|
+
return [...new Set(tags.map((tag) => tag.version).filter((version) => !isPrerelease(version)))];
|
|
236
300
|
};
|
|
237
301
|
/**
|
|
238
302
|
* Build one `plugin-version` advisory per detected `akm` harness plugin,
|
|
@@ -263,14 +327,14 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
263
327
|
let availableVersion;
|
|
264
328
|
try {
|
|
265
329
|
const tags = probe && fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
|
|
266
|
-
availableVersion = tags && tags.length > 0 ?
|
|
330
|
+
availableVersion = tags && tags.length > 0 ? highestVersion(tags) : undefined;
|
|
267
331
|
}
|
|
268
332
|
catch {
|
|
269
333
|
availableVersion = undefined;
|
|
270
334
|
}
|
|
271
335
|
const stale = availableVersion !== undefined &&
|
|
272
336
|
plugin.version !== availableVersion &&
|
|
273
|
-
|
|
337
|
+
highestVersion([plugin.version, availableVersion]) === availableVersion;
|
|
274
338
|
// Point 3: does the plugin's own declared range admit the running CLI?
|
|
275
339
|
const versionRange = readVersionRange(plugin.pluginDir);
|
|
276
340
|
const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
|
|
@@ -177,7 +177,7 @@ function rejectExtraShowPositionals(positionals, ref) {
|
|
|
177
177
|
if (extra.length === 0)
|
|
178
178
|
return;
|
|
179
179
|
throw new UsageError(`akm show takes a single ref, but got ${extra.map((token) => `"${token}"`).join(" ")} after "${ref}". ` +
|
|
180
|
-
`Use \`akm show ${ref}#<heading-slug>\` to read one section, or \`akm show ${ref}\` for the whole item.`, "INVALID_FLAG_VALUE",
|
|
180
|
+
`Use \`akm show ${ref}#<heading-slug>\` to read one section, or \`akm show ${ref}\` for the whole item.`, "INVALID_FLAG_VALUE", `Run \`akm show ${ref} --detail brief\` for the document's outline (\`toc\`, no body).`);
|
|
181
181
|
}
|
|
182
182
|
export const showCommand = defineJsonCommand({
|
|
183
183
|
meta: {
|
|
@@ -187,7 +187,7 @@ export const showCommand = defineJsonCommand({
|
|
|
187
187
|
args: {
|
|
188
188
|
ref: {
|
|
189
189
|
type: "positional",
|
|
190
|
-
description: "Asset ref ([bundle//]conceptId[#fragment]). On a markdown document `#fragment` selects one section by heading slug
|
|
190
|
+
description: "Asset ref ([bundle//]conceptId[#fragment]). On a markdown document `#fragment` selects one section by heading slug; `--detail brief` returns the outline (`toc`, each heading's slug) without the body. Example: `akm show knowledge/guide.md#auth`.",
|
|
191
191
|
required: true,
|
|
192
192
|
},
|
|
193
193
|
filter: {
|
|
@@ -20,7 +20,7 @@ import { recognizeMatch } from "../../core/adapter/recognize-match.js";
|
|
|
20
20
|
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
21
21
|
import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
22
22
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
23
|
-
import { extractSection, markdownFragmentSlugs } from "../../core/asset/markdown.js";
|
|
23
|
+
import { extractSection, markdownFragmentSlugs, markdownToc } from "../../core/asset/markdown.js";
|
|
24
24
|
import { buildMarkdownLeadContext, fragmentForSelector, MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS, } from "../../core/asset/markdown-fragments.js";
|
|
25
25
|
import { displayRef, displayRefForConceptId, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
26
26
|
import { META_DIR, parseMetaRef, readMetaFile } from "../../core/asset/stash-meta.js";
|
|
@@ -297,8 +297,10 @@ export async function showLocal(input) {
|
|
|
297
297
|
const indexedFragmentContent = opaqueFragmentSelector ? indexedFragment?.content : undefined;
|
|
298
298
|
const indexedRenderer = rendererForIndexedEntry(indexedEntry, fileCtx);
|
|
299
299
|
let response;
|
|
300
|
+
let markdown = false;
|
|
300
301
|
try {
|
|
301
302
|
if (indexedRenderer === null) {
|
|
303
|
+
markdown = path.extname(assetPath).toLowerCase() === ".md";
|
|
302
304
|
response = buildIndexedProjectionResponse(indexedEntry, assetPath, parsed.fragment, indexedFragmentContent);
|
|
303
305
|
}
|
|
304
306
|
else {
|
|
@@ -315,8 +317,9 @@ export async function showLocal(input) {
|
|
|
315
317
|
const renderDefaultBundle = config.defaultBundle ?? (source?.isDefault === true ? renderBundle : undefined);
|
|
316
318
|
const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, renderBundle, renderDefaultBundle);
|
|
317
319
|
response = renderer.buildShowResponse(renderCtx);
|
|
320
|
+
markdown = match.renderer.endsWith("-md");
|
|
318
321
|
if (parsed.fragment !== undefined) {
|
|
319
|
-
if (!
|
|
322
|
+
if (!markdown) {
|
|
320
323
|
warn(`Fragment "#${parsed.fragment}" was ignored: ${makeBundleRef(parsed.bundle, parsed.conceptId)} is not a Markdown document, so heading fragments do not apply. Showing the whole asset.`);
|
|
321
324
|
}
|
|
322
325
|
else {
|
|
@@ -324,6 +327,13 @@ export async function showLocal(input) {
|
|
|
324
327
|
}
|
|
325
328
|
}
|
|
326
329
|
}
|
|
330
|
+
// The outline is read from the bytes `#<slug>` resolves against, so every
|
|
331
|
+
// listed slug selects its section even when the index is stale.
|
|
332
|
+
if (markdown && parsed.fragment === undefined) {
|
|
333
|
+
const toc = markdownToc(fileCtx.content());
|
|
334
|
+
if (toc.length > 0)
|
|
335
|
+
response.toc = toc;
|
|
336
|
+
}
|
|
327
337
|
}
|
|
328
338
|
catch (error) {
|
|
329
339
|
throwIndexedPathNotFound(error, input.ref);
|
|
@@ -598,6 +608,7 @@ function applyMarkdownResponsePayload(response, content) {
|
|
|
598
608
|
else
|
|
599
609
|
response.content = content;
|
|
600
610
|
}
|
|
611
|
+
const SHOW_OUTLINE_HINT = "Run `akm show <ref> --detail brief` for the document's outline (`toc`, no body), then `akm show <ref>#<slug>` for one section.";
|
|
601
612
|
function requireMarkdownSection(content, fragment, name) {
|
|
602
613
|
const section = extractSection(content, fragment);
|
|
603
614
|
if (section)
|
|
@@ -609,7 +620,7 @@ function requireMarkdownSection(content, fragment, name) {
|
|
|
609
620
|
}
|
|
610
621
|
const available = markdownFragmentSlugs(content);
|
|
611
622
|
throw new NotFoundError(`Fragment "#${fragment}" not found in ${name}.` +
|
|
612
|
-
(available.length > 0 ? ` Available fragments: ${available.map((slug) => `#${slug}`).join(", ")}.` : ""));
|
|
623
|
+
(available.length > 0 ? ` Available fragments: ${available.map((slug) => `#${slug}`).join(", ")}.` : ""), "ASSET_NOT_FOUND", SHOW_OUTLINE_HINT);
|
|
613
624
|
}
|
|
614
625
|
/**
|
|
615
626
|
* Build a reduced brief response from a full ShowResponse.
|
|
@@ -625,6 +636,7 @@ function buildBriefResponse(full, assetPath) {
|
|
|
625
636
|
...(summary.ref ? { ref: summary.ref } : {}),
|
|
626
637
|
...(summary.description ? { description: summary.description } : {}),
|
|
627
638
|
...(summary.action ? { action: summary.action } : {}),
|
|
639
|
+
...(full.toc ? { toc: full.toc } : {}),
|
|
628
640
|
...(summary.run ? { run: summary.run } : {}),
|
|
629
641
|
...(summary.origin !== undefined ? { origin: summary.origin } : {}),
|
|
630
642
|
...(full.editable !== undefined ? { editable: full.editable } : {}),
|
|
@@ -16,6 +16,15 @@ export function markdownHeadingSlug(heading) {
|
|
|
16
16
|
export function markdownFragmentSlugs(content) {
|
|
17
17
|
return uniqueHeadingSlugs(parseMarkdownToc(content).headings).filter(Boolean);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* The headings `extractSection` can select, with the slug it resolves each by.
|
|
21
|
+
* A heading with no sluggable text has no selector, so it is left out.
|
|
22
|
+
*/
|
|
23
|
+
export function markdownToc(content) {
|
|
24
|
+
const headings = parseMarkdownToc(content).headings;
|
|
25
|
+
const slugs = uniqueHeadingSlugs(headings);
|
|
26
|
+
return headings.flatMap((heading, index) => slugs[index] ? [{ level: heading.level, text: heading.text, slug: slugs[index], startLine: heading.line }] : []);
|
|
27
|
+
}
|
|
19
28
|
/** One slug per heading, in order; a heading with no sluggable text gets "". */
|
|
20
29
|
export function uniqueHeadingSlugs(headings) {
|
|
21
30
|
const used = new Set();
|
|
@@ -27,7 +27,7 @@ import { hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED } from "../
|
|
|
27
27
|
import { warnOnce } from "../warn.js";
|
|
28
28
|
import { AkmConfigBaseSchema, listTopLevelConfigKeys } from "./config-schema.js";
|
|
29
29
|
import { deepMergeConfig } from "./deep-merge.js";
|
|
30
|
-
import { isEnvReference } from "./schema/primitives.js";
|
|
30
|
+
import { isEnvReference, OpaqueFieldSchema } from "./schema/primitives.js";
|
|
31
31
|
/**
|
|
32
32
|
* Parse a dotted path into segments. Empty segments are rejected. Bracket
|
|
33
33
|
* notation (e.g. `sources[0]`) is NOT supported — arrays are set as JSON.
|
|
@@ -46,33 +46,39 @@ function parsePath(dotted) {
|
|
|
46
46
|
function unwrap(schema) {
|
|
47
47
|
let current = schema;
|
|
48
48
|
while (true) {
|
|
49
|
-
if (current instanceof z.ZodOptional
|
|
50
|
-
current
|
|
51
|
-
|
|
52
|
-
else if (current instanceof z.ZodDefault) {
|
|
53
|
-
current = current._def.innerType;
|
|
54
|
-
}
|
|
55
|
-
else if (current instanceof z.ZodNullable) {
|
|
56
|
-
current = current._def.innerType;
|
|
57
|
-
}
|
|
58
|
-
else if (current instanceof z.ZodCatch) {
|
|
49
|
+
if (current instanceof z.ZodOptional ||
|
|
50
|
+
current instanceof z.ZodDefault ||
|
|
51
|
+
current instanceof z.ZodNullable ||
|
|
59
52
|
// `.catch(...)` wraps an inner schema with a fallback value.
|
|
60
|
-
current
|
|
53
|
+
current instanceof z.ZodCatch ||
|
|
54
|
+
current instanceof z.ZodReadonly) {
|
|
55
|
+
current = current.def.innerType;
|
|
61
56
|
}
|
|
62
|
-
else if (current instanceof z.
|
|
63
|
-
// `.
|
|
64
|
-
// descend into the
|
|
65
|
-
|
|
66
|
-
current =
|
|
67
|
-
}
|
|
68
|
-
else if (current instanceof z.ZodReadonly) {
|
|
69
|
-
current = current._def.innerType;
|
|
57
|
+
else if (current instanceof z.ZodPipe) {
|
|
58
|
+
// `.transform()` pipes the schema into a transform and `z.preprocess()` pipes a transform into it:
|
|
59
|
+
// descend into the side that is a schema. (`.refine()` and `.superRefine()` wrap nothing.)
|
|
60
|
+
const { in: input, out } = current.def;
|
|
61
|
+
current = (input instanceof z.ZodTransform ? out : input);
|
|
70
62
|
}
|
|
71
63
|
else {
|
|
72
64
|
return current;
|
|
73
65
|
}
|
|
74
66
|
}
|
|
75
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The schema of the keys an object takes without naming them (`index.<passName>`,
|
|
70
|
+
* a connection's request fields), or `undefined` when its extra keys are not
|
|
71
|
+
* ones akm defines. `.passthrough()` gives an object an `unknown` catchall, so
|
|
72
|
+
* only `OpaqueFieldSchema` itself says "any key, contents left to the endpoint".
|
|
73
|
+
*/
|
|
74
|
+
function namedCatchall(object) {
|
|
75
|
+
const catchall = object.def.catchall;
|
|
76
|
+
if (catchall === undefined || catchall instanceof z.ZodNever)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (catchall instanceof z.ZodUnknown && catchall !== OpaqueFieldSchema)
|
|
79
|
+
return undefined;
|
|
80
|
+
return catchall;
|
|
81
|
+
}
|
|
76
82
|
/** True for a schema that accepts any value (`z.unknown()`): its contents are not keys akm defines. */
|
|
77
83
|
export function isOpaqueSchema(schema) {
|
|
78
84
|
return unwrap(schema) instanceof z.ZodUnknown;
|
|
@@ -91,13 +97,10 @@ export function resolveSchemaAt(path, config, raw) {
|
|
|
91
97
|
const next = schema.shape[segment];
|
|
92
98
|
if (!next) {
|
|
93
99
|
// Catchall (e.g. index.<passName>) — descend into the catchall schema.
|
|
94
|
-
const catchall = schema
|
|
95
|
-
if (
|
|
96
|
-
schema = catchall;
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
100
|
+
const catchall = namedCatchall(schema);
|
|
101
|
+
if (!catchall)
|
|
99
102
|
return undefined;
|
|
100
|
-
|
|
103
|
+
schema = catchall;
|
|
101
104
|
}
|
|
102
105
|
else {
|
|
103
106
|
schema = next;
|
|
@@ -106,13 +109,13 @@ export function resolveSchemaAt(path, config, raw) {
|
|
|
106
109
|
else if (schema instanceof z.ZodRecord) {
|
|
107
110
|
// Named records (connections, harnesses, improve.strategies, sources, etc.)
|
|
108
111
|
// accept any string key — descend into the value schema.
|
|
109
|
-
schema = schema.
|
|
112
|
+
schema = schema.def.valueType;
|
|
110
113
|
}
|
|
111
114
|
else if (schema instanceof z.ZodUnion) {
|
|
112
|
-
const option = selectUnionObjectOption(schema.
|
|
115
|
+
const option = selectUnionObjectOption(schema.options, segment, existing, index === path.length - 1 ? raw : undefined);
|
|
113
116
|
if (!option)
|
|
114
117
|
return undefined;
|
|
115
|
-
schema = (option.shape[segment] ?? option
|
|
118
|
+
schema = (option.shape[segment] ?? namedCatchall(option));
|
|
116
119
|
}
|
|
117
120
|
else {
|
|
118
121
|
// Cannot descend into a non-object schema.
|
|
@@ -145,7 +148,7 @@ function selectUnionObjectOption(candidates, segment, existing, raw) {
|
|
|
145
148
|
}
|
|
146
149
|
return (options.find((option) => segment in option.shape) ??
|
|
147
150
|
// A model entry such as `{ model, reasoning_effort }` holds request fields the object does not name.
|
|
148
|
-
options.find((option) =>
|
|
151
|
+
options.find((option) => namedCatchall(option) !== undefined));
|
|
149
152
|
}
|
|
150
153
|
/**
|
|
151
154
|
* Get the value at the dotted path from a config-shaped object. Returns
|
|
@@ -350,7 +353,7 @@ function coerceForSchema(schema, raw, key) {
|
|
|
350
353
|
return raw;
|
|
351
354
|
}
|
|
352
355
|
if (target instanceof z.ZodLiteral) {
|
|
353
|
-
return typeof target.
|
|
356
|
+
return typeof target.value === "string" ? raw : target.value;
|
|
354
357
|
}
|
|
355
358
|
if (target instanceof z.ZodArray || target instanceof z.ZodObject || target instanceof z.ZodRecord) {
|
|
356
359
|
if (raw === "" || raw === "null")
|
|
@@ -366,7 +369,7 @@ function coerceForSchema(schema, raw, key) {
|
|
|
366
369
|
if (target instanceof z.ZodUnion) {
|
|
367
370
|
// Try each option in order. Use the first that the schema accepts after
|
|
368
371
|
// coercion. Falls through to JSON parsing if all fail.
|
|
369
|
-
const options = target.
|
|
372
|
+
const options = target.options;
|
|
370
373
|
let lastErr;
|
|
371
374
|
for (const opt of options) {
|
|
372
375
|
try {
|