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.
Files changed (33) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/STABILITY.md +2 -1
  3. package/dist/assets/hints/cli-hints-full.md +6 -4
  4. package/dist/assets/hints/cli-hints-short.md +4 -2
  5. package/dist/assets/opencode/akm-plugin.js +16 -8
  6. package/dist/assets/prompts/workflow-unit-preamble.md +3 -1
  7. package/dist/commands/health/plugin-staleness.js +86 -22
  8. package/dist/commands/read/search-cli.js +2 -2
  9. package/dist/commands/read/show.js +15 -3
  10. package/dist/core/asset/markdown.js +9 -0
  11. package/dist/core/config/config-walker.js +35 -32
  12. package/dist/core/config/schema/connections.js +6 -3
  13. package/dist/core/config/schema/harnesses.js +8 -1
  14. package/dist/core/config/schema/primitives.js +12 -1
  15. package/dist/core/config/schema/sources-bundles.js +2 -2
  16. package/dist/core/npm-package.js +48 -13
  17. package/dist/output/renderers.js +2 -2
  18. package/dist/output/shapes/helpers.js +2 -0
  19. package/dist/output/text/show-format.js +7 -0
  20. package/dist/registry/semver.js +12 -0
  21. package/dist/scripts/akm-migrate-node.js +21629 -5738
  22. package/dist/scripts/akm-migrate.js +21632 -5741
  23. package/dist/tasks/backends/cron.js +19 -0
  24. package/docs/architecture/internals/health-advisories.md +1 -1
  25. package/docs/architecture/specs/ref.md +4 -3
  26. package/docs/architecture/testing/testing-workflow.md +7 -8
  27. package/docs/guides/discover-and-load.md +12 -4
  28. package/docs/guides/getting-started.md +3 -2
  29. package/docs/guides/use-with-any-agent.md +4 -1
  30. package/docs/maintainers/release-checklist.md +15 -8
  31. package/docs/reference/cli.md +22 -3
  32. package/package.json +4 -4
  33. package/schemas/akm-config.json +535 -302
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { z } from "zod";
14
14
  import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
15
- import { connectionEndpoint, engineName, envReferenceApiKey, nonEmptyString, positiveInt, retiredApiKeyFile, } from "./primitives.js";
15
+ import { connectionEndpoint, engineName, envReferenceApiKey, nonEmptyString, OpaqueFieldSchema, positiveInt, retiredApiKeyFile, } from "./primitives.js";
16
16
  /**
17
17
  * Timeouts share the workflow ceiling: a larger value would pass config
18
18
  * validation and then fail every run with an unlocated "invocation is invalid".
@@ -24,7 +24,10 @@ export const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS),
24
24
  * request fields of its own. The object is listed first so a JSON value given to
25
25
  * `akm config set` is read as one.
26
26
  */
27
- export const ModelEntrySchema = z.union([z.object({ model: nonEmptyString }).catchall(z.unknown()), nonEmptyString]);
27
+ export const ModelEntrySchema = z.union([
28
+ z.object({ model: nonEmptyString }).catchall(OpaqueFieldSchema),
29
+ nonEmptyString,
30
+ ]);
28
31
  /** Alias (`fast`, `balanced`, `reasoning`, or any the user adds) to the model it names. */
29
32
  export const ModelAliasesSchema = z.record(nonEmptyString, ModelEntrySchema);
30
33
  export const ConnectionSchema = z
@@ -37,5 +40,5 @@ export const ConnectionSchema = z
37
40
  timeoutMs: timeoutMsField,
38
41
  concurrency: positiveInt.optional(),
39
42
  })
40
- .catchall(z.unknown());
43
+ .catchall(OpaqueFieldSchema);
41
44
  export const ConnectionsSchema = z.record(engineName, ConnectionSchema);
@@ -13,7 +13,14 @@ import { ModelAliasesSchema, timeoutMsField } from "./connections.js";
13
13
  import { engineName, nonEmptyString } from "./primitives.js";
14
14
  export const HarnessSchema = z
15
15
  .object({
16
- platform: z.enum(HARNESS_CONFIG_PLATFORMS).optional(),
16
+ // Zod 4's message for a value outside an enum no longer quotes the value; the warning for a dropped harness should.
17
+ platform: z
18
+ .enum(HARNESS_CONFIG_PLATFORMS, {
19
+ error: (issue) => issue.code === "invalid_value"
20
+ ? `${JSON.stringify(issue.input)} is not one of ${HARNESS_CONFIG_PLATFORMS.join(", ")}`
21
+ : undefined,
22
+ })
23
+ .optional(),
17
24
  bin: nonEmptyString.optional(),
18
25
  args: z.array(z.string()).optional(),
19
26
  workspace: nonEmptyString.optional(),
@@ -12,11 +12,22 @@ import { redactUrlCredentials } from "../../redaction.js";
12
12
  import { isSecretRefToken } from "../../ref-token.js";
13
13
  import { warnOnce } from "../../warn.js";
14
14
  import { ENGINE_NAME_PATTERN_SOURCE } from "../engine-semantics.js";
15
+ // Zod 4 reports a map key its key schema rejects as "Invalid key in record" and keeps that schema's own message
16
+ // (the naming rule, the slug rule) inside the issue; config errors and warnings print only the outer message.
17
+ z.config({
18
+ customError: (issue) => issue.code === "invalid_key" ? issue.issues.map((inner) => inner.message).join("; ") : undefined,
19
+ });
15
20
  /** Persisted config schema version. Package prerelease/patch versions do not change this value. */
16
21
  export const CURRENT_CONFIG_VERSION = "0.9.0";
17
22
  // ── Reusable atomic schemas ─────────────────────────────────────────────────
18
23
  /** Positive integer (used for tokens, timeouts, batch sizes). */
19
24
  export const positiveInt = z.number().int().positive();
25
+ /**
26
+ * The catchall of an object whose extra keys are the endpoint's to define (a connection's request fields):
27
+ * any key, contents left alone. The config walker knows it by identity: a `.passthrough()` object also has an
28
+ * `unknown` catchall, and its extra keys are ones akm does not define.
29
+ */
30
+ export const OpaqueFieldSchema = z.unknown();
20
31
  /** Non-negative finite number (used for scores, weights, days). */
21
32
  export const nonNegativeNumber = z.number().finite().min(0);
22
33
  /** Non-empty string (rejects "" and whitespace-only). */
@@ -92,7 +103,7 @@ export const connectionEndpoint = z.string().superRefine((value, ctx) => {
92
103
  warnOnce(`connectionEndpoint:userinfo:${shown}`, `Config endpoint "${shown}" embeds a username/password; move the credential out of the URL (apiKey: "$VAR", or credentials.json).`);
93
104
  }
94
105
  });
95
- export const ExtraParamsSchema = z.record(z.unknown()).superRefine((value, ctx) => {
106
+ export const ExtraParamsSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => {
96
107
  for (const issue of validateExtraParams(value)) {
97
108
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: issue.path, message: issue.message });
98
109
  }
@@ -16,7 +16,7 @@ import { isBundleSlug } from "../../asset/asset-ref.js";
16
16
  import { httpUrl, isApiKeyReference, nonEmptyString, positiveInt } from "./primitives.js";
17
17
  const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
18
18
  // ── Sources / registries / installed ────────────────────────────────────────
19
- const SourceConfigEntryOptionsSchema = z.record(z.unknown());
19
+ const SourceConfigEntryOptionsSchema = z.record(z.string(), z.unknown());
20
20
  export const SourceConfigEntrySchema = z
21
21
  .object({
22
22
  type: nonEmptyString,
@@ -74,7 +74,7 @@ export const RegistryConfigEntrySchema = z
74
74
  name: z.string().min(1).optional(),
75
75
  enabled: z.boolean().optional(),
76
76
  provider: z.string().min(1).optional(),
77
- options: z.record(z.unknown()).optional(),
77
+ options: z.record(z.string(), z.unknown()).optional(),
78
78
  })
79
79
  .passthrough();
80
80
  // ── Bundles ─────────────────────────────────────────────────────────────────
@@ -12,8 +12,10 @@
12
12
  import { createHash } from "node:crypto";
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { writeResponseToFileCapped } from "../runtime.js";
15
+ import { sleep, writeResponseToFileCapped } from "../runtime.js";
16
16
  import { fetchWithRetry, isRecord } from "./common.js";
17
+ import { releaseLock } from "./file-lock.js";
18
+ import { formatLockHolderPid, tryAcquireRunLock } from "./run-lock.js";
17
19
  import { extractTarGzMembers } from "./tar.js";
18
20
  export const NPM_REGISTRY = "https://registry.npmjs.org";
19
21
  /** The `sha512-<base64>` integrity npm records for a tarball. */
@@ -47,14 +49,50 @@ async function registryDocument(url) {
47
49
  }
48
50
  return { tarball: dist.tarball, integrity: dist.integrity };
49
51
  }
52
+ /** How long a fetch waits for another process's fetch into the same directory: a large tarball on a slow link takes minutes. */
53
+ const FETCH_LOCK_TIMEOUT_MS = 600_000;
50
54
  /**
51
- * Keep members of `name@version`'s tarball in `directory`, each as `file`: the registry document of that one version,
52
- * its tarball, the check against the sha512 integrity the registry lists before anything is extracted, then each
53
- * member by temp file and rename (mode 0755), in the order given. Two akm processes fetching at once both finish: a
54
- * rename that loses to the other leaves the file the other made.
55
+ * Keep members of `name@version`'s tarball in `directory`, each as `file`. One fetch runs per directory at a time: the
56
+ * processes fetching there, this one included, wait on a lock file in it, and a dead holder's lock is reclaimed.
55
57
  */
56
58
  export async function fetchNpmPackageFiles(name, version, directory, files) {
57
59
  fs.mkdirSync(directory, { recursive: true });
60
+ const lock = path.join(directory, "fetch.lock");
61
+ const deadline = Date.now() + FETCH_LOCK_TIMEOUT_MS;
62
+ for (;;) {
63
+ const attempt = tryAcquireRunLock(lock, { label: "fetch" });
64
+ if (attempt.state === "acquired") {
65
+ try {
66
+ return await fetchHoldingLock(name, version, directory, files);
67
+ }
68
+ finally {
69
+ releaseLock(attempt.ownership);
70
+ }
71
+ }
72
+ if (Date.now() >= deadline) {
73
+ throw new Error(`another akm process (pid ${formatLockHolderPid(attempt.holder)}) is still fetching into ${directory} (lock ${lock})`);
74
+ }
75
+ await sleep(100);
76
+ }
77
+ }
78
+ /** Whether `file` is in place whole: files only ever arrive by rename, so an executable regular file is complete. */
79
+ function isExecutableFile(file) {
80
+ try {
81
+ fs.accessSync(file, fs.constants.X_OK);
82
+ return fs.statSync(file).isFile();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * The registry document of that one version, its tarball, the check against the sha512 integrity the registry lists
90
+ * before anything is extracted, then each member by temp file and rename (mode 0755), in the order given. A fetch that
91
+ * finds every file in place downloads nothing, and a file in place at the size the tarball holds is never renamed over.
92
+ */
93
+ async function fetchHoldingLock(name, version, directory, files) {
94
+ if (files.every(({ file }) => isExecutableFile(path.join(directory, file))))
95
+ return;
58
96
  const download = path.join(directory, `.download-${process.pid}.tgz`);
59
97
  const partial = (file) => path.join(directory, `.${file}-${process.pid}.tmp`);
60
98
  try {
@@ -70,15 +108,12 @@ export async function fetchNpmPackageFiles(name, version, directory, files) {
70
108
  const sizes = await extractTarGzMembers(download, new Map(files.map(({ member, file }) => [member, partial(file)])));
71
109
  for (const { member, file } of files) {
72
110
  const target = path.join(directory, file);
111
+ // A whole file in place (an akm without this lock may have made it) stands: a rename would unlink a binary that
112
+ // may be running on Linux and fails on Windows.
113
+ if (isExecutableFile(target) && fs.statSync(target).size === sizes.get(member))
114
+ continue;
73
115
  fs.chmodSync(partial(file), 0o755);
74
- try {
75
- fs.renameSync(partial(file), target);
76
- }
77
- catch (error) {
78
- // Where a file in use cannot be replaced, the other process's file stands.
79
- if (!fs.existsSync(target) || fs.statSync(target).size !== sizes.get(member))
80
- throw error;
81
- }
116
+ fs.renameSync(partial(file), target);
82
117
  }
83
118
  }
84
119
  finally {
@@ -217,8 +217,8 @@ const agentMdRenderer = {
217
217
  },
218
218
  };
219
219
  // ── 4. knowledge-md ──────────────────────────────────────────────────────────
220
- const KNOWLEDGE_ACTION = "Reference material - read the content below. For a large document, read one section with `akm show <ref>#fragment` " +
221
- "(an unmatched #fragment lists the available slugs).";
220
+ const KNOWLEDGE_ACTION = "Reference material - read the content below. For a large document, run `akm show <ref> --detail brief` for its " +
221
+ "outline (`toc`, no body), then `akm show <ref>#<slug>` for only the sections you need.";
222
222
  const knowledgeMdRenderer = {
223
223
  name: "knowledge-md",
224
224
  buildShowResponse(ctx) {
@@ -348,6 +348,7 @@ export function shapeShowOutput(result, detail, shape = "human") {
348
348
  "editHint",
349
349
  "description",
350
350
  "action",
351
+ "toc",
351
352
  "content",
352
353
  "template",
353
354
  "prompt",
@@ -381,6 +382,7 @@ export function shapeShowOutput(result, detail, shape = "human") {
381
382
  "action",
382
383
  "description",
383
384
  "tags",
385
+ "toc",
384
386
  "content",
385
387
  "template",
386
388
  "prompt",
@@ -59,6 +59,13 @@ export function formatShowPlain(r, detail) {
59
59
  if (r.schemaVersion !== undefined)
60
60
  lines.push(`schemaVersion: ${String(r.schemaVersion)}`);
61
61
  }
62
+ if (Array.isArray(r.toc) && r.toc.length > 0) {
63
+ lines.push("");
64
+ lines.push("toc:");
65
+ for (const heading of r.toc) {
66
+ lines.push(` ${"#".repeat(heading.level)} ${heading.text} (#${heading.slug}, line ${heading.startLine})`);
67
+ }
68
+ }
62
69
  const links = typeof r.links === "object" && r.links !== null ? r.links : undefined;
63
70
  if (links) {
64
71
  lines.push("");
@@ -41,6 +41,18 @@ export function isSemverRange(input) {
41
41
  export function maxSatisfying(versions, range) {
42
42
  return semver.maxSatisfying(versions, range) ?? undefined;
43
43
  }
44
+ /**
45
+ * Highest of `versions` in semver order, prereleases included (`0.10.1-beta` sorts above `0.9.30`). Invalid entries
46
+ * are ignored; `undefined` when none is valid. Use this to order known versions, not `maxSatisfying(versions, "*")`,
47
+ * which skips every prerelease.
48
+ */
49
+ export function highestVersion(versions) {
50
+ return semver.rsort(versions.filter((version) => semver.valid(version) !== null))[0];
51
+ }
52
+ /** True when `version` is a valid semver prerelease (`1.2.3-beta`). */
53
+ export function isPrerelease(version) {
54
+ return semver.valid(version) !== null && semver.prerelease(version) !== null;
55
+ }
44
56
  /** True when `version` satisfies `range` (both real semver forms). False for an invalid version or range. */
45
57
  export function satisfiesRange(version, range) {
46
58
  return semver.satisfies(version, range);