akm-cli 0.9.0-beta.0 → 0.9.0-beta.2
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 +83 -0
- package/dist/assets/profiles/quick.json +2 -1
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve.js +52 -14
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/file-lock.js +22 -0
- package/dist/tasks/backends/cron.js +46 -9
- package/package.json +1 -1
- package/dist/commands/config-edit.js +0 -344
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0-beta.2] - 2026-06-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Consolidation starved merge recall; the memory pool grew unbounded.** Commit
|
|
14
|
+
`633ece41` made the `incrementalSince` narrowing unconditional, so every
|
|
15
|
+
consolidation run only judged memories changed since the last run plus their
|
|
16
|
+
immediate vector-neighbors. Stale-but-unmerged duplicate clusters were never
|
|
17
|
+
re-examined, so the eligible pool grew monotonically and never shrank, and
|
|
18
|
+
contradiction detection (which rides on the consolidation pass) went dark.
|
|
19
|
+
Consolidation only runs on the nightly default-profile pass (`quick`/`frequent`
|
|
20
|
+
disable it), so a full-pool sweep is correct and affordable; the override is
|
|
21
|
+
removed. `lastConsolidateTs` still gates whether the pass runs. (Forward-port
|
|
22
|
+
of the 0.8.5 fix.)
|
|
23
|
+
- **`akm tasks sync` ignored schedule changes** — forward-ported from 0.8.4.
|
|
24
|
+
Sync classified any task already present in the OS scheduler as "unchanged"
|
|
25
|
+
without comparing its installed entry, so editing a task's `schedule:` in the
|
|
26
|
+
`.yml` never reached the crontab; the same gap affected `tasks enable`/`disable`
|
|
27
|
+
(toggled the comment, re-enabling a stale schedule). Sync now compares the
|
|
28
|
+
backend's installed signature against the signature the current definition
|
|
29
|
+
renders to and reinstalls on drift (new `updated[]` field); `enable`/`disable`
|
|
30
|
+
reinstall from the current `.yml`. The cron backend gains `expectedSignature()`
|
|
31
|
+
and a per-entry signature on `list()`; other backends fall back to an
|
|
32
|
+
idempotent reinstall.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`akm improve --skip-if-locked`** — forward-ported from 0.8.4. When another
|
|
37
|
+
improve run already holds the lock, the run logs and exits 0 with a no-op
|
|
38
|
+
result (`skipped.reason: "lock-held"`) instead of failing with the "already
|
|
39
|
+
running" config error (exit 78). Intended for high-frequency scheduled runs
|
|
40
|
+
(e.g. an every-30-min `quick` pass) that overlap a longer run. Default off.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- **`akm config edit`** — the interactive menu-based editor was removed. A
|
|
45
|
+
prompt-driven drill-down was clunkier than just editing the file. Edit the
|
|
46
|
+
config directly (the path is shown by `akm config path`), use
|
|
47
|
+
`akm config set/get/unset` for scripted changes, and `akm config validate` to
|
|
48
|
+
check it.
|
|
49
|
+
|
|
50
|
+
## [0.9.0-beta.1] - 2026-06-08
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **`improve.lock` leaked on signal death (cron timeout)** — forward-ported from
|
|
55
|
+
0.8.3. The improve SIGTERM/SIGINT/SIGHUP handler calls `process.exit()`, which
|
|
56
|
+
skips `finally` blocks, so the `finally` releasing `improve.lock` never ran and
|
|
57
|
+
every timed-out cron run leaked the lock. It is now released from a
|
|
58
|
+
`process.on("exit", …)` handler registered at acquire time, via a new
|
|
59
|
+
ownership-checked `releaseLockIfOwned(path, pid)`.
|
|
60
|
+
- **`quick` profile was not quick** — forward-ported from 0.8.3. It did not
|
|
61
|
+
disable the default-ON session-`extract` process, so a `quick` run processed
|
|
62
|
+
the entire session backlog (~40 min). `quick` now sets
|
|
63
|
+
`processes.extract.enabled: false`.
|
|
64
|
+
- **`akm-eval` smoke suite adapted to the 0.9.0 CLI** (CI/tooling only). The
|
|
65
|
+
eval harness called `akm search --detail agent`, but 0.9.0 moved the
|
|
66
|
+
agent/summary projections to `--shape`; it now uses `--shape agent`.
|
|
67
|
+
Additionally, the improve-run history readers (`listRecentImproveRunIds` /
|
|
68
|
+
`resolveImproveRunId`) treated a missing `state.db` as an error rather than
|
|
69
|
+
"no runs", which broke the read-only smoke + replay-determinism gates on a
|
|
70
|
+
fresh checkout; a missing `state.db` is now handled as an empty history.
|
|
71
|
+
|
|
9
72
|
## [0.9.0-beta.0] - 2026-06-08
|
|
10
73
|
|
|
11
74
|
### Added
|
|
@@ -191,6 +254,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
191
254
|
`migrate-storage` change is pinned by a sha256 + file-mode fixture-stash
|
|
192
255
|
differential test.
|
|
193
256
|
|
|
257
|
+
## [0.8.3] - 2026-06-08
|
|
258
|
+
|
|
259
|
+
### Fixed
|
|
260
|
+
|
|
261
|
+
- **`improve.lock` leaked on signal death (cron timeout).** The improve
|
|
262
|
+
SIGTERM/SIGINT/SIGHUP handler calls `process.exit()`, which skips `finally`
|
|
263
|
+
blocks — so the `finally` that releases `improve.lock` never ran, and every
|
|
264
|
+
timed-out cron run leaked the lock sentinel. (It wasn't a permanent deadlock
|
|
265
|
+
only because the next run reclaims a dead-PID lock, a path that PID reuse can
|
|
266
|
+
defeat.) The lock is now released from a `process.on("exit", …)` handler
|
|
267
|
+
registered at acquire time (exit handlers DO run on `process.exit()`), via a
|
|
268
|
+
new ownership-checked `releaseLockIfOwned(path, pid)` so a backstop release can
|
|
269
|
+
never delete a different run's lock. This generalizes to the budget watchdog
|
|
270
|
+
and any future exit path.
|
|
271
|
+
- **`quick` profile was not quick.** It was documented "Reflect-only" but did
|
|
272
|
+
not disable the session-`extract` process (which is default-ON), so a `quick`
|
|
273
|
+
run processed the entire unindexed-session backlog (~40 min) — guaranteeing a
|
|
274
|
+
5-minute cron timeout → SIGTERM → the lock leak above, every run. `quick` now
|
|
275
|
+
explicitly sets `processes.extract.enabled: false`.
|
|
276
|
+
|
|
194
277
|
## [0.8.2] - 2026-06-05
|
|
195
278
|
|
|
196
279
|
### Added
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Reflect-only pass — no distill, consolidate, memoryInference, or graphExtraction.",
|
|
2
|
+
"description": "Reflect-only pass — no extract, distill, consolidate, memoryInference, or graphExtraction.",
|
|
3
3
|
"processes": {
|
|
4
4
|
"reflect": {
|
|
5
5
|
"enabled": true,
|
|
6
6
|
"allowedTypes": ["agent", "command", "knowledge", "lesson", "memory", "skill", "wiki", "workflow"]
|
|
7
7
|
},
|
|
8
|
+
"extract": { "enabled": false },
|
|
8
9
|
"distill": { "enabled": false },
|
|
9
10
|
"consolidate": { "enabled": false },
|
|
10
11
|
"memoryInference": { "enabled": false },
|
|
@@ -320,16 +320,6 @@ export const configCommand = defineJsonCommand({
|
|
|
320
320
|
}
|
|
321
321
|
},
|
|
322
322
|
}),
|
|
323
|
-
edit: defineJsonCommand({
|
|
324
|
-
meta: {
|
|
325
|
-
name: "edit",
|
|
326
|
-
description: "Interactively edit configuration via a schema-driven menu (TTY only).",
|
|
327
|
-
},
|
|
328
|
-
async run() {
|
|
329
|
-
const { runConfigEdit } = await import("./config-edit.js");
|
|
330
|
-
await runConfigEdit();
|
|
331
|
-
},
|
|
332
|
-
}),
|
|
333
323
|
validate: defineJsonCommand({
|
|
334
324
|
meta: {
|
|
335
325
|
name: "validate",
|
|
@@ -54,6 +54,11 @@ export const improveCommand = defineCommand({
|
|
|
54
54
|
description: "Emit the full JSON result on stdout (legacy behaviour). (0.8.0+: full result is recorded in the improve_runs table of state.db and stdout is empty; use this flag for the prior behaviour, e.g. `akm improve --json-to-stdout | jq`.)",
|
|
55
55
|
default: false,
|
|
56
56
|
},
|
|
57
|
+
"skip-if-locked": {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
|
|
60
|
+
default: false,
|
|
61
|
+
},
|
|
57
62
|
profile: {
|
|
58
63
|
type: "string",
|
|
59
64
|
description: "Named improve profile from profiles.improve or built-in profiles (default, quick, thorough, memory-focus, graph-refresh). Controls which sub-processes run and which asset types are processed.",
|
|
@@ -92,6 +97,7 @@ export const improveCommand = defineCommand({
|
|
|
92
97
|
const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
|
|
93
98
|
const minRetrievalCount = parseNonNegativeIntFlag(minRetrievalCountRaw, "--min-retrieval-count");
|
|
94
99
|
const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
|
|
100
|
+
const skipIfLocked = getHyphenatedBoolean(args, "skip-if-locked");
|
|
95
101
|
const profileArg = getStringArg(args, "profile");
|
|
96
102
|
// Only set the keys the user actually passed (citty leaves the flag
|
|
97
103
|
// undefined unless `--sync`/`--no-sync` / `--push`/`--no-push` appears),
|
|
@@ -189,6 +195,7 @@ export const improveCommand = defineCommand({
|
|
|
189
195
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
190
196
|
...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
|
|
191
197
|
...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
|
|
198
|
+
...(skipIfLocked ? { skipIfLocked } : {}),
|
|
192
199
|
...(profileArg !== undefined ? { profile: profileArg } : {}),
|
|
193
200
|
...(Object.keys(syncOverride).length > 0 ? { sync: syncOverride } : {}),
|
|
194
201
|
consolidateOptions: {
|
|
@@ -10,7 +10,7 @@ import { daysToMs, isAssetType } from "../../core/common.js";
|
|
|
10
10
|
import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
|
|
11
11
|
import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
12
12
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
13
|
-
import { probeLock, releaseLock, tryAcquireLockSync } from "../../core/file-lock.js";
|
|
13
|
+
import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../../core/file-lock.js";
|
|
14
14
|
import { classifyImproveAction } from "../../core/improve-types.js";
|
|
15
15
|
import { getDbPath, getStateDbPathInDataDir } from "../../core/paths.js";
|
|
16
16
|
import { openStateDatabase, purgeOldEvents, purgeOldImproveRuns } from "../../core/state-db.js";
|
|
@@ -498,7 +498,7 @@ export async function akmImprove(options = {}) {
|
|
|
498
498
|
fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
|
|
499
499
|
const lockPayload = () => JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
500
500
|
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
501
|
-
return;
|
|
501
|
+
return "acquired";
|
|
502
502
|
// Lock file already exists — probe to determine whether it's still held
|
|
503
503
|
// or whether the prior run died without cleaning up.
|
|
504
504
|
const probe = probeLock(resolvedLockPath, { staleAfterMs: MAX_LOCK_AGE_MS });
|
|
@@ -533,9 +533,19 @@ export async function akmImprove(options = {}) {
|
|
|
533
533
|
}
|
|
534
534
|
releaseLock(resolvedLockPath);
|
|
535
535
|
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
536
|
-
return;
|
|
536
|
+
return "acquired";
|
|
537
|
+
// Lost the race to another run that grabbed the freed stale lock.
|
|
538
|
+
if (options.skipIfLocked) {
|
|
539
|
+
warn("[improve] another run acquired the lock during stale recovery; skipping (--skip-if-locked)");
|
|
540
|
+
return "skipped";
|
|
541
|
+
}
|
|
537
542
|
throw new ConfigError(`akm improve is already running. Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
538
543
|
}
|
|
544
|
+
// Lock is held by a live run within the staleness window.
|
|
545
|
+
if (options.skipIfLocked) {
|
|
546
|
+
warn(`[improve] another improve run holds the lock (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
|
|
547
|
+
return "skipped";
|
|
548
|
+
}
|
|
539
549
|
throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
540
550
|
};
|
|
541
551
|
// Phase 4 lock-leak guard (§7 ordering hazard): hoisting `improve.lock` above
|
|
@@ -560,6 +570,17 @@ export async function akmImprove(options = {}) {
|
|
|
560
570
|
}
|
|
561
571
|
lockAcquired = false;
|
|
562
572
|
};
|
|
573
|
+
// Signal-safe lock release. The SIGTERM/SIGINT/SIGHUP handler in improve-cli.ts
|
|
574
|
+
// calls `process.exit()`, which does NOT run the `finally` below that owns lock
|
|
575
|
+
// release — so a cron-timeout SIGTERM leaked `improve.lock` every run.
|
|
576
|
+
// `process.exit()` DOES fire `'exit'` listeners, so we release the lock from
|
|
577
|
+
// one. `releaseLockIfOwned` only unlinks a lock still owned by this PID, so it
|
|
578
|
+
// is safe even if a later run re-acquired it. The listener is removed in the
|
|
579
|
+
// `finally` so the normal path stays single-release and repeated in-process
|
|
580
|
+
// `akmImprove` calls (tests) do not accumulate listeners.
|
|
581
|
+
const releaseLockOnExit = () => {
|
|
582
|
+
releaseLockIfOwned(resolvedLockPath, process.pid);
|
|
583
|
+
};
|
|
563
584
|
const preEnsureCleanupWarnings = [];
|
|
564
585
|
let plannedRefs;
|
|
565
586
|
let memorySummary;
|
|
@@ -572,8 +593,25 @@ export async function akmImprove(options = {}) {
|
|
|
572
593
|
// The dry-run branch below produces plannedRefs/memorySummary WITHOUT the lock
|
|
573
594
|
// or triage (decision: dry-run never mutates the queue).
|
|
574
595
|
if (!options.dryRun) {
|
|
575
|
-
acquireLock()
|
|
596
|
+
if (acquireLock() === "skipped") {
|
|
597
|
+
// Another improve holds the lock and the caller asked to skip rather
|
|
598
|
+
// than fail. Return a clean no-op result (exit 0) before any index/DB
|
|
599
|
+
// work — never registered the exit listener, never set lockAcquired,
|
|
600
|
+
// so we release nothing belonging to the run that owns the lock.
|
|
601
|
+
return {
|
|
602
|
+
schemaVersion: 1,
|
|
603
|
+
ok: true,
|
|
604
|
+
scope,
|
|
605
|
+
dryRun: false,
|
|
606
|
+
skipped: { reason: "lock-held" },
|
|
607
|
+
memorySummary: { eligible: 0, derived: 0 },
|
|
608
|
+
plannedRefs: [],
|
|
609
|
+
};
|
|
610
|
+
}
|
|
576
611
|
lockAcquired = true;
|
|
612
|
+
// Backstop release on process.exit() (signal handler / budget watchdog),
|
|
613
|
+
// which skips the finally below. Removed in that finally on the normal path.
|
|
614
|
+
process.on("exit", releaseLockOnExit);
|
|
577
615
|
// Phase 4 triage pre-pass (§7, §13): drain the standing pending backlog
|
|
578
616
|
// BEFORE ensureIndex so improve generates fresh proposals against a cleared
|
|
579
617
|
// queue (no `duplicate_pending` collisions) and ensureIndex absorbs triage's
|
|
@@ -1002,6 +1040,9 @@ export async function akmImprove(options = {}) {
|
|
|
1002
1040
|
catch {
|
|
1003
1041
|
// ignore
|
|
1004
1042
|
}
|
|
1043
|
+
// The normal path released the lock above; drop the process.exit backstop so
|
|
1044
|
+
// it does not fire later (or accumulate across repeated in-process calls).
|
|
1045
|
+
process.removeListener("exit", releaseLockOnExit);
|
|
1005
1046
|
// I1: close the long-lived state.db connection opened at the top of the run.
|
|
1006
1047
|
try {
|
|
1007
1048
|
eventsDb?.close();
|
|
@@ -1324,16 +1365,13 @@ async function runConsolidationPass(args) {
|
|
|
1324
1365
|
// Tie consolidate proposals back to this improve invocation so
|
|
1325
1366
|
// accept-rate-per-run aggregation works. Mirrors reflect/propose/extract.
|
|
1326
1367
|
sourceRun: `consolidate-${Date.now()}`,
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1334
|
-
// volumeTriggered=true on every run, permanently forcing full 12-chunk
|
|
1335
|
-
// scans (~264s) instead of the intended 1-2 chunk incremental path (~44s).
|
|
1336
|
-
incrementalSince: lastConsolidateTs,
|
|
1368
|
+
// Full-pool sweep: consolidation only runs on the nightly default-profile
|
|
1369
|
+
// pass (quick/frequent disable it), so a complete re-cluster is correct and
|
|
1370
|
+
// affordable here. Do NOT pass incrementalSince — the time-window narrowing
|
|
1371
|
+
// it triggers permanently excludes stale-but-unmerged duplicate clusters,
|
|
1372
|
+
// starving merge recall and letting the pool grow unbounded. (The narrowing
|
|
1373
|
+
// was a band-aid for an every-30-min consolidation cadence that the profile
|
|
1374
|
+
// split has since eliminated.) lastConsolidateTs still gates whether we run.
|
|
1337
1375
|
maxChunkSize: improveProfile?.processes?.consolidate?.maxChunkSize,
|
|
1338
1376
|
// Honor profile.autoAccept (already merged into options.autoAccept at the
|
|
1339
1377
|
// top of akmImprove). The CLI parser always supplies 90 when --auto-accept
|
|
@@ -218,7 +218,13 @@ export async function akmTasksSetEnabled(id, enabled) {
|
|
|
218
218
|
fs.writeFileSync(filePath, updated, "utf8");
|
|
219
219
|
const sched = selectBackend();
|
|
220
220
|
try {
|
|
221
|
-
|
|
221
|
+
// Reinstall from the (just-updated) definition rather than only toggling
|
|
222
|
+
// the comment. A plain toggle leaves a stale schedule in place if the
|
|
223
|
+
// .yml's `schedule:` changed while the task was disabled — re-enabling
|
|
224
|
+
// would silently keep the old cron line. install() renders the block with
|
|
225
|
+
// both the current schedule and the new enabled state, and is idempotent.
|
|
226
|
+
const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
|
|
227
|
+
await sched.install(task);
|
|
222
228
|
}
|
|
223
229
|
catch (err) {
|
|
224
230
|
// Roll the file back so the YAML source-of-truth and the OS
|
|
@@ -254,9 +260,12 @@ export async function akmTasksHistory(input) {
|
|
|
254
260
|
* Reconcile the on-disk task files with the OS scheduler.
|
|
255
261
|
* • install missing tasks (after validating them — invalid files are
|
|
256
262
|
* skipped with a per-task reason rather than aborting the whole sync)
|
|
263
|
+
* • reinstall tasks whose schedule or enabled state changed in the .yml
|
|
264
|
+
* (drift detected by comparing the backend's installed signature against
|
|
265
|
+
* the signature the current definition would produce)
|
|
257
266
|
* • remove orphan scheduler entries that no longer have a backing file
|
|
258
267
|
*/
|
|
259
|
-
export async function akmTasksSync() {
|
|
268
|
+
export async function akmTasksSync(deps = {}) {
|
|
260
269
|
const stashDir = resolveStashDir();
|
|
261
270
|
const typeRoot = path.join(stashDir, "tasks");
|
|
262
271
|
if (fs.existsSync(typeRoot))
|
|
@@ -267,10 +276,13 @@ export async function akmTasksSync() {
|
|
|
267
276
|
.filter((f) => f.endsWith(".yml"))
|
|
268
277
|
.map((f) => f.slice(0, -4))
|
|
269
278
|
: [];
|
|
270
|
-
const sched = selectBackend();
|
|
279
|
+
const sched = deps.backend ?? selectBackend();
|
|
271
280
|
const backend = backendNameForPlatform();
|
|
272
|
-
|
|
281
|
+
// Map id → installed signature so sync can detect schedule/enabled drift on
|
|
282
|
+
// tasks that already exist in the scheduler, not just presence/absence.
|
|
283
|
+
const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
|
|
273
284
|
const installed = [];
|
|
285
|
+
const updated = [];
|
|
274
286
|
const unchanged = [];
|
|
275
287
|
const skipped = [];
|
|
276
288
|
for (const id of fileIds) {
|
|
@@ -290,22 +302,34 @@ export async function akmTasksSync() {
|
|
|
290
302
|
skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
|
|
291
303
|
continue;
|
|
292
304
|
}
|
|
293
|
-
if (present.has(id)) {
|
|
305
|
+
if (!present.has(id)) {
|
|
306
|
+
await sched.install(task);
|
|
307
|
+
installed.push(id);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Already installed — reconcile against the current definition. Compare the
|
|
311
|
+
// installed signature to what this task would render to; reinstall on drift.
|
|
312
|
+
// When the backend can't produce a signature (no expectedSignature, or it
|
|
313
|
+
// didn't record one), reinstall unconditionally — install() is idempotent,
|
|
314
|
+
// so the cost is one crontab write and correctness is guaranteed.
|
|
315
|
+
const installedSig = present.get(id);
|
|
316
|
+
const expectedSig = sched.expectedSignature?.(task);
|
|
317
|
+
if (installedSig !== undefined && expectedSig !== undefined && installedSig === expectedSig) {
|
|
294
318
|
unchanged.push(id);
|
|
295
319
|
}
|
|
296
320
|
else {
|
|
297
321
|
await sched.install(task);
|
|
298
|
-
|
|
322
|
+
updated.push(id);
|
|
299
323
|
}
|
|
300
324
|
}
|
|
301
325
|
const removed = [];
|
|
302
|
-
for (const installedId of present) {
|
|
326
|
+
for (const installedId of present.keys()) {
|
|
303
327
|
if (!fileIds.includes(installedId)) {
|
|
304
328
|
await sched.uninstall(installedId);
|
|
305
329
|
removed.push(installedId);
|
|
306
330
|
}
|
|
307
331
|
}
|
|
308
|
-
return { installed, removed, unchanged, skipped, backend: sched.name };
|
|
332
|
+
return { installed, updated, removed, unchanged, skipped, backend: sched.name };
|
|
309
333
|
}
|
|
310
334
|
export async function akmTasksDoctor() {
|
|
311
335
|
const warnings = [];
|
package/dist/core/file-lock.js
CHANGED
|
@@ -79,6 +79,28 @@ export function releaseLock(lockPath) {
|
|
|
79
79
|
// Sentinel already gone — fine.
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Release a lock ONLY if it is still owned by `ownerPid`. Safe to call from a
|
|
84
|
+
* `process.exit()` / `'exit'` handler as a backstop: `process.exit()` skips
|
|
85
|
+
* `finally` blocks — so the normal lock-release never runs on signal death
|
|
86
|
+
* (SIGTERM/SIGINT) — but it DOES fire `'exit'` listeners synchronously. Checking
|
|
87
|
+
* ownership first means that if the lock was already released and re-acquired by
|
|
88
|
+
* a different process, this leaves that process's lock intact (no cross-run
|
|
89
|
+
* deletion / PID-reuse footgun). Synchronous so it is valid inside an exit handler.
|
|
90
|
+
*/
|
|
91
|
+
export function releaseLockIfOwned(lockPath, ownerPid) {
|
|
92
|
+
let rawContent;
|
|
93
|
+
try {
|
|
94
|
+
rawContent = fs.readFileSync(lockPath, "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Absent or unreadable — nothing of ours to release.
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (extractHolderPid(rawContent) === ownerPid) {
|
|
101
|
+
releaseLock(lockPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
82
104
|
/**
|
|
83
105
|
* Extract a PID from a sentinel body. Accepts the two shapes used across
|
|
84
106
|
* the codebase: a bare numeric string (config-io, vault, lockfile) and
|
|
@@ -64,13 +64,11 @@ export function CRON_BACKEND(options = {}) {
|
|
|
64
64
|
},
|
|
65
65
|
list() {
|
|
66
66
|
const existing = readCrontab(exec);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
return ids.map((id) => ({ id }));
|
|
67
|
+
return listBlocks(existing).map(({ id, body }) => ({ id, signature: normalizeSignature(body) }));
|
|
68
|
+
},
|
|
69
|
+
expectedSignature(task) {
|
|
70
|
+
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
71
|
+
return normalizeSignature(cronBlockBody(cronLine, task.enabled));
|
|
74
72
|
},
|
|
75
73
|
};
|
|
76
74
|
}
|
|
@@ -82,9 +80,48 @@ export function buildCronLine(task, akmArgv, logDir) {
|
|
|
82
80
|
const cmd = [...akmArgv, "tasks", "run", task.id].map((part) => quoteForCron(part)).join(" ");
|
|
83
81
|
return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
|
|
84
82
|
}
|
|
83
|
+
/** The crontab line as it appears inside a block — commented when disabled. */
|
|
84
|
+
export function cronBlockBody(cronLine, enabled) {
|
|
85
|
+
return enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
|
|
86
|
+
}
|
|
85
87
|
export function renderBlock(id, cronLine, enabled) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
return [BEGIN(id), cronBlockBody(cronLine, enabled), END(id)].join("\n");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse the akm-owned blocks out of a crontab, returning each task id with the
|
|
92
|
+
* raw body line(s) between its BEGIN/END markers. Used by `list()` to build a
|
|
93
|
+
* drift signature, and exported for tests.
|
|
94
|
+
*/
|
|
95
|
+
export function listBlocks(existing) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const lines = existing.split(/\r?\n/);
|
|
98
|
+
let currentId = null;
|
|
99
|
+
let body = [];
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const begin = line.match(BLOCK_RE);
|
|
102
|
+
if (begin) {
|
|
103
|
+
currentId = begin[1];
|
|
104
|
+
body = [];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (currentId !== null && line === END(currentId)) {
|
|
108
|
+
out.push({ id: currentId, body: body.join("\n") });
|
|
109
|
+
currentId = null;
|
|
110
|
+
body = [];
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (currentId !== null)
|
|
114
|
+
body.push(line);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Collapse incidental whitespace so signature comparison ignores it. */
|
|
119
|
+
function normalizeSignature(body) {
|
|
120
|
+
return body
|
|
121
|
+
.split(/\r?\n/)
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter((l) => l.length > 0)
|
|
124
|
+
.join("\n");
|
|
88
125
|
}
|
|
89
126
|
export function upsertBlock(existing, id, block) {
|
|
90
127
|
const trimmed = existing.replace(/\s+$/g, "");
|
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.2",
|
|
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": [
|
|
@@ -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
|
-
}
|