akm-cli 0.10.261019 → 0.10.261021
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 +46 -0
- package/STABILITY.md +0 -8
- package/dist/assets/templates/html/metrics.html +14 -8
- package/dist/assets/ui/overview.js +2 -2
- package/dist/commands/health/llm-usage.js +16 -2
- package/dist/commands/health/plugin-staleness.js +126 -112
- package/dist/commands/health/stash-exposure.js +23 -5
- package/dist/commands/health/type-directory-check.js +17 -49
- package/dist/commands/health.js +45 -15
- package/dist/commands/metrics/collect.js +9 -1
- package/dist/commands/metrics/metrics-cli.js +1 -1
- package/dist/commands/metrics/report-view.js +20 -9
- package/dist/commands/sources/plugin-channels.js +147 -0
- package/dist/commands/sources/plugin-upgrade.js +341 -59
- package/dist/commands/sources/self-update.js +6 -3
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/sources-cli.js +15 -2
- package/dist/core/common.js +4 -1
- package/dist/indexer/passes/metadata.js +8 -6
- package/dist/indexer/walk/matchers.js +10 -0
- package/dist/output/text/command-format.js +10 -3
- package/dist/scripts/akm-migrate-node.js +69 -40
- package/dist/scripts/akm-migrate.js +69 -40
- package/dist/workflows/exec/param-secrets.js +29 -1
- package/docs/architecture/internals/health-advisories.md +3 -3
- package/docs/architecture/testing/testing-workflow.md +13 -7
- package/docs/reference/cli.md +39 -11
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ 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.261021] - 2026-10-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `akm upgrade --channel latest|next` switches the CLI and installed Claude,
|
|
14
|
+
Codex and OpenCode plugins together, preserving unrelated native settings and
|
|
15
|
+
writing backups. `--check` shows channel mismatches; an older target requires
|
|
16
|
+
`--allow-downgrade` (#1285).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `akm metrics` replaces `usage.selectRate` with `usage.selectsPerSearch` and
|
|
21
|
+
adds `usage.totals.searchesWithHits`. Selects carry no source or search id, so
|
|
22
|
+
the figure is an unbounded ratio, not a conversion percentage: eight selects
|
|
23
|
+
after one search read 8.00, not 800% (#1293).
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A model call whose configured deadline is longer than five minutes is no
|
|
28
|
+
longer cut off at five minutes by Bun's socket-idle timer (#1295).
|
|
29
|
+
- Workflow params that are ordinary file paths (absolute, relative, Windows,
|
|
30
|
+
with dates, digits or a UUID directory) are no longer warned about or redacted
|
|
31
|
+
as secrets; credential-named keys, known token prefixes and token-shaped path
|
|
32
|
+
segments still are (#1292).
|
|
33
|
+
- `akm bundle add --writable` on a bundle installed read-only from Git makes a
|
|
34
|
+
fresh checkout instead of failing with "is not a checkout", keeping the
|
|
35
|
+
bundle's name, components, order and credential (#1296).
|
|
36
|
+
- `akm health` checks every configured git bundle for tracked `env/` or
|
|
37
|
+
`secrets/` files with a remote, not only the working stash, and names the
|
|
38
|
+
bundle in each warning. A bundle whose `.gitignore` carries
|
|
39
|
+
`# akm: intentionally track env and secrets` is not reported; the warning
|
|
40
|
+
names that line as the way to acknowledge intentional tracking (#1291).
|
|
41
|
+
- `akm health`'s `type-directory-disagreement` warning uses the indexer's own
|
|
42
|
+
directory rules, so `scripts/README.md` or `knowledge/example.sh` no longer
|
|
43
|
+
warn, and it checks only `akm`-adapter entries inside their component root (#1288).
|
|
44
|
+
- `akm metrics` reports token usage as unknown, with the number of calls that
|
|
45
|
+
reported it, instead of zero when the provider sent none. A call reporting
|
|
46
|
+
prompt and completion tokens but no total now counts their sum in the LLM
|
|
47
|
+
usage aggregate `akm health` shares (#1294).
|
|
48
|
+
- Claude plugin health uses native installation, scope and enabled metadata
|
|
49
|
+
instead of choosing the highest cached version. It reads `AKM_MIN_VERSION`
|
|
50
|
+
as well as the legacy range, and reports uncertain selections as unknown (#1286).
|
|
51
|
+
- Repeated fenced identifiers contribute once per document to search content,
|
|
52
|
+
reducing repetition bias while retaining distinct code names. Existing
|
|
53
|
+
content projections and embedding inputs refresh on the next index pass (#1284).
|
|
54
|
+
|
|
9
55
|
## [0.10.261019] - 2026-10-29
|
|
10
56
|
|
|
11
57
|
The first stable release of the 0.10 line, published to npm `latest`. Its
|
package/STABILITY.md
CHANGED
|
@@ -4,14 +4,6 @@
|
|
|
4
4
|
**with one caveat**: while the version is 0.x, minor releases (0.x → 0.x+1)
|
|
5
5
|
may include breaking changes.
|
|
6
6
|
|
|
7
|
-
**0.9.x series exception.** The 0.9.x releases are a deliberate refactoring
|
|
8
|
-
and clean-up series: the goal is to pay off all remaining technical debt and
|
|
9
|
-
land every planned breaking change before 0.10. While that work completes,
|
|
10
|
-
**0.9.x patch releases may also contain breaking changes** — each one called
|
|
11
|
-
out in the CHANGELOG with a migration note. The 0.10.x series returns to bug
|
|
12
|
-
fixes and tuning, and aims to restore the normal discipline of breaking
|
|
13
|
-
changes only in major and minor releases.
|
|
14
|
-
|
|
15
7
|
**Version floor (0.10, #1091).** 0.10 reads what 0.9.15 and later wrote: config,
|
|
16
8
|
`state.db`, task sources, frozen workflow plans and scheduler rows. It does not
|
|
17
9
|
convert anything older. An install on an earlier release runs `akm migrate
|
|
@@ -363,17 +363,19 @@ function aggregate(rows, opts) {
|
|
|
363
363
|
// and carry no ref, source or event type, so only the date range applies.
|
|
364
364
|
function aggregateLlm(rows, f) {
|
|
365
365
|
var by = { stage: Object.create(null), process: Object.create(null), engine: Object.create(null) }, daily = new Map();
|
|
366
|
-
var t = { calls: 0, tokens: 0, failures: 0, durationMs: 0 };
|
|
366
|
+
var t = { calls: 0, tokens: 0, failures: 0, durationMs: 0, usageCalls: 0 };
|
|
367
367
|
rows.forEach(function (r) {
|
|
368
368
|
var d = dayOf(r.day);
|
|
369
369
|
if (f.from && d < f.from) return;
|
|
370
370
|
if (f.to && d > f.to) return;
|
|
371
371
|
var tok = tokensOf(r), calls = r.calls || 0, failed = r.outcome === 'error' ? calls : 0;
|
|
372
|
-
|
|
372
|
+
// Rows from a report that predates usage coverage count as fully measured.
|
|
373
|
+
var measured = r.usageCalls != null ? r.usageCalls : calls;
|
|
374
|
+
t.calls += calls; t.tokens += tok; t.durationMs += r.durationMs || 0; t.failures += failed; t.usageCalls += measured;
|
|
373
375
|
[['stage', r.stage], ['process', r.process], ['engine', r.engine]].forEach(function (p) {
|
|
374
376
|
var k = p[1] || 'unattributed';
|
|
375
|
-
var e = by[p[0]][k] || (by[p[0]][k] = { name: k, calls: 0, tokens: 0, failures: 0 });
|
|
376
|
-
e.calls += calls; e.tokens += tok; e.failures += failed;
|
|
377
|
+
var e = by[p[0]][k] || (by[p[0]][k] = { name: k, calls: 0, tokens: 0, failures: 0, usageCalls: 0 });
|
|
378
|
+
e.calls += calls; e.tokens += tok; e.failures += failed; e.usageCalls += measured;
|
|
377
379
|
});
|
|
378
380
|
var day = daily.get(d) || { day: d, calls: 0, durationMs: 0 };
|
|
379
381
|
day.calls += calls; day.durationMs += r.durationMs || 0;
|
|
@@ -621,7 +623,7 @@ function toCsv(columns, rows) {
|
|
|
621
623
|
var agg = useRows ? aggregateLlm(LLM, { from: state.from, to: state.to }) : null;
|
|
622
624
|
$('llm-hint').textContent = useRows ? 'follows the date range only' : 'window-wide aggregate (no per-call rows)';
|
|
623
625
|
function fromAggregate(o) {
|
|
624
|
-
return Object.keys(o || {}).map(function (k) { return { name: k, tokens: o[k].totalTokens, calls: o[k].calls, failures: o[k].failures }; })
|
|
626
|
+
return Object.keys(o || {}).map(function (k) { return { name: k, tokens: o[k].totalTokens, calls: o[k].calls, failures: o[k].failures, usageCalls: o[k].usageCalls != null ? o[k].usageCalls : o[k].calls }; })
|
|
625
627
|
.sort(function (a, b) { return b.tokens - a.tokens; });
|
|
626
628
|
}
|
|
627
629
|
var dims = [
|
|
@@ -633,7 +635,7 @@ function toCsv(columns, rows) {
|
|
|
633
635
|
var list = d[1].slice(0, 15);
|
|
634
636
|
if (!hasData(d[0], list.length > 0, 'No LLM calls recorded')) return;
|
|
635
637
|
chart(d[0], Object.assign(barOption(list.map(function (x) { return x.name; }),
|
|
636
|
-
[{ name: 'tokens', type: 'bar', itemStyle: { color: css('--purple') }, data: list.map(function (x) { return x.tokens; }) }], true), {}));
|
|
638
|
+
[{ name: 'tokens', type: 'bar', itemStyle: { color: css('--purple') }, data: list.map(function (x) { return x.calls > 0 && x.usageCalls === 0 ? null : x.tokens; }) }], true), {}));
|
|
637
639
|
});
|
|
638
640
|
if (hasData('ch-llm-time', useRows && agg.daily.length > 0, 'No per-call rows in this report')) {
|
|
639
641
|
var days = agg.daily.map(function (d) { return d.day; });
|
|
@@ -744,15 +746,19 @@ function toCsv(columns, rows) {
|
|
|
744
746
|
var t = agg.totals;
|
|
745
747
|
var tokens = llm ? llm.totals.tokens : DATA.llm.totalTokens;
|
|
746
748
|
var calls = llm ? llm.totals.calls : DATA.llm.calls;
|
|
749
|
+
var usageCalls = llm ? llm.totals.usageCalls : (DATA.llm.usageCalls != null ? DATA.llm.usageCalls : calls);
|
|
750
|
+
// Calls whose provider sent no usage are unknown, not zero tokens.
|
|
751
|
+
var llmSub = fmtN(calls) + ' calls' + (usageCalls < calls ? ', ' + fmtN(usageCalls) + ' reported usage' : '');
|
|
747
752
|
var tasks = DATA.tasks || {};
|
|
748
753
|
var cards = [
|
|
749
754
|
['Searches', fmtN(t.searches), fmtN(t.curates) + ' curates', ''],
|
|
750
755
|
['Shows', fmtN(t.shows), '', ''],
|
|
751
|
-
['
|
|
756
|
+
['Selects per search', DATA.usage.selectsPerSearch == null ? '—' : DATA.usage.selectsPerSearch.toFixed(2),
|
|
757
|
+
fmtN(DATA.usage.totals.selects) + ' selects / ' + fmtN(DATA.usage.totals.searchesWithHits) + ' searches with hits, window-wide, selects not source-filtered', ''],
|
|
752
758
|
['Zero-result rate', fmtPct(t.searches ? t.zeroResult / t.searches : null), fmtN(t.zeroResult) + ' searches', t.zeroResult ? 'yellow' : ''],
|
|
753
759
|
['Feedback', '<span class="pos">+' + fmtN(t.positive) + '</span> / <span class="neg">−' + fmtN(t.negative) + '</span>',
|
|
754
760
|
'net ' + (t.positive - t.negative >= 0 ? '+' : '−') + Math.abs(t.positive - t.negative), ''],
|
|
755
|
-
['LLM tokens', fmtN(tokens),
|
|
761
|
+
['LLM tokens', calls > 0 && usageCalls === 0 ? 'unknown' : fmtN(tokens), llmSub, usageCalls < calls ? 'yellow' : ''],
|
|
756
762
|
['Task fail rate', fmtPct(tasks.failRate), fmtN(tasks.failed) + ' of ' + fmtN(tasks.runs) + ' runs, window-wide', tasks.failed ? 'red' : '']
|
|
757
763
|
];
|
|
758
764
|
$('kpis').innerHTML = cards.map(function (c) {
|
|
@@ -70,10 +70,10 @@ function metricsSection(m) {
|
|
|
70
70
|
${tile("Searches", fmtNum(u.totals.searches), `${fmtNum(u.totals.zeroResultSearches)} with no results`)}
|
|
71
71
|
${tile("Shows", fmtNum(u.totals.shows), `${fmtNum(u.totals.distinctAssets)} distinct assets`)}
|
|
72
72
|
${tile("Curates", fmtNum(u.totals.curates))}
|
|
73
|
-
${tile("
|
|
73
|
+
${tile("Selects per search", u.selectsPerSearch == null ? "—" : u.selectsPerSearch.toFixed(2), `${fmtNum(u.totals.selects)} selects / ${fmtNum(u.totals.searchesWithHits)} searches with hits`)}
|
|
74
74
|
${tile("Median search", fmtMs(u.searchMedianMs))}
|
|
75
75
|
${tile("Feedback", `+${fmtNum(m.feedback.totals.positive)} / -${fmtNum(m.feedback.totals.negative)}`, "positive / negative")}
|
|
76
|
-
${tile("LLM calls", fmtNum(m.llm.calls), `${fmtNum(m.llm.failures)} failed, ${fmtNum(m.llm.totalTokens)} tokens`)}
|
|
76
|
+
${tile("LLM calls", fmtNum(m.llm.calls), `${fmtNum(m.llm.failures)} failed, ${m.llm.calls > 0 && m.llm.usageCalls === 0 ? "unknown" : fmtNum(m.llm.totalTokens)} tokens`)}
|
|
77
77
|
${tile("Pending proposals", fmtNum(m.proposals.byStatus.pending ?? 0))}
|
|
78
78
|
${tile("Task runs", fmtNum(m.tasks.runs), `${fmtNum(m.tasks.failed)} failed`)}
|
|
79
79
|
${tile("Index builds", fmtNum(m.index.runs), `median ${fmtMs(m.index.medianMs)}`)}
|
|
@@ -21,6 +21,13 @@ function emptyLlmUsageStageAggregate() {
|
|
|
21
21
|
failures: 0,
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
+
/** True when the provider response carried any token count. Absent usage is unknown, not zero. */
|
|
25
|
+
export function hasTokenUsage(record) {
|
|
26
|
+
return (record.promptTokens !== undefined ||
|
|
27
|
+
record.completionTokens !== undefined ||
|
|
28
|
+
record.totalTokens !== undefined ||
|
|
29
|
+
record.reasoningTokens !== undefined);
|
|
30
|
+
}
|
|
24
31
|
/** A zeroed aggregate — also the value health reports when it could not read state.db at all (#791). */
|
|
25
32
|
export function emptyLlmUsageAggregate() {
|
|
26
33
|
return { ...emptyLlmUsageStageAggregate(), byStage: {}, byProcess: {}, byEngine: {} };
|
|
@@ -28,7 +35,7 @@ export function emptyLlmUsageAggregate() {
|
|
|
28
35
|
/**
|
|
29
36
|
* Aggregate `llm_usage` events (#576) into a window total plus a per-stage
|
|
30
37
|
* breakdown of call count, wall-time, and token usage. Token fields absent from
|
|
31
|
-
* a best-effort record
|
|
38
|
+
* a best-effort record add nothing and the call is left out of `usageCalls`. Calls with no `stage` land under
|
|
32
39
|
* {@link UNATTRIBUTED_STAGE}.
|
|
33
40
|
*/
|
|
34
41
|
export function summarizeLlmUsage(events) {
|
|
@@ -47,11 +54,18 @@ export function summarizeLlmUsage(events) {
|
|
|
47
54
|
dimensions.push(groups[key]);
|
|
48
55
|
}
|
|
49
56
|
for (const target of [aggregate, ...dimensions]) {
|
|
57
|
+
target.usageCalls ??= 0;
|
|
58
|
+
target.reasoningCalls ??= 0;
|
|
50
59
|
target.calls += 1;
|
|
60
|
+
if (hasTokenUsage(record))
|
|
61
|
+
target.usageCalls += 1;
|
|
62
|
+
if (record.reasoningTokens !== undefined)
|
|
63
|
+
target.reasoningCalls += 1;
|
|
51
64
|
target.totalDurationMs += record.durationMs;
|
|
52
65
|
target.promptTokens += record.promptTokens ?? 0;
|
|
53
66
|
target.completionTokens += record.completionTokens ?? 0;
|
|
54
|
-
|
|
67
|
+
// A call without a total counts as prompt + completion, as the per-day metrics rows do.
|
|
68
|
+
target.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
|
|
55
69
|
target.reasoningTokens += record.reasoningTokens ?? 0;
|
|
56
70
|
if (record.outcome === "error")
|
|
57
71
|
target.failures += 1;
|
|
@@ -2,51 +2,11 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
10
|
-
* three days stale relative to the fix, and its own `AKM_VERSION_RANGE` gate
|
|
11
|
-
* had nothing to do with it — the plugin was simply running old code. Every
|
|
12
|
-
* fact needed to reach that conclusion in one step was already on disk:
|
|
13
|
-
*
|
|
14
|
-
* - installed plugin version: `~/.claude/plugins/cache/<marketplace>/akm/<version>/.claude-plugin/plugin.json`
|
|
15
|
-
* - the plugin's own akm-cli compatibility contract: `<pluginDir>/shared/akm-version.ts`'s `AKM_VERSION_RANGE`
|
|
16
|
-
* - the running CLI's version: `../../version.ts`'s `pkgVersion`
|
|
17
|
-
*
|
|
18
|
-
* Nothing correlated them, so a stale plugin was indistinguishable from a
|
|
19
|
-
* broken CLI. This module closes that gap with three checks, one per
|
|
20
|
-
* detected plugin:
|
|
21
|
-
*
|
|
22
|
-
* 1. report the installed version;
|
|
23
|
-
* 2. compare it against the newest tag published to the plugin's git
|
|
24
|
-
* remote, and warn (naming the update command) when behind;
|
|
25
|
-
* 3. the sharp one — check whether the *installed plugin's* declared
|
|
26
|
-
* `AKM_VERSION_RANGE` admits the *running* CLI version. When it does
|
|
27
|
-
* not, the plugin has silently disabled itself (both surfaces log
|
|
28
|
-
* `version_out_of_range` / `akm_version_mismatch` and degrade quietly)
|
|
29
|
-
* and there was previously no way to know that from the CLI side.
|
|
30
|
-
*
|
|
31
|
-
* Read-only: this never fetches, writes, or mutates the plugin cache or
|
|
32
|
-
* marketplace clone. Check 2 is one of two deliberate exceptions to "`akm
|
|
33
|
-
* health` makes no network call" (see `./health-advisories.md`; the other is
|
|
34
|
-
* the `cli-version` advisory in `./version-drift.ts`, #950): a plugin's
|
|
35
|
-
* local marketplace clone is not proof of what is newest upstream — the
|
|
36
|
-
* incident above involved a clone that hadn't seen the fix's tag at all — so
|
|
37
|
-
* the only way to ever detect drift is to ask the remote what tags exist.
|
|
38
|
-
* That query is a `git ls-remote --symref` (lists refs; fetches nothing,
|
|
39
|
-
* writes nothing) with a short timeout, and any failure (offline, no
|
|
40
|
-
* remote, timeout) degrades to "installed version reported, no staleness
|
|
41
|
-
* claim" rather than a false positive or a hang. Like `cli-version`, this
|
|
42
|
-
* check is gated behind `--probe`/`--no-probe`: with probing off the remote is
|
|
43
|
-
* not asked, the row reports `unknown` ("not probed") and only the local range
|
|
44
|
-
* check (3) runs.
|
|
45
|
-
*
|
|
46
|
-
* Every collector here is best-effort and silent on missing/unreadable
|
|
47
|
-
* input: no Claude plugin installed, no marketplace clone, an unreadable
|
|
48
|
-
* manifest, or a malformed version range must never crash `akm health` and
|
|
49
|
-
* must never produce a false "stale" or "inactive" warning.
|
|
5
|
+
* Report the installations Claude's native listing selects, not the versions
|
|
6
|
+
* left in its cache. The listing owns scope and merged enabled settings; it
|
|
7
|
+
* cannot prove what an already-running session has loaded. Ambiguous scoped
|
|
8
|
+
* installations and unavailable compatibility contracts are reported as unknown.
|
|
9
|
+
* Remote release checks remain optional under --no-probe.
|
|
50
10
|
*/
|
|
51
11
|
import { spawnSync } from "node:child_process";
|
|
52
12
|
import fs from "node:fs";
|
|
@@ -64,7 +24,9 @@ import { openCodeConfigSpec } from "../sources/plugin-upgrade.js";
|
|
|
64
24
|
* `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
|
|
65
25
|
*/
|
|
66
26
|
function claudePluginsDir() {
|
|
67
|
-
return process.env.AKM_CLAUDE_PLUGINS_DIR ??
|
|
27
|
+
return (process.env.AKM_CLAUDE_PLUGINS_DIR ??
|
|
28
|
+
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ??
|
|
29
|
+
path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "plugins"));
|
|
68
30
|
}
|
|
69
31
|
/**
|
|
70
32
|
* Root of OpenCode's package cache, same override pattern as
|
|
@@ -159,74 +121,90 @@ function buildOpencodeBundledAdvisory(plugin, bundled, cliVersion) {
|
|
|
159
121
|
evidence: bundled.spec ? { bundledVersion, cliVersion, spec: bundled.spec } : { bundledVersion, cliVersion },
|
|
160
122
|
};
|
|
161
123
|
}
|
|
162
|
-
/**
|
|
163
|
-
* Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
|
|
164
|
-
* `akm` plugin cache entry, picking the highest cached version per
|
|
165
|
-
* marketplace when more than one is present. Returns `[]` (never throws)
|
|
166
|
-
* when the cache directory is absent, empty, or unreadable — that is the
|
|
167
|
-
* ordinary "no Claude plugin installed" case, not an error.
|
|
168
|
-
*/
|
|
169
124
|
function detectInstalledPlugins(pluginsRoot) {
|
|
170
|
-
|
|
171
|
-
|
|
125
|
+
if (!fs.existsSync(path.join(pluginsRoot, "installed_plugins.json")) &&
|
|
126
|
+
!fs.existsSync(path.join(pluginsRoot, "cache")))
|
|
127
|
+
return [];
|
|
172
128
|
try {
|
|
173
|
-
|
|
129
|
+
const listed = spawnSync("claude", ["plugin", "list", "--json"], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
timeout: 30_000,
|
|
132
|
+
env: {
|
|
133
|
+
...process.env,
|
|
134
|
+
...(process.env.AKM_CLAUDE_PLUGINS_DIR
|
|
135
|
+
? { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR ?? path.dirname(pluginsRoot) }
|
|
136
|
+
: {}),
|
|
137
|
+
CLAUDE_CODE_PLUGIN_CACHE_DIR: pluginsRoot,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (listed.status !== 0)
|
|
141
|
+
return undefined;
|
|
142
|
+
const rows = JSON.parse(listed.stdout);
|
|
143
|
+
if (!Array.isArray(rows))
|
|
144
|
+
return undefined;
|
|
145
|
+
const detected = rows.flatMap((row) => {
|
|
146
|
+
if (typeof row?.id !== "string" || !row.id.startsWith("akm@"))
|
|
147
|
+
return [];
|
|
148
|
+
const applicable = typeof row.projectPath !== "string" ||
|
|
149
|
+
path.resolve(process.cwd()) === path.resolve(row.projectPath) ||
|
|
150
|
+
path.resolve(process.cwd()).startsWith(`${path.resolve(row.projectPath)}${path.sep}`);
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
harness: "claude",
|
|
154
|
+
marketplace: row.id.slice(4),
|
|
155
|
+
pluginName: "akm",
|
|
156
|
+
version: typeof row.folderVersion === "string"
|
|
157
|
+
? row.folderVersion
|
|
158
|
+
: typeof row.version === "string"
|
|
159
|
+
? row.version
|
|
160
|
+
: "unknown",
|
|
161
|
+
pluginDir: typeof row.readFromFolder === "string"
|
|
162
|
+
? row.readFromFolder
|
|
163
|
+
: typeof row.installPath === "string"
|
|
164
|
+
? row.installPath
|
|
165
|
+
: "",
|
|
166
|
+
applicable,
|
|
167
|
+
selectionKnown: false,
|
|
168
|
+
scope: typeof row.scope === "string" ? row.scope : "unknown",
|
|
169
|
+
enabled: typeof row.enabled === "boolean" ? row.enabled : undefined,
|
|
170
|
+
...(typeof row.projectPath === "string" ? { projectPath: row.projectPath } : {}),
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
});
|
|
174
|
+
for (const plugin of detected) {
|
|
175
|
+
const applicable = detected.filter((p) => p.marketplace === plugin.marketplace && p.applicable);
|
|
176
|
+
plugin.selectionKnown =
|
|
177
|
+
plugin.applicable &&
|
|
178
|
+
isExactSemver(plugin.version) &&
|
|
179
|
+
plugin.scope !== "unknown" &&
|
|
180
|
+
plugin.pluginDir !== "" &&
|
|
181
|
+
new Set(applicable.map((p) => `${p.version}:${p.pluginDir}`)).size === 1;
|
|
182
|
+
}
|
|
183
|
+
return detected;
|
|
174
184
|
}
|
|
175
185
|
catch {
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
const detected = [];
|
|
179
|
-
for (const marketplace of marketplaces) {
|
|
180
|
-
const pluginDir = path.join(cacheDir, marketplace, "akm");
|
|
181
|
-
let versions;
|
|
182
|
-
try {
|
|
183
|
-
versions = fs.readdirSync(pluginDir).filter(isExactSemver);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (versions.length === 0)
|
|
189
|
-
continue;
|
|
190
|
-
const latest = highestVersion(versions);
|
|
191
|
-
if (!latest)
|
|
192
|
-
continue;
|
|
193
|
-
const versionDir = path.join(pluginDir, latest);
|
|
194
|
-
const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
|
|
195
|
-
try {
|
|
196
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
197
|
-
if (typeof manifest.version !== "string")
|
|
198
|
-
continue;
|
|
199
|
-
detected.push({
|
|
200
|
-
harness: "claude",
|
|
201
|
-
marketplace,
|
|
202
|
-
pluginName: "akm",
|
|
203
|
-
version: manifest.version,
|
|
204
|
-
pluginDir: versionDir,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// Unreadable/malformed manifest — skip this entry rather than crash.
|
|
209
|
-
}
|
|
186
|
+
return undefined;
|
|
210
187
|
}
|
|
211
|
-
return detected;
|
|
212
188
|
}
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
* `shared/akm-version.ts`. Returns `undefined` when the file is missing,
|
|
216
|
-
* unreadable, or does not contain the expected declaration — callers must
|
|
217
|
-
* treat that as "compatibility unknown", never as a mismatch.
|
|
218
|
-
*/
|
|
219
|
-
function readVersionRange(pluginDir) {
|
|
220
|
-
const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
|
|
189
|
+
/** Read the contract, never execute plugin source. A minimum admits later prereleases too. */
|
|
190
|
+
function readCompatibility(pluginDir, cliVersion) {
|
|
221
191
|
let text;
|
|
222
192
|
try {
|
|
223
|
-
text = fs.readFileSync(
|
|
193
|
+
text = fs.readFileSync(path.join(pluginDir, "shared", "akm-version.ts"), "utf8");
|
|
224
194
|
}
|
|
225
195
|
catch {
|
|
226
|
-
return
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
const minimum = text.match(/export\s+const\s+AKM_MIN_VERSION\s*=\s*["']([^"']+)["']/)?.[1];
|
|
199
|
+
if (minimum !== undefined) {
|
|
200
|
+
if (!isExactSemver(minimum) || !isExactSemver(cliVersion))
|
|
201
|
+
return {};
|
|
202
|
+
return { versionRange: `>=${minimum}`, admitted: highestVersion([minimum, cliVersion]) === cliVersion };
|
|
227
203
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
204
|
+
const range = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/)?.[1];
|
|
205
|
+
if (!range || !isSemverRange(range) || !isExactSemver(cliVersion))
|
|
206
|
+
return {};
|
|
207
|
+
return { versionRange: range, admitted: satisfiesRange(cliVersion, range) };
|
|
230
208
|
}
|
|
231
209
|
const LS_REMOTE_TIMEOUT_MS = 5_000;
|
|
232
210
|
/**
|
|
@@ -311,7 +289,17 @@ export function collectPluginStalenessAdvisories(options) {
|
|
|
311
289
|
const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
|
|
312
290
|
const plugins = detectInstalledPlugins(pluginsRoot);
|
|
313
291
|
const probe = options.probe ?? true;
|
|
314
|
-
const results = plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
|
|
292
|
+
const results = (plugins ?? []).map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags, probe));
|
|
293
|
+
if (plugins === undefined && fs.existsSync(pluginsRoot)) {
|
|
294
|
+
results.push({
|
|
295
|
+
name: "plugin-version",
|
|
296
|
+
kind: "deterministic",
|
|
297
|
+
status: "unknown",
|
|
298
|
+
confidence: "high",
|
|
299
|
+
message: "Claude plugin installation and enabled state could not be resolved with `claude plugin list --json`; cached versions do not establish the selected installation.",
|
|
300
|
+
evidence: { harness: "claude", installedVersion: null, enabled: null, admitted: null },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
315
303
|
const opencodeCacheRoot = options.opencodeCacheRoot ?? defaultOpencodeCacheRoot();
|
|
316
304
|
for (const plugin of OPENCODE_PLUGIN_LOCATIONS) {
|
|
317
305
|
for (const bundled of findBundledAkm(plugin, opencodeCacheRoot)) {
|
|
@@ -326,7 +314,9 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
326
314
|
const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
|
|
327
315
|
let availableVersion;
|
|
328
316
|
try {
|
|
329
|
-
const tags = probe && fs.existsSync(marketplaceDir)
|
|
317
|
+
const tags = probe && plugin.selectionKnown && plugin.enabled !== false && fs.existsSync(marketplaceDir)
|
|
318
|
+
? listRemoteTags(marketplaceDir)
|
|
319
|
+
: undefined;
|
|
330
320
|
availableVersion = tags && tags.length > 0 ? highestVersion(tags) : undefined;
|
|
331
321
|
}
|
|
332
322
|
catch {
|
|
@@ -336,10 +326,20 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
336
326
|
plugin.version !== availableVersion &&
|
|
337
327
|
highestVersion([plugin.version, availableVersion]) === availableVersion;
|
|
338
328
|
// Point 3: does the plugin's own declared range admit the running CLI?
|
|
339
|
-
const versionRange =
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
329
|
+
const { versionRange, admitted } = readCompatibility(plugin.pluginDir, cliVersion);
|
|
330
|
+
const messageParts = [
|
|
331
|
+
`${pluginRef}: installed ${plugin.version} (${plugin.scope}${plugin.projectPath ? `: ${plugin.projectPath}` : ""})`,
|
|
332
|
+
];
|
|
333
|
+
if (!plugin.applicable)
|
|
334
|
+
messageParts.push("installed for another project; not selected in this directory");
|
|
335
|
+
else if (!plugin.selectionKnown)
|
|
336
|
+
messageParts.push("effective selection unknown across native installations");
|
|
337
|
+
if (plugin.enabled === false)
|
|
338
|
+
messageParts.push("disabled in Claude settings");
|
|
339
|
+
else if (plugin.enabled === undefined)
|
|
340
|
+
messageParts.push("enabled state unknown");
|
|
341
|
+
if (admitted === undefined)
|
|
342
|
+
messageParts.push("CLI compatibility unknown");
|
|
343
343
|
if (!probe)
|
|
344
344
|
messageParts.push("newest tag was not probed");
|
|
345
345
|
if (availableVersion !== undefined) {
|
|
@@ -347,15 +347,23 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
347
347
|
}
|
|
348
348
|
if (stale)
|
|
349
349
|
messageParts.push(`-> claude plugin update ${pluginRef}`);
|
|
350
|
-
if (
|
|
351
|
-
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED
|
|
350
|
+
if (plugin.selectionKnown && plugin.enabled === true && admitted === false) {
|
|
351
|
+
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED by the plugin compatibility contract`);
|
|
352
352
|
}
|
|
353
353
|
return {
|
|
354
354
|
name: "plugin-version",
|
|
355
355
|
kind: "deterministic",
|
|
356
356
|
// Not probed => staleness is unknown, not "up to date"; only the local
|
|
357
357
|
// range check can still warn.
|
|
358
|
-
status:
|
|
358
|
+
status: plugin.enabled === false || !plugin.applicable
|
|
359
|
+
? "pass"
|
|
360
|
+
: !plugin.selectionKnown || plugin.enabled === undefined
|
|
361
|
+
? "unknown"
|
|
362
|
+
: stale || admitted === false
|
|
363
|
+
? "warn"
|
|
364
|
+
: probe && availableVersion !== undefined && admitted !== undefined && plugin.enabled === true
|
|
365
|
+
? "pass"
|
|
366
|
+
: "unknown",
|
|
359
367
|
confidence: "high",
|
|
360
368
|
message: messageParts.join(" — "),
|
|
361
369
|
evidence: {
|
|
@@ -363,6 +371,12 @@ function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags, probe) {
|
|
|
363
371
|
marketplace: plugin.marketplace,
|
|
364
372
|
plugin: plugin.pluginName,
|
|
365
373
|
installedVersion: plugin.version,
|
|
374
|
+
scope: plugin.scope,
|
|
375
|
+
applicable: plugin.applicable,
|
|
376
|
+
selectionKnown: plugin.selectionKnown,
|
|
377
|
+
projectPath: plugin.projectPath ?? null,
|
|
378
|
+
enabled: plugin.enabled ?? null,
|
|
379
|
+
installPath: plugin.pluginDir,
|
|
366
380
|
availableVersion: availableVersion ?? null,
|
|
367
381
|
...(probe ? {} : { probed: false }),
|
|
368
382
|
stale,
|
|
@@ -9,18 +9,35 @@
|
|
|
9
9
|
* is when secret assets are git-TRACKED **and** a remote is configured — only
|
|
10
10
|
* then can a `git push` exfiltrate tokens/keys. Warn on exactly that
|
|
11
11
|
* combination; stay silent on the tracked-but-no-remote opt-in so the advisory
|
|
12
|
-
* catches the exposure without nagging the intentional backup.
|
|
12
|
+
* catches the exposure without nagging the intentional backup. A stash whose
|
|
13
|
+
* `.gitignore` carries the intentional-tracking marker has acknowledged the
|
|
14
|
+
* risk, so it is not warned about at all.
|
|
13
15
|
*/
|
|
14
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { STASH_GITIGNORE_TRACK_SECRETS_MARKER } from "../sources/stash-skeleton.js";
|
|
15
20
|
const realGit = (stashDir, args) => {
|
|
16
21
|
const result = spawnSync("git", ["-C", stashDir, ...args], { encoding: "utf8", timeout: 5_000 });
|
|
17
22
|
return { ok: result.status === 0, stdout: (result.stdout ?? "").trim() };
|
|
18
23
|
};
|
|
24
|
+
/** True when the stash `.gitignore` carries the explicit intentional-tracking marker. */
|
|
25
|
+
function hasIntentionalTrackingMarker(stashDir) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(path.join(stashDir, ".gitignore"), "utf8").includes(STASH_GITIGNORE_TRACK_SECRETS_MARKER);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
/**
|
|
20
34
|
* Build the `stash-git-exposure` advisory, or `undefined` when there is nothing
|
|
21
|
-
* to warn about (
|
|
35
|
+
* to warn about (tracking acknowledged by the marker, not a git repo, no
|
|
36
|
+
* tracked env/secret assets, or no remote).
|
|
22
37
|
*/
|
|
23
38
|
export function collectStashExposureAdvisory(stashDir, git = realGit) {
|
|
39
|
+
if (hasIntentionalTrackingMarker(stashDir))
|
|
40
|
+
return undefined;
|
|
24
41
|
// Not a git work tree → nothing to expose.
|
|
25
42
|
if (!git(stashDir, ["rev-parse", "--is-inside-work-tree"]).ok)
|
|
26
43
|
return undefined;
|
|
@@ -37,10 +54,11 @@ export function collectStashExposureAdvisory(stashDir, git = realGit) {
|
|
|
37
54
|
kind: "deterministic",
|
|
38
55
|
status: "warn",
|
|
39
56
|
confidence: "high",
|
|
40
|
-
message:
|
|
57
|
+
message: `Bundle at ${stashDir}: ${trackedFiles.length} env/secret file(s) are git-tracked AND a remote is configured — ` +
|
|
41
58
|
`a 'git push' can leak tokens/keys (${preview}). ` +
|
|
42
59
|
"Run 'git rm --cached' on them (a .gitignore rule alone does NOT untrack already-tracked " +
|
|
43
|
-
"files) and then add env/+secrets/ to .gitignore to prevent recurrence (akm bundle create scaffolds it)
|
|
44
|
-
|
|
60
|
+
"files) and then add env/+secrets/ to .gitignore to prevent recurrence (akm bundle create scaffolds it), " +
|
|
61
|
+
`or, to keep tracking them on purpose, add '${STASH_GITIGNORE_TRACK_SECRETS_MARKER}' to .gitignore.`,
|
|
62
|
+
evidence: { bundleRoot: stashDir, trackedSecretFiles: trackedFiles },
|
|
45
63
|
};
|
|
46
64
|
}
|