akm-cli 0.9.0-beta.1 → 0.9.0-beta.11
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 +492 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +201 -14
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +73 -8
- package/dist/commands/improve/improve-auto-accept.js +63 -3
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +874 -447
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +33 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +154 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
package/dist/tasks/runner.js
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* 4. Dispatch by target kind:
|
|
17
17
|
* • workflow → `startWorkflowRun(ref, params)`
|
|
18
18
|
* • prompt → `runAgent(profile, prompt, { stdio: "captured" })`
|
|
19
|
-
* 5. Capture stdout / stderr
|
|
19
|
+
* 5. Capture stdout / stderr as structured rows in logs.db (task_logs) and,
|
|
20
|
+
* transitionally, as a flat text tail at `<cacheDir>/tasks/logs/<id>/<ts>.log`
|
|
21
|
+
* (see docs/technical/logs-audit.md).
|
|
20
22
|
* 6. Write a history row to state.db task_history table.
|
|
21
23
|
*
|
|
22
24
|
* Returns a structured result so the CLI handler can shape it for `output()`
|
|
@@ -29,6 +31,7 @@ import { parseAssetRef } from "../core/asset/asset-ref.js";
|
|
|
29
31
|
import { resolveStashDir } from "../core/common.js";
|
|
30
32
|
import { loadConfig } from "../core/config/config.js";
|
|
31
33
|
import { NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
|
|
34
|
+
import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
|
|
32
35
|
import { getTaskLogDir } from "../core/paths.js";
|
|
33
36
|
import { getTaskHistory, openStateDatabase, queryTaskHistory, upsertTaskHistory } from "../core/state-db.js";
|
|
34
37
|
import { error } from "../core/warn.js";
|
|
@@ -70,7 +73,15 @@ export async function runTask(id, options = {}) {
|
|
|
70
73
|
log: logPath,
|
|
71
74
|
target: disabledTarget,
|
|
72
75
|
};
|
|
73
|
-
|
|
76
|
+
const disabledLine = `[akm tasks] task "${id}" is disabled — skipping run.`;
|
|
77
|
+
persistRunLog({
|
|
78
|
+
taskId: id,
|
|
79
|
+
startedAtIso: startedIso,
|
|
80
|
+
finishedAtIso: result.finishedAt,
|
|
81
|
+
logPath,
|
|
82
|
+
fileText: `${disabledLine}\n`,
|
|
83
|
+
dbLines: [{ line: disabledLine }],
|
|
84
|
+
});
|
|
74
85
|
appendHistory(result);
|
|
75
86
|
return result;
|
|
76
87
|
}
|
|
@@ -108,7 +119,9 @@ async function runCommandTask(input) {
|
|
|
108
119
|
throw new Error("invariant: command target");
|
|
109
120
|
const { cmd } = task.target;
|
|
110
121
|
const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : null;
|
|
111
|
-
const
|
|
122
|
+
const header = `[akm tasks] task=${task.id} kind=command cmd=${cmd.join(" ")}`;
|
|
123
|
+
const logLines = [header];
|
|
124
|
+
const dbLines = [{ line: header }];
|
|
112
125
|
let stdout = "";
|
|
113
126
|
let stderr = "";
|
|
114
127
|
let exitCode = null;
|
|
@@ -144,24 +157,36 @@ async function runCommandTask(input) {
|
|
|
144
157
|
exitCode = proc.exitCode ?? (timedOut ? 143 : 1);
|
|
145
158
|
if (timedOut) {
|
|
146
159
|
logLines.push(`timed_out=true timeout_ms=${timeoutMs}`);
|
|
160
|
+
dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${timeoutMs}` });
|
|
147
161
|
}
|
|
148
162
|
logLines.push(`exit_code=${exitCode}`);
|
|
163
|
+
dbLines.push({ level: exitCode === 0 ? "info" : "error", line: `exit_code=${exitCode}` });
|
|
149
164
|
if (stdout) {
|
|
150
165
|
logLines.push("--- stdout ---");
|
|
151
166
|
logLines.push(stdout);
|
|
167
|
+
dbLines.push(...streamLines(stdout, "stdout", "info"));
|
|
152
168
|
}
|
|
153
169
|
if (stderr) {
|
|
154
170
|
logLines.push("--- stderr ---");
|
|
155
171
|
logLines.push(stderr);
|
|
172
|
+
dbLines.push(...streamLines(stderr, "stderr", "error"));
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
catch (e) {
|
|
159
176
|
const msg = e instanceof Error ? e.message : String(e);
|
|
160
177
|
logLines.push(`spawn_error=${msg}`);
|
|
178
|
+
dbLines.push({ level: "error", line: `spawn_error=${msg}` });
|
|
161
179
|
exitCode = 1;
|
|
162
180
|
}
|
|
163
|
-
fs.writeFileSync(logPath, `${logLines.join("\n")}\n`);
|
|
164
181
|
const finishedAt = now();
|
|
182
|
+
persistRunLog({
|
|
183
|
+
taskId: task.id,
|
|
184
|
+
startedAtIso: startedAt.toISOString(),
|
|
185
|
+
finishedAtIso: finishedAt.toISOString(),
|
|
186
|
+
logPath,
|
|
187
|
+
fileText: `${logLines.join("\n")}\n`,
|
|
188
|
+
dbLines,
|
|
189
|
+
});
|
|
165
190
|
const status = exitCode === 0 ? "completed" : "failed";
|
|
166
191
|
const result = {
|
|
167
192
|
id: task.id,
|
|
@@ -196,7 +221,14 @@ async function runWorkflowTask(input) {
|
|
|
196
221
|
const finishedAt = now();
|
|
197
222
|
const status = error ? "failed" : mapWorkflowStatus(detail?.run.status);
|
|
198
223
|
const log = renderWorkflowLog({ task, detail, error });
|
|
199
|
-
|
|
224
|
+
persistRunLog({
|
|
225
|
+
taskId: task.id,
|
|
226
|
+
startedAtIso: startedAt.toISOString(),
|
|
227
|
+
finishedAtIso: finishedAt.toISOString(),
|
|
228
|
+
logPath,
|
|
229
|
+
fileText: log.fileText,
|
|
230
|
+
dbLines: log.dbLines,
|
|
231
|
+
});
|
|
200
232
|
const result = {
|
|
201
233
|
id: task.id,
|
|
202
234
|
status,
|
|
@@ -248,16 +280,17 @@ function mapWorkflowStatus(status) {
|
|
|
248
280
|
}
|
|
249
281
|
}
|
|
250
282
|
function renderWorkflowLog(input) {
|
|
251
|
-
const
|
|
252
|
-
|
|
283
|
+
const dbLines = [
|
|
284
|
+
{ line: `[akm tasks] task=${input.task.id} kind=workflow ref=${input.task.target.ref}` },
|
|
285
|
+
];
|
|
253
286
|
if (input.detail) {
|
|
254
|
-
|
|
255
|
-
|
|
287
|
+
dbLines.push({ line: `run_id=${input.detail.run.id} status=${input.detail.run.status}` });
|
|
288
|
+
dbLines.push({ line: `workflow_title=${input.detail.run.workflowTitle}` });
|
|
256
289
|
}
|
|
257
290
|
if (input.error) {
|
|
258
|
-
|
|
291
|
+
dbLines.push({ level: "error", line: `error=${input.error.message}` });
|
|
259
292
|
}
|
|
260
|
-
return `${
|
|
293
|
+
return { fileText: `${dbLines.map((entry) => entry.line).join("\n")}\n`, dbLines };
|
|
261
294
|
}
|
|
262
295
|
// ── prompt target ───────────────────────────────────────────────────────────
|
|
263
296
|
async function runPromptTask(input) {
|
|
@@ -321,7 +354,14 @@ async function runPromptTask(input) {
|
|
|
321
354
|
});
|
|
322
355
|
const finishedAt = now();
|
|
323
356
|
const log = renderPromptLog({ task, profileName: profile.name, result });
|
|
324
|
-
|
|
357
|
+
persistRunLog({
|
|
358
|
+
taskId: task.id,
|
|
359
|
+
startedAtIso: startedAt.toISOString(),
|
|
360
|
+
finishedAtIso: finishedAt.toISOString(),
|
|
361
|
+
logPath,
|
|
362
|
+
fileText: log.fileText,
|
|
363
|
+
dbLines: log.dbLines,
|
|
364
|
+
});
|
|
325
365
|
const status = result.ok ? "completed" : "failed";
|
|
326
366
|
const out = {
|
|
327
367
|
id: task.id,
|
|
@@ -359,20 +399,63 @@ async function resolvePromptText(task, stashDir) {
|
|
|
359
399
|
}
|
|
360
400
|
function renderPromptLog(input) {
|
|
361
401
|
const lines = [];
|
|
362
|
-
|
|
363
|
-
|
|
402
|
+
const dbLines = [];
|
|
403
|
+
const header = `[akm tasks] task=${input.task.id} kind=prompt profile=${input.profileName}`;
|
|
404
|
+
const summary = `ok=${input.result.ok} exit_code=${input.result.exitCode ?? "null"} duration_ms=${input.result.durationMs}`;
|
|
405
|
+
lines.push(header, summary);
|
|
406
|
+
dbLines.push({ line: header }, { level: input.result.ok ? "info" : "error", line: summary });
|
|
364
407
|
if (!input.result.ok) {
|
|
365
|
-
|
|
408
|
+
const failure = `reason=${input.result.reason ?? ""} error=${input.result.error ?? ""}`;
|
|
409
|
+
lines.push(failure);
|
|
410
|
+
dbLines.push({ level: "error", line: failure });
|
|
366
411
|
}
|
|
367
412
|
if (input.result.stdout) {
|
|
368
413
|
lines.push("--- agent stdout ---");
|
|
369
414
|
lines.push(input.result.stdout);
|
|
415
|
+
dbLines.push(...streamLines(input.result.stdout, "stdout", "info"));
|
|
370
416
|
}
|
|
371
417
|
if (input.result.stderr) {
|
|
372
418
|
lines.push("--- agent stderr ---");
|
|
373
419
|
lines.push(input.result.stderr);
|
|
420
|
+
dbLines.push(...streamLines(input.result.stderr, "stderr", "error"));
|
|
421
|
+
}
|
|
422
|
+
return { fileText: `${lines.join("\n")}\n`, dbLines };
|
|
423
|
+
}
|
|
424
|
+
/** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
|
|
425
|
+
function streamLines(text, stream, level) {
|
|
426
|
+
return text
|
|
427
|
+
.split("\n")
|
|
428
|
+
.filter((line) => line.length > 0)
|
|
429
|
+
.map((line) => ({ stream, level, line }));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Persist a finished run's log: the flat text file (so `log_path` in
|
|
433
|
+
* task_history keeps resolving for humans and older consumers) plus
|
|
434
|
+
* structured rows in logs.db keyed by `buildTaskRunId(taskId, startedAt)`.
|
|
435
|
+
*
|
|
436
|
+
* The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
|
|
437
|
+
* logs.db must never fail a task run.
|
|
438
|
+
*/
|
|
439
|
+
function persistRunLog(input) {
|
|
440
|
+
fs.writeFileSync(input.logPath, input.fileText);
|
|
441
|
+
try {
|
|
442
|
+
const db = openLogsDatabase();
|
|
443
|
+
try {
|
|
444
|
+
insertTaskLogLines(db, {
|
|
445
|
+
taskId: input.taskId,
|
|
446
|
+
runId: buildTaskRunId(input.taskId, input.startedAtIso),
|
|
447
|
+
ts: input.finishedAtIso,
|
|
448
|
+
lines: input.dbLines,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
rethrowIfTestIsolationError(err);
|
|
457
|
+
error(`[akm] task log DB write failed: ${String(err)}`);
|
|
374
458
|
}
|
|
375
|
-
return `${lines.join("\n")}\n`;
|
|
376
459
|
}
|
|
377
460
|
// ── history ─────────────────────────────────────────────────────────────────
|
|
378
461
|
function appendHistory(result) {
|
package/dist/workflows/db.js
CHANGED
|
@@ -47,6 +47,10 @@ export function openWorkflowDatabase(dbPath = getWorkflowDbPath()) {
|
|
|
47
47
|
}
|
|
48
48
|
const db = openDatabase(dbPath);
|
|
49
49
|
db.exec("PRAGMA journal_mode = WAL");
|
|
50
|
+
// #589: 30 s busy timeout, matching index.db / state.db. Without it the
|
|
51
|
+
// default is 0 ms, so any concurrent writer fails immediately with
|
|
52
|
+
// SQLITE_BUSY.
|
|
53
|
+
db.exec("PRAGMA busy_timeout = 30000");
|
|
50
54
|
db.exec("PRAGMA foreign_keys = ON");
|
|
51
55
|
ensureBaseSchema(db);
|
|
52
56
|
runMigrations(db);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.9.0-beta.
|
|
3
|
+
"version": "0.9.0-beta.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
|
|
6
6
|
"keywords": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"preinstall": "node -e \"var ua=process.env.npm_config_user_agent||'';var major=parseInt((process.versions.node||'0').split('.')[0],10);if(process.versions.bun||ua.startsWith('bun/')||process.env.BUN_INSTALL||major>=20){process.exit(0)}console.error('\\n ERROR: akm-cli requires the Bun runtime (https://bun.sh), Node.js >= 20, or the prebuilt binary.\\n Install options:\\n 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\\n 2. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\\n');process.exit(1)\"",
|
|
54
|
-
"build": "rm -rf dist && bun run tsc --project ./tsconfig.build.json && bun scripts/copy-assets.ts && bun scripts/fix-esm-extensions.ts",
|
|
54
|
+
"build": "rm -rf dist && bun scripts/gen-config-schema.ts &&bun run tsc --project ./tsconfig.build.json && bun scripts/copy-assets.ts && bun scripts/fix-esm-extensions.ts",
|
|
55
55
|
"check": "bun run lint && bunx tsc --noEmit && bun run test:unit && bun run test:integration",
|
|
56
56
|
"check:fast": "bun run lint && bunx tsc --noEmit && bun run test:unit",
|
|
57
57
|
"check:changed": "bun test tests/output-baseline.test.ts tests/integration/e2e.test.ts tests/stash-search.test.ts && bun run lint && bunx tsc --noEmit",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"test:unit": "bun test --parallel=${TEST_PARALLEL:-12} --timeout=30000 ./tests --path-ignore-patterns=tests/integration",
|
|
60
60
|
"test:integration": "bun test --parallel=${TEST_PARALLEL:-12} --timeout=30000 ./tests/integration ./tests/commands ./tests/workflows",
|
|
61
61
|
"test:node-smoke": "bun scripts/node-smoke.ts",
|
|
62
|
+
"test:node-compat": "AKM_NODE_COMPAT_TESTS=1 bun test --timeout=120000 tests/integration/node-compat.test.ts",
|
|
62
63
|
"test:sharded": "bun test ./tests --shard=1/4 & bun test ./tests --shard=2/4 & bun test ./tests --shard=3/4 & bun test ./tests --shard=4/4 & wait",
|
|
63
64
|
"test:time": "bun scripts/test-timing-report.ts",
|
|
64
65
|
"lint:isolation": "bun scripts/lint-tests-isolation.ts",
|
|
@@ -1,344 +0,0 @@
|
|
|
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
|
-
* Interactive `akm config edit` — a schema-driven, menu-based config editor.
|
|
6
|
-
*
|
|
7
|
-
* ## Why @clack/prompts (not a widget TUI)
|
|
8
|
-
*
|
|
9
|
-
* The issue (#513) originally proposed a `neo-blessed` BIOS-style widget TUI.
|
|
10
|
-
* After evaluation (see `docs/technical/ink-tui-evaluation.md` and the #513
|
|
11
|
-
* comments) we ship this on `@clack/prompts` — the prompt library akm already
|
|
12
|
-
* uses for `akm setup` and `confirmDestructive`. Zero new deps, the same
|
|
13
|
-
* interaction paradigm as setup, and a proven packaging path through the
|
|
14
|
-
* `bun build --compile` single binary.
|
|
15
|
-
*
|
|
16
|
-
* ## Schema-driven, single source of truth
|
|
17
|
-
*
|
|
18
|
-
* The section list, the per-section fields, and each field's input type are
|
|
19
|
-
* DERIVED from the Zod config schema (`core/config/config-schema.ts`) by
|
|
20
|
-
* {@link buildConfigEditModel}. There is no hand-maintained parallel field
|
|
21
|
-
* table — adding a field to the schema makes it appear in the editor for free.
|
|
22
|
-
*
|
|
23
|
-
* ## Reuse, don't reimplement
|
|
24
|
-
*
|
|
25
|
-
* The write path reuses the existing machinery verbatim:
|
|
26
|
-
* - {@link setConfigValue} (the config-cli walker front-end) for coercion,
|
|
27
|
-
* validation, legacy aliasing, and apiKey rejection.
|
|
28
|
-
* - {@link loadConfig} / {@link saveConfig} for read/write.
|
|
29
|
-
* - {@link backupExistingConfig} for the timestamped pre-write snapshot.
|
|
30
|
-
*
|
|
31
|
-
* ## Pure core, thin shell
|
|
32
|
-
*
|
|
33
|
-
* {@link buildConfigEditModel} and {@link applyConfigEdit} are pure and unit
|
|
34
|
-
* tested directly — no TTY required. {@link runConfigEdit} is the thin
|
|
35
|
-
* @clack interaction layer.
|
|
36
|
-
*/
|
|
37
|
-
import * as p from "@clack/prompts";
|
|
38
|
-
import { z } from "zod";
|
|
39
|
-
import { loadConfig, saveConfig } from "../core/config/config.js";
|
|
40
|
-
import { backupExistingConfig } from "../core/config/config-io.js";
|
|
41
|
-
import { AkmConfigShape } from "../core/config/config-schema.js";
|
|
42
|
-
import { UsageError } from "../core/errors.js";
|
|
43
|
-
import { getConfigPath } from "../core/paths.js";
|
|
44
|
-
import { getConfigValue, setConfigValue } from "./config-cli.js";
|
|
45
|
-
/** Maximum nesting depth walked when deriving fields. Guards against records. */
|
|
46
|
-
const MAX_FIELD_DEPTH = 3;
|
|
47
|
-
/** Strip Zod wrappers (.optional/.default/.nullable/.catch/.effects). */
|
|
48
|
-
function unwrapSchema(schema) {
|
|
49
|
-
let current = schema;
|
|
50
|
-
for (;;) {
|
|
51
|
-
if (current instanceof z.ZodOptional)
|
|
52
|
-
current = current._def.innerType;
|
|
53
|
-
else if (current instanceof z.ZodDefault)
|
|
54
|
-
current = current._def.innerType;
|
|
55
|
-
else if (current instanceof z.ZodNullable)
|
|
56
|
-
current = current._def.innerType;
|
|
57
|
-
else if (current instanceof z.ZodCatch)
|
|
58
|
-
current = current._def.innerType;
|
|
59
|
-
else if (current instanceof z.ZodReadonly)
|
|
60
|
-
current = current._def.innerType;
|
|
61
|
-
else if (current instanceof z.ZodEffects)
|
|
62
|
-
current = current._def.schema;
|
|
63
|
-
else
|
|
64
|
-
return current;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/** Classify an unwrapped leaf schema into a {@link ConfigFieldKind}. */
|
|
68
|
-
function classifyLeaf(schema, isSecret) {
|
|
69
|
-
if (isSecret)
|
|
70
|
-
return "secret";
|
|
71
|
-
if (schema instanceof z.ZodString)
|
|
72
|
-
return "text";
|
|
73
|
-
if (schema instanceof z.ZodNumber)
|
|
74
|
-
return "number";
|
|
75
|
-
if (schema instanceof z.ZodBoolean)
|
|
76
|
-
return "boolean";
|
|
77
|
-
if (schema instanceof z.ZodEnum)
|
|
78
|
-
return "select";
|
|
79
|
-
// ZodNativeEnum / ZodLiteral are treated as select/text fallbacks.
|
|
80
|
-
if (schema instanceof z.ZodLiteral)
|
|
81
|
-
return "text";
|
|
82
|
-
// Unions of primitives (e.g. configVersion: string|number) → text.
|
|
83
|
-
if (schema instanceof z.ZodUnion) {
|
|
84
|
-
const opts = schema._def.options.map(unwrapSchema);
|
|
85
|
-
if (opts.some((o) => o instanceof z.ZodString || o instanceof z.ZodNumber))
|
|
86
|
-
return "text";
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Recursively collect editable leaf fields from an object schema. Descends
|
|
92
|
-
* into nested `z.object(...)` shapes (building dotted paths); records, arrays,
|
|
93
|
-
* and unknown composites are surfaced as a single `json` field so the user can
|
|
94
|
-
* still edit them as raw JSON via the walker's JSON coercion path.
|
|
95
|
-
*/
|
|
96
|
-
function collectFields(schema, prefix, depth) {
|
|
97
|
-
const unwrapped = unwrapSchema(schema);
|
|
98
|
-
const fields = [];
|
|
99
|
-
if (unwrapped instanceof z.ZodObject && depth < MAX_FIELD_DEPTH) {
|
|
100
|
-
const shape = unwrapped.shape;
|
|
101
|
-
for (const [key, child] of Object.entries(shape)) {
|
|
102
|
-
const path = prefix ? `${prefix}.${key}` : key;
|
|
103
|
-
const childUnwrapped = unwrapSchema(child);
|
|
104
|
-
const isSecret = key === "apiKey";
|
|
105
|
-
if (childUnwrapped instanceof z.ZodObject && depth + 1 < MAX_FIELD_DEPTH) {
|
|
106
|
-
fields.push(...collectFields(childUnwrapped, path, depth + 1));
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const kind = classifyLeaf(childUnwrapped, isSecret);
|
|
110
|
-
if (kind === null) {
|
|
111
|
-
// Records / arrays / nested composites at the depth limit: editable as JSON.
|
|
112
|
-
if (childUnwrapped instanceof z.ZodRecord ||
|
|
113
|
-
childUnwrapped instanceof z.ZodArray ||
|
|
114
|
-
childUnwrapped instanceof z.ZodObject) {
|
|
115
|
-
fields.push({ path, label: key, kind: "json", secret: false });
|
|
116
|
-
}
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const field = {
|
|
120
|
-
path,
|
|
121
|
-
label: key,
|
|
122
|
-
kind,
|
|
123
|
-
secret: kind === "secret",
|
|
124
|
-
};
|
|
125
|
-
if (kind === "select") {
|
|
126
|
-
field.options = [...childUnwrapped._def.values];
|
|
127
|
-
}
|
|
128
|
-
fields.push(field);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return fields;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Build the schema-driven edit model: one section per top-level config key,
|
|
135
|
-
* each with its editable leaf fields and input kinds. Pure — depends only on
|
|
136
|
-
* the schema (the `config` argument is reserved for future value-aware
|
|
137
|
-
* shaping; current callers pass it through unchanged for symmetry with
|
|
138
|
-
* {@link applyConfigEdit}).
|
|
139
|
-
*
|
|
140
|
-
* Sections that yield no editable fields (pure records/arrays like `sources`,
|
|
141
|
-
* `installed`, `registries`, `index`, `profiles`) are still surfaced with a
|
|
142
|
-
* single `json` field so they remain reachable in the menu.
|
|
143
|
-
*/
|
|
144
|
-
export function buildConfigEditModel(shape = AkmConfigShape, _config) {
|
|
145
|
-
const sections = [];
|
|
146
|
-
for (const [key, schema] of Object.entries(shape)) {
|
|
147
|
-
const unwrapped = unwrapSchema(schema);
|
|
148
|
-
let fields;
|
|
149
|
-
if (unwrapped instanceof z.ZodObject) {
|
|
150
|
-
fields = collectFields(unwrapped, key, 1);
|
|
151
|
-
if (fields.length === 0) {
|
|
152
|
-
fields = [{ path: key, label: key, kind: "json", secret: false }];
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
const kind = classifyLeaf(unwrapped, false);
|
|
157
|
-
if (kind) {
|
|
158
|
-
const field = { path: key, label: key, kind, secret: false };
|
|
159
|
-
if (kind === "select")
|
|
160
|
-
field.options = [...unwrapped._def.values];
|
|
161
|
-
fields = [field];
|
|
162
|
-
}
|
|
163
|
-
else {
|
|
164
|
-
// Arrays / records / unknown top-level shapes → editable as JSON.
|
|
165
|
-
fields = [{ path: key, label: key, kind: "json", secret: false }];
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
sections.push({ key, fields });
|
|
169
|
-
}
|
|
170
|
-
return sections.length > 0 ? { sections } : { sections: [] };
|
|
171
|
-
}
|
|
172
|
-
// ── Apply (pure write delegation) ────────────────────────────────────────────
|
|
173
|
-
/**
|
|
174
|
-
* Apply a single edit to a config object, returning the next config. Pure —
|
|
175
|
-
* delegates to {@link setConfigValue} (the existing walker front-end), so it
|
|
176
|
-
* inherits coercion, schema validation, legacy aliasing, AND the apiKey
|
|
177
|
-
* rejection guard (#454). Callers must NOT pass apiKey paths; the editor shell
|
|
178
|
-
* routes secrets to env-var guidance and never reaches here for them.
|
|
179
|
-
*
|
|
180
|
-
* @throws UsageError on apiKey paths, unknown keys, or invalid values.
|
|
181
|
-
*/
|
|
182
|
-
export function applyConfigEdit(config, path, value) {
|
|
183
|
-
return setConfigValue(config, path, value);
|
|
184
|
-
}
|
|
185
|
-
/** Environment variable a secret field steers the user toward (#454). */
|
|
186
|
-
export function envVarForSecret(path) {
|
|
187
|
-
if (path === "embedding.apiKey")
|
|
188
|
-
return "AKM_EMBED_API_KEY";
|
|
189
|
-
if (path === "llm.apiKey")
|
|
190
|
-
return "AKM_LLM_API_KEY";
|
|
191
|
-
if (path.startsWith("profiles.llm."))
|
|
192
|
-
return "AKM_LLM_API_KEY";
|
|
193
|
-
return "AKM_LLM_API_KEY / AKM_EMBED_API_KEY";
|
|
194
|
-
}
|
|
195
|
-
// ── Interactive shell (thin @clack layer) ────────────────────────────────────
|
|
196
|
-
/**
|
|
197
|
-
* Determine whether the current process can run an interactive editor.
|
|
198
|
-
* Requires a real TTY on both stdin and stdout and a non-CI environment.
|
|
199
|
-
*/
|
|
200
|
-
export function isInteractiveTerminal(env = process.env) {
|
|
201
|
-
const ci = env.CI;
|
|
202
|
-
const isCi = ci !== undefined && ci !== null && !["", "0", "false"].includes(String(ci).trim().toLowerCase());
|
|
203
|
-
if (isCi)
|
|
204
|
-
return false;
|
|
205
|
-
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
206
|
-
}
|
|
207
|
-
const NON_INTERACTIVE_MESSAGE = "`akm config edit` is interactive and requires a TTY. " +
|
|
208
|
-
"Use `akm config set <key> <value>` for scripted or CI edits.";
|
|
209
|
-
function formatValue(value) {
|
|
210
|
-
if (value === null || value === undefined)
|
|
211
|
-
return "(unset)";
|
|
212
|
-
if (typeof value === "object")
|
|
213
|
-
return JSON.stringify(value);
|
|
214
|
-
return String(value);
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Run the interactive config editor. Throws {@link UsageError} when no TTY is
|
|
218
|
-
* available (CI / piped). Otherwise drives a menu loop:
|
|
219
|
-
* section select → field select → typed value prompt → confirm → backup+save.
|
|
220
|
-
*/
|
|
221
|
-
export async function runConfigEdit() {
|
|
222
|
-
if (!isInteractiveTerminal()) {
|
|
223
|
-
throw new UsageError(NON_INTERACTIVE_MESSAGE, "NON_INTERACTIVE_REQUIRES_YES");
|
|
224
|
-
}
|
|
225
|
-
let config = loadConfig();
|
|
226
|
-
const model = buildConfigEditModel(AkmConfigShape, config);
|
|
227
|
-
let dirty = false;
|
|
228
|
-
p.intro("akm config edit");
|
|
229
|
-
for (;;) {
|
|
230
|
-
const sectionKey = await p.select({
|
|
231
|
-
message: "Select a config section to edit:",
|
|
232
|
-
options: [
|
|
233
|
-
...model.sections.map((s) => ({ value: s.key, label: s.key })),
|
|
234
|
-
{ value: "__exit__", label: dirty ? "Save and exit" : "Exit" },
|
|
235
|
-
],
|
|
236
|
-
});
|
|
237
|
-
if (p.isCancel(sectionKey) || sectionKey === "__exit__")
|
|
238
|
-
break;
|
|
239
|
-
const section = model.sections.find((s) => s.key === sectionKey);
|
|
240
|
-
if (!section)
|
|
241
|
-
continue;
|
|
242
|
-
const fieldPath = await p.select({
|
|
243
|
-
message: `Select a field in "${section.key}":`,
|
|
244
|
-
options: [
|
|
245
|
-
...section.fields.map((f) => ({
|
|
246
|
-
value: f.path,
|
|
247
|
-
label: f.label,
|
|
248
|
-
hint: `${f.kind} — ${formatValue(safeGet(config, f.path))}`,
|
|
249
|
-
})),
|
|
250
|
-
{ value: "__back__", label: "← Back" },
|
|
251
|
-
],
|
|
252
|
-
});
|
|
253
|
-
if (p.isCancel(fieldPath) || fieldPath === "__back__")
|
|
254
|
-
continue;
|
|
255
|
-
const field = section.fields.find((f) => f.path === fieldPath);
|
|
256
|
-
if (!field)
|
|
257
|
-
continue;
|
|
258
|
-
// #454: never persist secrets. Show env-var guidance and skip the write.
|
|
259
|
-
if (field.secret) {
|
|
260
|
-
p.note(`API keys are never stored in config (they leak through backups, logs, and version control).\n` +
|
|
261
|
-
`Export the environment variable instead:\n\n export ${envVarForSecret(field.path)}=…\n\n` +
|
|
262
|
-
`AKM reads it at request time.`, "apiKey is not persisted");
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const newValue = await promptForField(field, safeGet(config, field.path));
|
|
266
|
-
if (newValue === undefined)
|
|
267
|
-
continue; // cancelled / back
|
|
268
|
-
try {
|
|
269
|
-
config = applyConfigEdit(config, field.path, newValue);
|
|
270
|
-
dirty = true;
|
|
271
|
-
p.log.success(`Set ${field.path} = ${newValue}`);
|
|
272
|
-
}
|
|
273
|
-
catch (err) {
|
|
274
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
275
|
-
p.log.error(msg);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
if (!dirty) {
|
|
279
|
-
p.outro("No changes made.");
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const confirmed = await p.confirm({ message: "Save changes to config?", initialValue: true });
|
|
283
|
-
if (p.isCancel(confirmed) || confirmed !== true) {
|
|
284
|
-
p.outro("Discarded changes.");
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const backup = backupExistingConfig(getConfigPath());
|
|
288
|
-
saveConfig(config);
|
|
289
|
-
if (backup) {
|
|
290
|
-
p.outro(`Saved. Backup written to ${backup.timestamped}`);
|
|
291
|
-
}
|
|
292
|
-
else {
|
|
293
|
-
p.outro("Saved.");
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
/** Read a value via the existing walker front-end, swallowing unknown-key errors. */
|
|
297
|
-
function safeGet(config, path) {
|
|
298
|
-
try {
|
|
299
|
-
return getConfigValue(config, path);
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
return undefined;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
/**
|
|
306
|
-
* Prompt for a single field's new value, typed by its schema-derived kind.
|
|
307
|
-
* Returns the raw string to pass to {@link applyConfigEdit}, or `undefined`
|
|
308
|
-
* when the user cancels.
|
|
309
|
-
*/
|
|
310
|
-
async function promptForField(field, current) {
|
|
311
|
-
if (field.kind === "boolean") {
|
|
312
|
-
const v = await p.confirm({
|
|
313
|
-
message: `${field.label}:`,
|
|
314
|
-
initialValue: current === true,
|
|
315
|
-
});
|
|
316
|
-
if (p.isCancel(v))
|
|
317
|
-
return undefined;
|
|
318
|
-
return v ? "true" : "false";
|
|
319
|
-
}
|
|
320
|
-
if (field.kind === "select" && field.options) {
|
|
321
|
-
const v = await p.select({
|
|
322
|
-
message: `${field.label}:`,
|
|
323
|
-
options: field.options.map((o) => ({ value: o, label: o })),
|
|
324
|
-
initialValue: typeof current === "string" ? current : undefined,
|
|
325
|
-
});
|
|
326
|
-
if (p.isCancel(v))
|
|
327
|
-
return undefined;
|
|
328
|
-
return v;
|
|
329
|
-
}
|
|
330
|
-
const placeholder = field.kind === "json" ? "JSON value (or empty to clear)" : "";
|
|
331
|
-
const initial = current === null || current === undefined
|
|
332
|
-
? ""
|
|
333
|
-
: typeof current === "object"
|
|
334
|
-
? JSON.stringify(current)
|
|
335
|
-
: String(current);
|
|
336
|
-
const v = await p.text({
|
|
337
|
-
message: `${field.label}${field.kind === "number" ? " (number)" : ""}:`,
|
|
338
|
-
placeholder,
|
|
339
|
-
initialValue: initial,
|
|
340
|
-
});
|
|
341
|
-
if (p.isCancel(v))
|
|
342
|
-
return undefined;
|
|
343
|
-
return v;
|
|
344
|
-
}
|