akm-cli 0.8.0-rc2 → 0.8.1
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/{.github/CHANGELOG.md → CHANGELOG.md} +238 -3
- package/README.md +22 -6
- package/SECURITY.md +93 -0
- package/dist/assets/help/help-accept.md +12 -0
- package/dist/assets/help/help-improve.md +81 -0
- package/dist/{commands → assets}/help/help-proposals.md +7 -4
- package/dist/assets/help/help-reject.md +11 -0
- package/dist/{output → assets/hints}/cli-hints-full.md +60 -32
- package/dist/{output → assets/hints}/cli-hints-short.md +10 -7
- package/dist/assets/profiles/default.json +15 -0
- package/dist/assets/profiles/graph-refresh.json +13 -0
- package/dist/assets/profiles/memory-focus.json +12 -0
- package/dist/assets/profiles/quick.json +15 -0
- package/dist/assets/profiles/thorough.json +15 -0
- package/dist/assets/prompts/extract-session.md +80 -0
- package/dist/assets/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/assets/tasks/graph-refresh-weekly.yml +10 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +93 -3
- package/dist/cli/shared.js +129 -0
- package/dist/cli.js +2141 -1268
- package/dist/commands/add-cli.js +279 -0
- package/dist/commands/agent-dispatch.js +20 -12
- package/dist/commands/agent-support.js +11 -5
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +129 -517
- package/dist/commands/consolidate.js +1557 -147
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +5 -3
- package/dist/commands/distill.js +906 -100
- package/dist/commands/env.js +213 -0
- package/dist/commands/eval-cases.js +3 -0
- package/dist/commands/events.js +3 -0
- package/dist/commands/extract-cli.js +127 -0
- package/dist/commands/extract-prompt.js +217 -0
- package/dist/commands/extract.js +477 -0
- package/dist/commands/feedback-cli.js +331 -0
- package/dist/commands/graph.js +260 -5
- package/dist/commands/health.js +1042 -55
- package/dist/commands/history.js +51 -16
- package/dist/commands/improve-auto-accept.js +97 -0
- package/dist/commands/improve-cli.js +236 -0
- package/dist/commands/improve-profiles.js +138 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +1736 -346
- package/dist/commands/info.js +26 -28
- package/dist/commands/init.js +49 -1
- package/dist/commands/installed-stashes.js +6 -23
- package/dist/commands/knowledge.js +3 -0
- package/dist/commands/lint/agent-linter.js +3 -0
- package/dist/commands/lint/base-linter.js +199 -5
- package/dist/commands/lint/command-linter.js +3 -0
- package/dist/commands/lint/default-linter.js +3 -0
- package/dist/commands/lint/env-key-rules.js +154 -0
- package/dist/commands/lint/index.js +92 -3
- package/dist/commands/lint/knowledge-linter.js +3 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +3 -0
- package/dist/commands/lint/registry.js +3 -0
- package/dist/commands/lint/skill-linter.js +3 -0
- package/dist/commands/lint/task-linter.js +15 -12
- package/dist/commands/lint/types.js +3 -0
- package/dist/commands/lint/workflow-linter.js +3 -0
- package/dist/commands/lint.js +3 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal-drain-policies.js +128 -0
- package/dist/commands/proposal-drain.js +477 -0
- package/dist/commands/proposal.js +60 -6
- package/dist/commands/propose.js +24 -19
- package/dist/commands/reflect.js +1004 -94
- package/dist/commands/registry-cli.js +150 -0
- package/dist/commands/registry-search.js +3 -0
- package/dist/commands/remember-cli.js +257 -0
- package/dist/commands/remember.js +15 -6
- package/dist/commands/schema-repair.js +88 -15
- package/dist/commands/search.js +99 -14
- package/dist/commands/secret.js +173 -0
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +32 -13
- package/dist/commands/source-add.js +7 -35
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +3 -0
- package/dist/commands/tasks.js +161 -95
- package/dist/commands/url-checker.js +3 -0
- package/dist/core/action-contributors.js +3 -0
- package/dist/core/asset-ref.js +13 -2
- package/dist/core/asset-registry.js +9 -2
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +61 -5
- package/dist/core/common.js +93 -5
- package/dist/core/concurrent.js +3 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +622 -0
- package/dist/core/config-schema.js +558 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +366 -1077
- package/dist/core/errors.js +42 -20
- package/dist/core/events.js +31 -25
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +75 -10
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +3 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +142 -14
- package/dist/core/parse.js +3 -0
- package/dist/core/paths.js +218 -50
- package/dist/core/proposal-quality-validators.js +380 -0
- package/dist/core/proposal-validators.js +11 -3
- package/dist/core/proposals.js +464 -5
- package/dist/core/state-db.js +349 -56
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +3 -0
- package/dist/core/tty.js +59 -0
- package/dist/core/warn.js +7 -2
- package/dist/core/write-source.js +12 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +136 -28
- package/dist/indexer/db.js +661 -166
- package/dist/indexer/ensure-index.js +3 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +162 -40
- package/dist/indexer/graph-db.js +241 -51
- package/dist/indexer/graph-dedup.js +3 -7
- package/dist/indexer/graph-extraction.js +242 -149
- package/dist/indexer/index-context.js +3 -9
- package/dist/indexer/indexer.js +86 -16
- package/dist/indexer/llm-cache.js +24 -19
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +184 -11
- package/dist/indexer/memory-inference.js +94 -50
- package/dist/indexer/metadata-contributors.js +3 -0
- package/dist/indexer/metadata.js +110 -50
- package/dist/indexer/path-resolver.js +3 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +134 -7
- package/dist/indexer/ranking.js +8 -1
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +91 -2
- package/dist/indexer/search-source.js +20 -1
- package/dist/indexer/semantic-status.js +4 -1
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +3 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +121 -401
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +6 -14
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +3 -0
- package/dist/integrations/agent/prompts.js +137 -8
- package/dist/integrations/agent/runner.js +208 -0
- package/dist/integrations/agent/sdk-runner.js +8 -2
- package/dist/integrations/agent/spawn.js +54 -14
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +22 -51
- package/dist/integrations/session-logs/index.js +4 -0
- package/dist/integrations/session-logs/inline-refs.js +35 -0
- package/dist/integrations/session-logs/pre-filter.js +152 -0
- package/dist/integrations/session-logs/providers/claude-code.js +226 -0
- package/dist/integrations/session-logs/providers/opencode.js +231 -25
- package/dist/integrations/session-logs/types.js +3 -0
- package/dist/llm/call-ai.js +14 -26
- package/dist/llm/client.js +16 -2
- package/dist/llm/embedder.js +20 -29
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +42 -1
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +92 -56
- package/dist/llm/graph-extract.js +402 -31
- package/dist/llm/index-passes.js +44 -29
- package/dist/llm/memory-infer.js +30 -2
- package/dist/llm/metadata-enhance.js +3 -7
- package/dist/output/cli-hints.js +7 -4
- package/dist/output/context.js +60 -8
- package/dist/output/renderers.js +170 -194
- package/dist/output/shapes/curate.js +56 -0
- package/dist/output/shapes/distill.js +10 -0
- package/dist/output/shapes/env-list.js +19 -0
- package/dist/output/shapes/events.js +11 -0
- package/dist/output/shapes/helpers.js +424 -0
- package/dist/output/shapes/history.js +7 -0
- package/dist/output/shapes/passthrough.js +105 -0
- package/dist/output/shapes/proposal-accept.js +7 -0
- package/dist/output/shapes/proposal-diff.js +7 -0
- package/dist/output/shapes/proposal-list.js +7 -0
- package/dist/output/shapes/proposal-producer.js +11 -0
- package/dist/output/shapes/proposal-reject.js +7 -0
- package/dist/output/shapes/proposal-show.js +7 -0
- package/dist/output/shapes/registry-search.js +6 -0
- package/dist/output/shapes/registry.js +30 -0
- package/dist/output/shapes/search.js +6 -0
- package/dist/output/shapes/secret-list.js +19 -0
- package/dist/output/shapes/show.js +6 -0
- package/dist/output/shapes/vault-list.js +19 -0
- package/dist/output/shapes.js +51 -549
- package/dist/output/text/add.js +6 -0
- package/dist/output/text/clone.js +6 -0
- package/dist/output/text/config.js +6 -0
- package/dist/output/text/curate.js +6 -0
- package/dist/output/text/distill.js +7 -0
- package/dist/output/text/enable-disable.js +7 -0
- package/dist/output/text/events.js +10 -0
- package/dist/output/text/feedback.js +6 -0
- package/dist/output/text/helpers.js +1059 -0
- package/dist/output/text/history.js +7 -0
- package/dist/output/text/import.js +6 -0
- package/dist/output/text/index.js +6 -0
- package/dist/output/text/info.js +6 -0
- package/dist/output/text/init.js +6 -0
- package/dist/output/text/list.js +6 -0
- package/dist/output/text/proposal-producer.js +8 -0
- package/dist/output/text/proposal.js +12 -0
- package/dist/output/text/registry-commands.js +11 -0
- package/dist/output/text/registry.js +30 -0
- package/dist/output/text/remember.js +6 -0
- package/dist/output/text/remove.js +6 -0
- package/dist/output/text/save.js +6 -0
- package/dist/output/text/search.js +6 -0
- package/dist/output/text/show.js +6 -0
- package/dist/output/text/update.js +6 -0
- package/dist/output/text/upgrade.js +6 -0
- package/dist/output/text/vault.js +16 -0
- package/dist/output/text/wiki.js +15 -0
- package/dist/output/text/workflow.js +14 -0
- package/dist/output/text.js +44 -1329
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +4 -1
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +11 -2
- package/dist/registry/providers/static-index.js +10 -1
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17767 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +306 -67
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +3 -11
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +171 -21
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +3 -0
- package/dist/tasks/backends/cron.js +3 -0
- package/dist/tasks/backends/exec-utils.js +3 -0
- package/dist/tasks/backends/index.js +3 -11
- package/dist/tasks/backends/launchd.js +4 -1
- package/dist/tasks/backends/schtasks.js +4 -1
- package/dist/tasks/parser.js +51 -38
- package/dist/tasks/resolveAkmBin.js +3 -0
- package/dist/tasks/runner.js +35 -9
- package/dist/tasks/schedule.js +20 -1
- package/dist/tasks/schema.js +5 -3
- package/dist/tasks/validator.js +6 -3
- package/dist/version.js +3 -0
- package/dist/wiki/wiki-templates.js +6 -3
- package/dist/wiki/wiki.js +4 -1
- package/dist/workflows/authoring.js +4 -1
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +3 -0
- package/dist/workflows/runs.js +18 -1
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +5 -9
- package/docs/README.md +7 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +57 -5
- package/docs/migration/v0.7-to-v0.8.md +1378 -0
- package/package.json +28 -11
- package/.github/LICENSE +0 -374
- package/dist/commands/help/help-accept.md +0 -9
- package/dist/commands/help/help-improve.md +0 -53
- package/dist/commands/help/help-reject.md +0 -8
- package/dist/commands/install-audit.js +0 -385
- package/dist/commands/vault.js +0 -310
- package/dist/indexer/match-contributors.js +0 -141
- package/dist/integrations/agent/pipeline.js +0 -39
- package/dist/integrations/agent/runners.js +0 -31
- package/dist/llm/prompts/graph-extract-user-prompt.md +0 -12
- /package/dist/{tasks → assets}/backends/launchd-template.xml +0 -0
- /package/dist/{tasks → assets}/backends/schtasks-template.xml +0 -0
- /package/dist/{commands → assets}/help/help-propose.md +0 -0
- /package/dist/{wiki → assets/wiki}/index-template.md +0 -0
- /package/dist/{wiki → assets/wiki}/ingest-workflow-template.md +0 -0
- /package/dist/{wiki → assets/wiki}/log-template.md +0 -0
- /package/dist/{wiki → assets/wiki}/schema-template.md +0 -0
- /package/dist/{workflows → assets/workflows}/workflow-template.md +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,56 +1,123 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
// Runtime guard: akm-cli 0.8 is Bun-only. The `preinstall` hook in
|
|
6
|
+
// package.json blocks `npm install`, but it does not protect against a
|
|
7
|
+
// stale node-resolved shebang, a wrong PATH entry, or someone running
|
|
8
|
+
// `node dist/cli.js` directly from a clone. In any of those cases the
|
|
9
|
+
// next line — `import { spawnSync } from "node:child_process";` — would
|
|
10
|
+
// itself succeed under node, only to die a few imports later with a
|
|
11
|
+
// confusing `ERR_MODULE_NOT_FOUND` for our extensionless internal paths.
|
|
12
|
+
// Catch the wrong-runtime case here with a friendly message instead of
|
|
13
|
+
// a stack trace. Cross-runtime support is planned for 0.9 (issue #465).
|
|
14
|
+
if (typeof globalThis.Bun === "undefined") {
|
|
15
|
+
console.error("\n ERROR: akm-cli 0.8 requires the Bun runtime (https://bun.sh) or the prebuilt binary.\n" +
|
|
16
|
+
" Running under Node.js is not supported in this release.\n" +
|
|
17
|
+
" Install options:\n" +
|
|
18
|
+
" 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\n" +
|
|
19
|
+
" 2. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n" +
|
|
20
|
+
" Cross-runtime support is planned for 0.9.0.\n");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
// Global error handlers (#478) — route any async work outside the
|
|
24
|
+
// `runWithJsonErrors` envelope through the same JSON shape so users never see
|
|
25
|
+
// a raw stack trace. Background timers, fire-and-forget appendEvent writes,
|
|
26
|
+
// and lazy `import()` failures are the typical sources. Registered before
|
|
27
|
+
// any other top-level work so the startup IIFE banner and the stale-DB
|
|
28
|
+
// cleanup are also covered.
|
|
29
|
+
process.on("unhandledRejection", (reason) => {
|
|
30
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
31
|
+
console.error(JSON.stringify({
|
|
32
|
+
ok: false,
|
|
33
|
+
error: `Unhandled rejection: ${err.message}`,
|
|
34
|
+
code: "UNHANDLED_REJECTION",
|
|
35
|
+
hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
|
|
36
|
+
}, null, 2));
|
|
37
|
+
if (process.env.AKM_DEBUG === "1" && err.stack)
|
|
38
|
+
console.error(err.stack);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
41
|
+
process.on("uncaughtException", (err) => {
|
|
42
|
+
console.error(JSON.stringify({
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `Uncaught exception: ${err.message}`,
|
|
45
|
+
code: "UNCAUGHT_EXCEPTION",
|
|
46
|
+
hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
|
|
47
|
+
}, null, 2));
|
|
48
|
+
if (process.env.AKM_DEBUG === "1" && err.stack)
|
|
49
|
+
console.error(err.stack);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
2
52
|
import { spawnSync } from "node:child_process";
|
|
3
53
|
import fs from "node:fs";
|
|
4
54
|
import path from "node:path";
|
|
5
55
|
import * as p from "@clack/prompts";
|
|
6
56
|
import { defineCommand, runMain } from "citty";
|
|
7
|
-
import { hasSubcommand, parsePositiveIntFlag } from "./cli/parse-args";
|
|
57
|
+
import { getStringArg, hasSubcommand, parsePositiveIntFlag } from "./cli/parse-args";
|
|
58
|
+
import { EXIT_CODES, emitJsonError, output, parseAllFlagValues, runWithJsonErrors } from "./cli/shared";
|
|
59
|
+
import { addCommand, buildWebsiteOptions } from "./commands/add-cli";
|
|
8
60
|
import { akmAgentDispatch } from "./commands/agent-dispatch";
|
|
9
61
|
import { generateBashCompletions, installBashCompletions } from "./commands/completions";
|
|
10
62
|
import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./commands/config-cli";
|
|
11
63
|
import { akmCurate } from "./commands/curate";
|
|
64
|
+
import { akmDbBackups } from "./commands/db-cli";
|
|
12
65
|
import { akmEventsList, akmEventsTail } from "./commands/events";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
66
|
+
import { extractCommand } from "./commands/extract-cli";
|
|
67
|
+
import { feedbackCommand } from "./commands/feedback-cli";
|
|
68
|
+
import { akmGraphEntities, akmGraphEntity, akmGraphExport, akmGraphOrphans, akmGraphRelated, akmGraphRelations, akmGraphSummary, akmGraphUpdate, } from "./commands/graph";
|
|
69
|
+
import { akmHealth, parseWindowSpec, renderRunsDetailMd, renderWindowCompareMd, } from "./commands/health";
|
|
15
70
|
import { akmHistory } from "./commands/history";
|
|
16
|
-
import {
|
|
71
|
+
import { improveCommand } from "./commands/improve-cli";
|
|
17
72
|
import { assembleInfo } from "./commands/info";
|
|
18
73
|
import { akmInit } from "./commands/init";
|
|
19
74
|
import { akmListSources, akmRemove, akmUpdate } from "./commands/installed-stashes";
|
|
20
75
|
import { readKnowledgeInput, writeMarkdownAsset } from "./commands/knowledge";
|
|
21
76
|
import { akmLint } from "./commands/lint";
|
|
22
77
|
import { renderMigrationHelp } from "./commands/migration-help";
|
|
23
|
-
import {
|
|
78
|
+
import { registryCommand } from "./commands/registry-cli";
|
|
79
|
+
import { rememberCommand } from "./commands/remember-cli";
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the event source from the environment. When `AKM_EVENT_SOURCE` is
|
|
82
|
+
* set (e.g. by `akm improve` for agent subprocesses), events are tagged so
|
|
83
|
+
* they can be filtered out of user-facing history.
|
|
84
|
+
*/
|
|
85
|
+
function resolveEventSource() {
|
|
86
|
+
const raw = process.env.AKM_EVENT_SOURCE;
|
|
87
|
+
if (raw === "improve")
|
|
88
|
+
return "improve";
|
|
89
|
+
if (raw === "user")
|
|
90
|
+
return "user";
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
import { resolveImproveProfile } from "./commands/improve-profiles";
|
|
94
|
+
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, } from "./commands/proposal";
|
|
95
|
+
import { drainProposals } from "./commands/proposal-drain";
|
|
96
|
+
import { resolveDrainPolicy } from "./commands/proposal-drain-policies";
|
|
24
97
|
import { akmPropose } from "./commands/propose";
|
|
25
|
-
import { searchRegistry } from "./commands/registry-search";
|
|
26
|
-
import { buildMemoryFrontmatter, parseDuration, readMemoryContent, resolveRememberContentArg, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
|
|
27
98
|
import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
|
|
28
99
|
import { checkForUpdate, performUpgrade } from "./commands/self-update";
|
|
29
100
|
import { akmShowUnified, normalizeShowArgv } from "./commands/show";
|
|
30
|
-
import { akmAdd } from "./commands/source-add";
|
|
31
101
|
import { akmClone } from "./commands/source-clone";
|
|
32
|
-
import { addStash } from "./commands/source-manage";
|
|
33
102
|
import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./commands/tasks";
|
|
34
103
|
import { parseAssetRef } from "./core/asset-ref";
|
|
35
104
|
import { deriveCanonicalAssetName, resolveAssetPathFromName } from "./core/asset-spec";
|
|
36
|
-
import { isHttpUrl, resolveStashDir } from "./core/common";
|
|
105
|
+
import { isHttpUrl, isWithin, resolveStashDir, writeFileAtomic } from "./core/common";
|
|
37
106
|
import { DEFAULT_CONFIG, loadConfig, loadUserConfig, resolveConfiguredSources, saveConfig } from "./core/config";
|
|
38
107
|
import { ConfigError, NotFoundError, UsageError } from "./core/errors";
|
|
39
108
|
import { appendEvent } from "./core/events";
|
|
40
109
|
import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
110
|
+
import { plainize } from "./core/tty";
|
|
111
|
+
import { clearLogFile, info, isQuiet, isVerbose, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
|
|
112
|
+
import { closeDatabase, openExistingDatabase } from "./indexer/db";
|
|
44
113
|
import { akmIndex } from "./indexer/indexer";
|
|
45
114
|
import { resolveSourceEntries } from "./indexer/search-source";
|
|
46
|
-
import {
|
|
115
|
+
import { resolveTriageJudgmentRunner } from "./integrations/agent/runner";
|
|
47
116
|
import { EMBEDDED_HINTS, EMBEDDED_HINTS_FULL } from "./output/cli-hints";
|
|
48
|
-
import { getHyphenatedArg, getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue, } from "./output/context";
|
|
49
|
-
import {
|
|
50
|
-
import { formatEventLine, formatPlain, outputJsonl } from "./output/text";
|
|
51
|
-
import { buildRegistryIndex, writeRegistryIndex } from "./registry/build-index";
|
|
117
|
+
import { getHyphenatedArg, getHyphenatedBoolean, getOutputMode, hasBooleanFlag, initOutputMode, parseDetailLevel, parseFlagValue, } from "./output/context";
|
|
118
|
+
import { formatEventLine } from "./output/text";
|
|
52
119
|
import { resolveSourcesForOrigin } from "./registry/origin-resolve";
|
|
53
|
-
import { saveGitStash } from "./sources/providers/git";
|
|
120
|
+
import { resolveWritableOverride, saveGitStash } from "./sources/providers/git";
|
|
54
121
|
import { resolveAssetPath } from "./sources/resolve";
|
|
55
122
|
import { pkgVersion } from "./version";
|
|
56
123
|
import { createWorkflowAsset, formatWorkflowErrors, getWorkflowTemplate, validateWorkflowSource, } from "./workflows/authoring";
|
|
@@ -59,7 +126,6 @@ import { completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, listWorkf
|
|
|
59
126
|
const SKILLS_SH_NAME = "skills.sh";
|
|
60
127
|
const SKILLS_SH_URL = "https://skills.sh";
|
|
61
128
|
const SKILLS_SH_PROVIDER = "skills-sh";
|
|
62
|
-
import { stringify as yamlStringify } from "yaml";
|
|
63
129
|
function applyEarlyStderrFlags(argv) {
|
|
64
130
|
if (argv.includes("--quiet") || argv.includes("-q")) {
|
|
65
131
|
setQuiet(true);
|
|
@@ -68,29 +134,6 @@ function applyEarlyStderrFlags(argv) {
|
|
|
68
134
|
setVerbose(true);
|
|
69
135
|
}
|
|
70
136
|
}
|
|
71
|
-
/**
|
|
72
|
-
* Collect all occurrences of a repeatable flag from process.argv.
|
|
73
|
-
* Citty's StringArgDef only exposes the last value when a flag is repeated,
|
|
74
|
-
* so for repeatable CLI args (like `--tag foo --tag bar`) we read argv directly.
|
|
75
|
-
* Supports both `--flag value` and `--flag=value` forms.
|
|
76
|
-
*/
|
|
77
|
-
function parseAllFlagValues(flag) {
|
|
78
|
-
const values = [];
|
|
79
|
-
for (let i = 0; i < process.argv.length; i++) {
|
|
80
|
-
const arg = process.argv[i];
|
|
81
|
-
if (arg === flag && i + 1 < process.argv.length) {
|
|
82
|
-
values.push(process.argv[i + 1]);
|
|
83
|
-
// BUG-M4: skip the value index so `--tag --tag` (literal `--tag`
|
|
84
|
-
// value) does not double-count the second `--tag` as a separate
|
|
85
|
-
// flag occurrence.
|
|
86
|
-
i++;
|
|
87
|
-
}
|
|
88
|
-
else if (arg.startsWith(`${flag}=`)) {
|
|
89
|
-
values.push(arg.slice(flag.length + 1));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return values;
|
|
93
|
-
}
|
|
94
137
|
function resolveHelpMigrateVersionArg(version) {
|
|
95
138
|
if (version === undefined)
|
|
96
139
|
return undefined;
|
|
@@ -128,26 +171,29 @@ function wasHelpMigrateFlagValueConsumedAsVersion(version, flagValue, flagName)
|
|
|
128
171
|
return false;
|
|
129
172
|
return relevant[flagIndex] === flagName ? relevant[flagIndex + 1] === version : true;
|
|
130
173
|
}
|
|
131
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Stderr-only human-friendly hint after a non-interactive `setup` invocation.
|
|
176
|
+
* Default --format is `json`, so a CI or piped consumer sees only the JSON on
|
|
177
|
+
* stdout. But an interactive user running `akm setup --yes` would otherwise
|
|
178
|
+
* see only the JSON blob with no obvious next step. When stderr is a TTY and
|
|
179
|
+
* the JSON went to stdout, print a two-line summary to stderr telling the
|
|
180
|
+
* user (a) where the stash landed and (b) what to run next.
|
|
181
|
+
*
|
|
182
|
+
* Silent when: stderr is not a TTY (CI, pipes), --format=text/yaml (the user
|
|
183
|
+
* already gets readable output), --quiet, or the result is missing fields.
|
|
184
|
+
*/
|
|
185
|
+
function printSetupTtyHint(result) {
|
|
186
|
+
if (!process.stderr.isTTY)
|
|
187
|
+
return;
|
|
132
188
|
const mode = getOutputMode();
|
|
133
|
-
|
|
134
|
-
if (mode.format === "jsonl") {
|
|
135
|
-
outputJsonl(command, shaped);
|
|
189
|
+
if (mode.format !== "json" && mode.format !== "jsonl")
|
|
136
190
|
return;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
console.log(yamlStringify(shaped));
|
|
144
|
-
return;
|
|
145
|
-
case "text": {
|
|
146
|
-
const plain = formatPlain(command, shaped, mode.detail);
|
|
147
|
-
console.log(plain ?? JSON.stringify(shaped, null, 2));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
191
|
+
if (isQuiet())
|
|
192
|
+
return;
|
|
193
|
+
if (!result?.stashDir)
|
|
194
|
+
return;
|
|
195
|
+
console.error(plainize(`\n✓ Stash created at ${result.stashDir}\n` +
|
|
196
|
+
` Next: \`akm add github:itlackey/akm-stash\` then \`akm index\` to populate the stash.`));
|
|
151
197
|
}
|
|
152
198
|
/**
|
|
153
199
|
* Module Naming:
|
|
@@ -166,6 +212,10 @@ const setupCommand = defineCommand({
|
|
|
166
212
|
type: "string",
|
|
167
213
|
description: 'Config JSON to apply non-interactively, e.g. \'{"llm":{"endpoint":"...","model":"..."}}\'',
|
|
168
214
|
},
|
|
215
|
+
from: {
|
|
216
|
+
type: "string",
|
|
217
|
+
description: "Path to a config file (JSON or YAML) to bootstrap from. Skips prompts for keys present in the file.",
|
|
218
|
+
},
|
|
169
219
|
yes: {
|
|
170
220
|
type: "boolean",
|
|
171
221
|
default: false,
|
|
@@ -184,7 +234,26 @@ const setupCommand = defineCommand({
|
|
|
184
234
|
async run({ args }) {
|
|
185
235
|
await runWithJsonErrors(async () => {
|
|
186
236
|
const noInit = getHyphenatedBoolean(args, "no-init");
|
|
187
|
-
if (args.config) {
|
|
237
|
+
if (args.from && args.config) {
|
|
238
|
+
throw new UsageError("Pass either --from <file> or --config <json>, not both.", "INVALID_FLAG_VALUE");
|
|
239
|
+
}
|
|
240
|
+
if (args.from) {
|
|
241
|
+
// File-based bootstrap. `loadSetupConfigFromFile` expands a leading
|
|
242
|
+
// `~`, resolves relative paths against cwd, picks the YAML or JSON
|
|
243
|
+
// parser based on the file extension, and surfaces any
|
|
244
|
+
// read/parse/shape errors as ConfigError("INVALID_CONFIG_FILE").
|
|
245
|
+
const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup");
|
|
246
|
+
const loaded = await loadSetupConfigFromFile(args.from);
|
|
247
|
+
const result = await runSetupFromConfig({
|
|
248
|
+
configJson: loaded.configJson,
|
|
249
|
+
dir: args.dir,
|
|
250
|
+
noInit,
|
|
251
|
+
probe: args.probe,
|
|
252
|
+
});
|
|
253
|
+
output("setup", result);
|
|
254
|
+
printSetupTtyHint(result);
|
|
255
|
+
}
|
|
256
|
+
else if (args.config) {
|
|
188
257
|
// Non-interactive config mode
|
|
189
258
|
const { runSetupFromConfig } = await import("./setup/setup");
|
|
190
259
|
const result = await runSetupFromConfig({
|
|
@@ -194,6 +263,7 @@ const setupCommand = defineCommand({
|
|
|
194
263
|
probe: args.probe,
|
|
195
264
|
});
|
|
196
265
|
output("setup", result);
|
|
266
|
+
printSetupTtyHint(result);
|
|
197
267
|
}
|
|
198
268
|
else if (args.yes) {
|
|
199
269
|
// Defaults mode — no prompts
|
|
@@ -204,6 +274,7 @@ const setupCommand = defineCommand({
|
|
|
204
274
|
probe: args.probe,
|
|
205
275
|
});
|
|
206
276
|
output("setup", result);
|
|
277
|
+
printSetupTtyHint(result);
|
|
207
278
|
}
|
|
208
279
|
else {
|
|
209
280
|
// Interactive wizard
|
|
@@ -235,7 +306,16 @@ const indexCommand = defineCommand({
|
|
|
235
306
|
meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
|
|
236
307
|
args: {
|
|
237
308
|
full: { type: "boolean", description: "Force full reindex", default: false },
|
|
238
|
-
|
|
309
|
+
clean: {
|
|
310
|
+
type: "boolean",
|
|
311
|
+
description: "After indexing, remove any entries whose source file no longer exists on disk.",
|
|
312
|
+
default: false,
|
|
313
|
+
},
|
|
314
|
+
"dry-run": {
|
|
315
|
+
type: "boolean",
|
|
316
|
+
description: "When combined with --clean, report stale entries without deleting them.",
|
|
317
|
+
default: false,
|
|
318
|
+
},
|
|
239
319
|
},
|
|
240
320
|
async run({ args }) {
|
|
241
321
|
await runWithJsonErrors(async () => {
|
|
@@ -252,7 +332,8 @@ const indexCommand = defineCommand({
|
|
|
252
332
|
process.once("SIGTERM", abort);
|
|
253
333
|
const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
254
334
|
setLogFile(indexLogFile);
|
|
255
|
-
const
|
|
335
|
+
const verbose = isVerbose();
|
|
336
|
+
const spin = !verbose && outputMode.format === "text" ? p.spinner() : null;
|
|
256
337
|
if (spin) {
|
|
257
338
|
spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
|
|
258
339
|
}
|
|
@@ -260,10 +341,12 @@ const indexCommand = defineCommand({
|
|
|
260
341
|
try {
|
|
261
342
|
const result = await akmIndex({
|
|
262
343
|
full: args.full,
|
|
344
|
+
clean: args.clean,
|
|
345
|
+
dryRun: args["dry-run"],
|
|
263
346
|
onProgress: ({ phase, message, processed, total }) => {
|
|
264
347
|
latestMessage = message;
|
|
265
348
|
const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : "";
|
|
266
|
-
if (
|
|
349
|
+
if (verbose) {
|
|
267
350
|
info(`[index:${phase}] ${progressPrefix}${message}`);
|
|
268
351
|
}
|
|
269
352
|
else if (spin) {
|
|
@@ -293,7 +376,7 @@ const indexCommand = defineCommand({
|
|
|
293
376
|
},
|
|
294
377
|
});
|
|
295
378
|
const infoCommand = defineCommand({
|
|
296
|
-
meta: { name: "info", description: "Show system capabilities, configuration, and index stats
|
|
379
|
+
meta: { name: "info", description: "Show system capabilities, configuration, and index stats" },
|
|
297
380
|
run() {
|
|
298
381
|
return runWithJsonErrors(() => {
|
|
299
382
|
const result = assembleInfo();
|
|
@@ -308,12 +391,83 @@ const healthCommand = defineCommand({
|
|
|
308
391
|
type: "string",
|
|
309
392
|
description: "Rolling window start (ISO timestamp, date, epoch ms, or shorthand like 24h / 7d)",
|
|
310
393
|
},
|
|
394
|
+
"group-by": {
|
|
395
|
+
type: "string",
|
|
396
|
+
description: "Group rows by: run (one row per improve_runs entry). Omit for the default summary.",
|
|
397
|
+
},
|
|
398
|
+
detail: {
|
|
399
|
+
type: "string",
|
|
400
|
+
description: "DEPRECATED: use --group-by run instead of --detail per-run (removed 0.9.0).",
|
|
401
|
+
},
|
|
402
|
+
"window-compare": {
|
|
403
|
+
type: "string",
|
|
404
|
+
description: "Compare current window vs prior window of the same duration (e.g. 24h, 7d, 30m)",
|
|
405
|
+
},
|
|
406
|
+
windows: {
|
|
407
|
+
type: "string",
|
|
408
|
+
description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
|
|
409
|
+
},
|
|
311
410
|
},
|
|
312
|
-
run({ args }) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
411
|
+
async run({ args }) {
|
|
412
|
+
let resultStatus;
|
|
413
|
+
await runWithJsonErrors(() => {
|
|
414
|
+
// citty only surfaces the last value of a repeated flag, so read --windows
|
|
415
|
+
// directly from argv to support multi-window comparison.
|
|
416
|
+
const rawWindows = parseAllFlagValues("--windows");
|
|
417
|
+
const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
|
|
418
|
+
const groupByRaw = args["group-by"];
|
|
419
|
+
const detailRaw = args.detail;
|
|
420
|
+
// Back-compat: `--detail per-run` → `--group-by run` (warns; removed 0.9.0).
|
|
421
|
+
let groupBy = groupByRaw;
|
|
422
|
+
if (detailRaw !== undefined) {
|
|
423
|
+
if (detailRaw === "per-run") {
|
|
424
|
+
// Read --quiet from argv (not the warn-module singleton) so the
|
|
425
|
+
// warning fires correctly even when the early-stderr flags were not
|
|
426
|
+
// applied (e.g. the in-process test harness), matching the WS2
|
|
427
|
+
// output-flag deprecations in src/output/context.ts.
|
|
428
|
+
const quietRequested = process.argv.includes("--quiet") || process.argv.includes("-q");
|
|
429
|
+
if (!quietRequested) {
|
|
430
|
+
process.stderr.write("warning: '--detail per-run' is deprecated for 'akm health'; use '--group-by run'. Removed in 0.9.0.\n");
|
|
431
|
+
}
|
|
432
|
+
groupBy = groupBy ?? "run";
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
throw new UsageError(`Invalid value for --detail: ${detailRaw}. 'akm health' uses --group-by run (not --detail).`, "INVALID_DETAIL_VALUE");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const windowCompareRaw = args["window-compare"];
|
|
439
|
+
const result = akmHealth({
|
|
440
|
+
since: args.since,
|
|
441
|
+
groupBy: groupBy,
|
|
442
|
+
windowCompare: windowCompareRaw,
|
|
443
|
+
windows,
|
|
444
|
+
});
|
|
445
|
+
resultStatus = result.status;
|
|
446
|
+
// `--format md` is health-specific: render a TSV-shaped per-run or
|
|
447
|
+
// window-compare table to stdout instead of going through the JSON
|
|
448
|
+
// envelope. Other modes fall through to the standard output() path.
|
|
449
|
+
const mode = getOutputMode();
|
|
450
|
+
if (mode.format === "md") {
|
|
451
|
+
if (result.windows && result.windows.length > 0) {
|
|
452
|
+
console.log(renderWindowCompareMd(result.windows, result.deltas));
|
|
453
|
+
}
|
|
454
|
+
else if (result.runs) {
|
|
455
|
+
console.log(renderRunsDetailMd(result.runs));
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
output("health", result);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
output("health", result);
|
|
463
|
+
}
|
|
316
464
|
});
|
|
465
|
+
if (resultStatus === "fail") {
|
|
466
|
+
process.exit(EXIT_GENERAL);
|
|
467
|
+
}
|
|
468
|
+
if (resultStatus === "warn") {
|
|
469
|
+
process.exit(EXIT_HEALTH_WARN);
|
|
470
|
+
}
|
|
317
471
|
},
|
|
318
472
|
});
|
|
319
473
|
const graphCommand = defineCommand({
|
|
@@ -371,6 +525,35 @@ const graphCommand = defineCommand({
|
|
|
371
525
|
});
|
|
372
526
|
},
|
|
373
527
|
}),
|
|
528
|
+
entity: defineCommand({
|
|
529
|
+
meta: { name: "entity", description: "List assets that contain the given entity" },
|
|
530
|
+
args: {
|
|
531
|
+
name: { type: "positional", description: "Entity name", required: true },
|
|
532
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
533
|
+
limit: { type: "string", description: "Maximum matches to return" },
|
|
534
|
+
},
|
|
535
|
+
run({ args }) {
|
|
536
|
+
return runWithJsonErrors(() => {
|
|
537
|
+
output("graph-entity", akmGraphEntity({
|
|
538
|
+
name: args.name ?? "",
|
|
539
|
+
source: args.source,
|
|
540
|
+
limit: parsePositiveIntFlag(args.limit ?? undefined),
|
|
541
|
+
}));
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
}),
|
|
545
|
+
orphans: defineCommand({
|
|
546
|
+
meta: { name: "orphans", description: "List assets with no extracted graph entities" },
|
|
547
|
+
args: {
|
|
548
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
549
|
+
limit: { type: "string", description: "Maximum orphans to return" },
|
|
550
|
+
},
|
|
551
|
+
run({ args }) {
|
|
552
|
+
return runWithJsonErrors(() => {
|
|
553
|
+
output("graph-orphans", akmGraphOrphans({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
}),
|
|
374
557
|
export: defineCommand({
|
|
375
558
|
meta: { name: "export", description: "Export graph artifact as JSON or JSONL" },
|
|
376
559
|
args: {
|
|
@@ -388,6 +571,25 @@ const graphCommand = defineCommand({
|
|
|
388
571
|
});
|
|
389
572
|
},
|
|
390
573
|
}),
|
|
574
|
+
update: defineCommand({
|
|
575
|
+
meta: { name: "update", description: "Re-run graph extraction, optionally scoped to specific asset refs" },
|
|
576
|
+
args: {
|
|
577
|
+
refs: {
|
|
578
|
+
type: "positional",
|
|
579
|
+
description: "Zero or more asset refs to scope extraction (omit for a full re-extract)",
|
|
580
|
+
required: false,
|
|
581
|
+
default: "",
|
|
582
|
+
},
|
|
583
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
584
|
+
},
|
|
585
|
+
async run({ args }) {
|
|
586
|
+
return runWithJsonErrors(async () => {
|
|
587
|
+
// `refs` is a single positional; collect remaining argv tokens as well.
|
|
588
|
+
const rawRefs = [args.refs, ...(Array.isArray(args._) ? args._ : [])].filter((r) => typeof r === "string" && r.trim().length > 0);
|
|
589
|
+
output("graph-update", await akmGraphUpdate({ refs: rawRefs.length > 0 ? rawRefs : undefined, source: args.source }));
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
}),
|
|
391
593
|
},
|
|
392
594
|
run({ args }) {
|
|
393
595
|
return runWithJsonErrors(() => {
|
|
@@ -397,6 +599,36 @@ const graphCommand = defineCommand({
|
|
|
397
599
|
});
|
|
398
600
|
},
|
|
399
601
|
});
|
|
602
|
+
// MVP DB administration. Currently only `akm db backups`; restore is manual —
|
|
603
|
+
// stop akm and run `scripts/migrations/restore-data-dir.sh <backup>`.
|
|
604
|
+
const DB_SUBCOMMAND_SET = new Set(["backups"]);
|
|
605
|
+
const dbCommand = defineCommand({
|
|
606
|
+
meta: {
|
|
607
|
+
name: "db",
|
|
608
|
+
description: "Inspect the AKM SQLite data directory. Currently exposes `backups`; to restore from a snapshot, stop akm and run scripts/migrations/restore-data-dir.sh against the chosen backup.",
|
|
609
|
+
},
|
|
610
|
+
subCommands: {
|
|
611
|
+
backups: defineCommand({
|
|
612
|
+
meta: {
|
|
613
|
+
name: "backups",
|
|
614
|
+
description: "List pre-upgrade snapshots of the data directory (newest first). Backups are created automatically before destructive DB version upgrades unless AKM_DB_BACKUP=0.",
|
|
615
|
+
},
|
|
616
|
+
run() {
|
|
617
|
+
return runWithJsonErrors(() => {
|
|
618
|
+
output("db-backups", akmDbBackups());
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
}),
|
|
622
|
+
},
|
|
623
|
+
run({ args }) {
|
|
624
|
+
return runWithJsonErrors(() => {
|
|
625
|
+
if (hasSubcommand(args, DB_SUBCOMMAND_SET))
|
|
626
|
+
return;
|
|
627
|
+
// Default action: list backups.
|
|
628
|
+
output("db-backups", akmDbBackups());
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
});
|
|
400
632
|
const searchCommand = defineCommand({
|
|
401
633
|
meta: { name: "search", description: "Search the stash" },
|
|
402
634
|
args: {
|
|
@@ -422,7 +654,12 @@ const searchCommand = defineCommand({
|
|
|
422
654
|
default: "all",
|
|
423
655
|
},
|
|
424
656
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
425
|
-
detail: { type: "string", description: "Detail level (brief|normal|full
|
|
657
|
+
detail: { type: "string", description: "Detail level (brief|normal|full)" },
|
|
658
|
+
"no-project-context": {
|
|
659
|
+
type: "boolean",
|
|
660
|
+
description: "Disable the automatic project-context ranking boost (also disabled by AKM_DISABLE_PROJECT_CONTEXT=1).",
|
|
661
|
+
default: false,
|
|
662
|
+
},
|
|
426
663
|
},
|
|
427
664
|
async run({ args }) {
|
|
428
665
|
await runWithJsonErrors(async () => {
|
|
@@ -439,7 +676,21 @@ const searchCommand = defineCommand({
|
|
|
439
676
|
const filters = parseScopeFilterFlags(filterTokens, "--filter");
|
|
440
677
|
const includeProposed = args["include-proposed"] === true;
|
|
441
678
|
const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
|
|
442
|
-
const
|
|
679
|
+
const noProjectContext = getHyphenatedBoolean(args, "no-project-context");
|
|
680
|
+
// --no-project-context sets env so searchDatabase picks it up without
|
|
681
|
+
// threading the flag through the entire call stack.
|
|
682
|
+
if (noProjectContext)
|
|
683
|
+
process.env.AKM_DISABLE_PROJECT_CONTEXT = "1";
|
|
684
|
+
const result = await akmSearch({
|
|
685
|
+
query,
|
|
686
|
+
type,
|
|
687
|
+
limit,
|
|
688
|
+
source,
|
|
689
|
+
filters,
|
|
690
|
+
includeProposed,
|
|
691
|
+
belief,
|
|
692
|
+
eventSource: resolveEventSource(),
|
|
693
|
+
});
|
|
443
694
|
output("search", result);
|
|
444
695
|
});
|
|
445
696
|
},
|
|
@@ -457,6 +708,12 @@ const curateCommand = defineCommand({
|
|
|
457
708
|
},
|
|
458
709
|
limit: { type: "string", description: "Maximum number of curated results", default: "4" },
|
|
459
710
|
source: { type: "string", description: "Search source (stash|registry|both)", default: "stash" },
|
|
711
|
+
// Output-contract flags. The active values are read from the process-level
|
|
712
|
+
// singleton (parsed from argv at startup); these declarations make them
|
|
713
|
+
// visible in `akm curate --help` and document the supported axes.
|
|
714
|
+
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
715
|
+
detail: { type: "string", description: "Detail level (brief|normal|full)" },
|
|
716
|
+
shape: { type: "string", description: "Output projection (human|agent)" },
|
|
460
717
|
},
|
|
461
718
|
async run({ args }) {
|
|
462
719
|
await runWithJsonErrors(async () => {
|
|
@@ -472,137 +729,6 @@ const curateCommand = defineCommand({
|
|
|
472
729
|
});
|
|
473
730
|
},
|
|
474
731
|
});
|
|
475
|
-
const addCommand = defineCommand({
|
|
476
|
-
meta: {
|
|
477
|
-
name: "add",
|
|
478
|
-
description: "Add a source (local directory, website, npm package, GitHub repo, git URL, or remote provider)",
|
|
479
|
-
},
|
|
480
|
-
args: {
|
|
481
|
-
ref: {
|
|
482
|
-
type: "positional",
|
|
483
|
-
description: "Path, URL, or registry ref (website URL, npm package, owner/repo, git URL, or local directory)",
|
|
484
|
-
required: true,
|
|
485
|
-
},
|
|
486
|
-
provider: { type: "string", description: "Provider type (e.g. website, npm). Required for URL sources." },
|
|
487
|
-
options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
|
|
488
|
-
name: { type: "string", description: "Human-friendly name for the source" },
|
|
489
|
-
writable: {
|
|
490
|
-
type: "boolean",
|
|
491
|
-
description: "Mark a git stash as writable so changes can be pushed back",
|
|
492
|
-
default: false,
|
|
493
|
-
},
|
|
494
|
-
trust: {
|
|
495
|
-
type: "boolean",
|
|
496
|
-
description: "Bypass install-audit blocking for this add invocation only",
|
|
497
|
-
default: false,
|
|
498
|
-
},
|
|
499
|
-
type: {
|
|
500
|
-
type: "string",
|
|
501
|
-
description: "Override asset type for all files in this stash (currently supports: wiki)",
|
|
502
|
-
},
|
|
503
|
-
"max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
|
|
504
|
-
"max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
|
|
505
|
-
"allow-insecure": {
|
|
506
|
-
type: "boolean",
|
|
507
|
-
description: "Allow a plain HTTP source URL (otherwise rejected for non-localhost hosts)",
|
|
508
|
-
default: false,
|
|
509
|
-
},
|
|
510
|
-
},
|
|
511
|
-
async run({ args }) {
|
|
512
|
-
await runWithJsonErrors(async () => {
|
|
513
|
-
const ref = args.ref.trim();
|
|
514
|
-
const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
|
|
515
|
-
// URL with --provider → stash source (remote or git provider)
|
|
516
|
-
if (args.provider) {
|
|
517
|
-
if (shouldWarnOnPlainHttp(ref)) {
|
|
518
|
-
if (!allowInsecure) {
|
|
519
|
-
throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
|
|
520
|
-
"Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
|
|
521
|
-
}
|
|
522
|
-
warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
|
|
523
|
-
}
|
|
524
|
-
let parsedOptions;
|
|
525
|
-
if (args.options) {
|
|
526
|
-
try {
|
|
527
|
-
const parsed = JSON.parse(args.options);
|
|
528
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
529
|
-
throw new UsageError("--options must be a JSON object");
|
|
530
|
-
}
|
|
531
|
-
parsedOptions = parsed;
|
|
532
|
-
}
|
|
533
|
-
catch (err) {
|
|
534
|
-
if (err instanceof UsageError)
|
|
535
|
-
throw err;
|
|
536
|
-
throw new UsageError("--options must be valid JSON");
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
const result = addStash({
|
|
540
|
-
target: ref,
|
|
541
|
-
name: args.name,
|
|
542
|
-
providerType: args.provider,
|
|
543
|
-
options: parsedOptions,
|
|
544
|
-
writable: args.writable,
|
|
545
|
-
});
|
|
546
|
-
appendEvent({
|
|
547
|
-
eventType: "add",
|
|
548
|
-
metadata: { target: ref, provider: args.provider, name: args.name ?? null, writable: args.writable === true },
|
|
549
|
-
});
|
|
550
|
-
output("add", result);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
if (shouldWarnOnPlainHttp(ref)) {
|
|
554
|
-
if (!allowInsecure) {
|
|
555
|
-
throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
|
|
556
|
-
"Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
|
|
557
|
-
}
|
|
558
|
-
warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
|
|
559
|
-
}
|
|
560
|
-
const websiteOptions = buildWebsiteOptions(args);
|
|
561
|
-
if (args.type === "wiki") {
|
|
562
|
-
const { registerWikiSource } = await import("./commands/source-add");
|
|
563
|
-
const result = await registerWikiSource({
|
|
564
|
-
ref,
|
|
565
|
-
name: args.name,
|
|
566
|
-
options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
|
|
567
|
-
trustThisInstall: args.trust,
|
|
568
|
-
writable: args.writable,
|
|
569
|
-
});
|
|
570
|
-
appendEvent({
|
|
571
|
-
eventType: "add",
|
|
572
|
-
metadata: { target: ref, type: "wiki", name: args.name ?? null, writable: args.writable === true },
|
|
573
|
-
});
|
|
574
|
-
output("add", result);
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
const result = await akmAdd({
|
|
578
|
-
ref,
|
|
579
|
-
name: args.name,
|
|
580
|
-
overrideType: args.type,
|
|
581
|
-
options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
|
|
582
|
-
trustThisInstall: args.trust,
|
|
583
|
-
writable: args.writable,
|
|
584
|
-
});
|
|
585
|
-
appendEvent({
|
|
586
|
-
eventType: "add",
|
|
587
|
-
metadata: {
|
|
588
|
-
target: ref,
|
|
589
|
-
name: args.name ?? null,
|
|
590
|
-
overrideType: args.type ?? null,
|
|
591
|
-
writable: args.writable === true,
|
|
592
|
-
},
|
|
593
|
-
});
|
|
594
|
-
output("add", result);
|
|
595
|
-
});
|
|
596
|
-
},
|
|
597
|
-
});
|
|
598
|
-
function buildWebsiteOptions(args) {
|
|
599
|
-
const websiteOptions = {};
|
|
600
|
-
if (typeof args["max-pages"] === "string" && args["max-pages"].length > 0)
|
|
601
|
-
websiteOptions.maxPages = args["max-pages"];
|
|
602
|
-
if (typeof args["max-depth"] === "string" && args["max-depth"].length > 0)
|
|
603
|
-
websiteOptions.maxDepth = args["max-depth"];
|
|
604
|
-
return websiteOptions;
|
|
605
|
-
}
|
|
606
732
|
const VALID_SOURCE_KINDS = new Set(["local", "managed", "remote"]);
|
|
607
733
|
function parseKindFilter(raw) {
|
|
608
734
|
if (!raw)
|
|
@@ -615,22 +741,6 @@ function parseKindFilter(raw) {
|
|
|
615
741
|
}
|
|
616
742
|
return kinds;
|
|
617
743
|
}
|
|
618
|
-
function shouldWarnOnPlainHttp(ref) {
|
|
619
|
-
if (!ref.startsWith("http://"))
|
|
620
|
-
return false;
|
|
621
|
-
try {
|
|
622
|
-
const hostname = new URL(ref).hostname.toLowerCase();
|
|
623
|
-
return (hostname !== "localhost" &&
|
|
624
|
-
hostname !== "127.0.0.1" &&
|
|
625
|
-
hostname !== "0.0.0.0" &&
|
|
626
|
-
hostname !== "::1" &&
|
|
627
|
-
hostname !== "[::1]" &&
|
|
628
|
-
!hostname.endsWith(".localhost"));
|
|
629
|
-
}
|
|
630
|
-
catch {
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
744
|
const listCommand = defineCommand({
|
|
635
745
|
meta: { name: "list", description: "List all sources (local directories, managed packages, remote providers)" },
|
|
636
746
|
args: {
|
|
@@ -648,9 +758,18 @@ const removeCommand = defineCommand({
|
|
|
648
758
|
meta: { name: "remove", description: "Remove a source by id, ref, path, URL, or name" },
|
|
649
759
|
args: {
|
|
650
760
|
target: { type: "positional", description: "Source to remove (id, ref, path, URL, or name)", required: true },
|
|
761
|
+
yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
|
|
651
762
|
},
|
|
652
763
|
async run({ args }) {
|
|
653
764
|
await runWithJsonErrors(async () => {
|
|
765
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
766
|
+
const confirmed = await confirmDestructive(`Remove source "${args.target}"? This cannot be undone.`, {
|
|
767
|
+
yes: args.yes === true,
|
|
768
|
+
});
|
|
769
|
+
if (!confirmed) {
|
|
770
|
+
process.stderr.write("Aborted.\n");
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
654
773
|
const result = await akmRemove({ target: args.target });
|
|
655
774
|
appendEvent({
|
|
656
775
|
eventType: "remove",
|
|
@@ -731,7 +850,8 @@ const showCommand = defineCommand({
|
|
|
731
850
|
required: true,
|
|
732
851
|
},
|
|
733
852
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
734
|
-
detail: { type: "string", description: "Detail level (brief|normal|full
|
|
853
|
+
detail: { type: "string", description: "Detail level (brief|normal|full)" },
|
|
854
|
+
shape: { type: "string", description: "Output projection (human|agent|summary)" },
|
|
735
855
|
scope: {
|
|
736
856
|
type: "string",
|
|
737
857
|
description: "Scope filter (repeatable): --scope user=<id> --scope agent=<id> --scope run=<id> --scope channel=<name>. Narrows resolution to assets whose frontmatter scope matches.",
|
|
@@ -741,9 +861,12 @@ const showCommand = defineCommand({
|
|
|
741
861
|
await runWithJsonErrors(async () => {
|
|
742
862
|
const subcommand = Array.isArray(args._) ? args._[0] : undefined;
|
|
743
863
|
if (subcommand === "proposal") {
|
|
864
|
+
if (!isQuiet()) {
|
|
865
|
+
process.stderr.write("warning: 'akm show proposal <id>' is deprecated and will be removed in 0.9.0. Use 'akm proposal show <id>'.\n");
|
|
866
|
+
}
|
|
744
867
|
const proposalId = Array.isArray(args._) ? args._[1] : undefined;
|
|
745
868
|
if (typeof proposalId !== "string" || !proposalId.trim()) {
|
|
746
|
-
throw new UsageError("Usage: akm show
|
|
869
|
+
throw new UsageError("Usage: akm proposal show <id>", "MISSING_REQUIRED_ARGUMENT");
|
|
747
870
|
}
|
|
748
871
|
const result = akmProposalShow({ id: proposalId.trim() });
|
|
749
872
|
output("proposal-show", result);
|
|
@@ -781,14 +904,24 @@ const showCommand = defineCommand({
|
|
|
781
904
|
throw new UsageError(`Unknown view mode: ${akmView}. Expected one of: full|toc|frontmatter|section|lines`);
|
|
782
905
|
}
|
|
783
906
|
}
|
|
784
|
-
const
|
|
907
|
+
const cliShape = getOutputMode().shape;
|
|
785
908
|
const explicitDetail = parseFlagValue(process.argv, "--detail");
|
|
786
|
-
|
|
909
|
+
// `--shape summary` selects the compact metadata projection for show
|
|
910
|
+
// (the legacy `--detail summary` spelling still maps here via the
|
|
911
|
+
// back-compat path in resolveOutputMode). `--detail brief` forces the
|
|
912
|
+
// brief response regardless of shape.
|
|
913
|
+
const showDetail = explicitDetail === "brief" ? "brief" : cliShape === "summary" ? "summary" : undefined;
|
|
787
914
|
// `--scope` is repeatable — citty only exposes the last value, so read
|
|
788
915
|
// every occurrence directly from argv (same pattern as `--filter`).
|
|
789
916
|
const scopeTokens = parseAllFlagValues("--scope");
|
|
790
917
|
const scope = parseScopeFilterFlags(scopeTokens, "--scope");
|
|
791
|
-
const result = await akmShowUnified({
|
|
918
|
+
const result = await akmShowUnified({
|
|
919
|
+
ref: args.ref,
|
|
920
|
+
view,
|
|
921
|
+
detail: showDetail,
|
|
922
|
+
scope,
|
|
923
|
+
eventSource: resolveEventSource(),
|
|
924
|
+
});
|
|
792
925
|
output("show", result);
|
|
793
926
|
});
|
|
794
927
|
},
|
|
@@ -862,12 +995,36 @@ const configCommand = defineCommand({
|
|
|
862
995
|
args: {
|
|
863
996
|
key: { type: "positional", required: true, description: "Config key (for example: embedding, llm)" },
|
|
864
997
|
value: { type: "positional", required: true, description: "Config value" },
|
|
998
|
+
// #463: stable machine-friendly entry point for plugins / hooks.
|
|
999
|
+
// `--silent` suppresses the config dump on stdout so hook-driven
|
|
1000
|
+
// writes don't pollute their host's output stream.
|
|
1001
|
+
silent: {
|
|
1002
|
+
type: "boolean",
|
|
1003
|
+
description: "Suppress the post-write config dump on stdout. Use from hooks and CI scripts; the write still happens and errors still print.",
|
|
1004
|
+
default: false,
|
|
1005
|
+
},
|
|
1006
|
+
// #463: explicit layer flag for forward-compat. User layer is the only
|
|
1007
|
+
// settable layer today; the flag exists so plugin authors can encode
|
|
1008
|
+
// intent and the surface stays stable if project-layer writes return.
|
|
1009
|
+
layer: {
|
|
1010
|
+
type: "string",
|
|
1011
|
+
description: "Config layer to write to. Currently only `user` is supported.",
|
|
1012
|
+
default: "user",
|
|
1013
|
+
},
|
|
865
1014
|
},
|
|
866
1015
|
run({ args }) {
|
|
867
1016
|
return runWithJsonErrors(() => {
|
|
868
|
-
|
|
1017
|
+
if (args.layer && args.layer !== "user") {
|
|
1018
|
+
throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
|
|
1019
|
+
}
|
|
1020
|
+
// Use loadConfig (not loadUserConfig) so the project-config
|
|
1021
|
+
// deprecation warning fires consistently with `akm config get`
|
|
1022
|
+
// (#457). Effective merged shape is identical post-0.8.0.
|
|
1023
|
+
const updated = setConfigValue(loadConfig(), args.key, args.value);
|
|
869
1024
|
saveConfig(updated);
|
|
870
|
-
|
|
1025
|
+
if (!args.silent) {
|
|
1026
|
+
output("config", listConfig(updated));
|
|
1027
|
+
}
|
|
871
1028
|
});
|
|
872
1029
|
},
|
|
873
1030
|
}),
|
|
@@ -875,12 +1032,83 @@ const configCommand = defineCommand({
|
|
|
875
1032
|
meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/llm section" },
|
|
876
1033
|
args: {
|
|
877
1034
|
key: { type: "positional", required: true, description: "Config key to unset" },
|
|
1035
|
+
silent: {
|
|
1036
|
+
type: "boolean",
|
|
1037
|
+
description: "Suppress the post-write config dump on stdout.",
|
|
1038
|
+
default: false,
|
|
1039
|
+
},
|
|
1040
|
+
layer: {
|
|
1041
|
+
type: "string",
|
|
1042
|
+
description: "Config layer to write to. Currently only `user` is supported.",
|
|
1043
|
+
default: "user",
|
|
1044
|
+
},
|
|
878
1045
|
},
|
|
879
1046
|
run({ args }) {
|
|
880
1047
|
return runWithJsonErrors(() => {
|
|
881
|
-
|
|
1048
|
+
if (args.layer && args.layer !== "user") {
|
|
1049
|
+
throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
|
|
1050
|
+
}
|
|
1051
|
+
const updated = unsetConfigValue(loadConfig(), args.key);
|
|
882
1052
|
saveConfig(updated);
|
|
883
|
-
|
|
1053
|
+
if (!args.silent) {
|
|
1054
|
+
output("config", listConfig(updated));
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
},
|
|
1058
|
+
}),
|
|
1059
|
+
validate: defineCommand({
|
|
1060
|
+
meta: {
|
|
1061
|
+
name: "validate",
|
|
1062
|
+
description: "Validate the on-disk config file against the schema. Exits non-zero on errors.",
|
|
1063
|
+
},
|
|
1064
|
+
async run() {
|
|
1065
|
+
return runWithJsonErrors(async () => {
|
|
1066
|
+
const { runConfigValidate } = await import("./cli/config-validate.js");
|
|
1067
|
+
await runConfigValidate();
|
|
1068
|
+
});
|
|
1069
|
+
},
|
|
1070
|
+
}),
|
|
1071
|
+
migrate: defineCommand({
|
|
1072
|
+
meta: {
|
|
1073
|
+
name: "migrate",
|
|
1074
|
+
description: "Migrate the config file to the current schema version. Use --dry-run to preview without writing.",
|
|
1075
|
+
},
|
|
1076
|
+
args: {
|
|
1077
|
+
"dry-run": { type: "boolean", description: "Preview the migration result without writing.", default: false },
|
|
1078
|
+
"print-diff": {
|
|
1079
|
+
type: "boolean",
|
|
1080
|
+
description: "Print a unified diff of old vs new config alongside the migration output.",
|
|
1081
|
+
default: false,
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
async run({ args }) {
|
|
1085
|
+
return runWithJsonErrors(async () => {
|
|
1086
|
+
const { runConfigMigrate } = await import("./cli/config-migrate.js");
|
|
1087
|
+
await runConfigMigrate({ dryRun: Boolean(args["dry-run"]), printDiff: Boolean(args["print-diff"]) });
|
|
1088
|
+
});
|
|
1089
|
+
},
|
|
1090
|
+
}),
|
|
1091
|
+
enable: defineCommand({
|
|
1092
|
+
meta: { name: "enable", description: "Enable an optional component (skills.sh)" },
|
|
1093
|
+
args: {
|
|
1094
|
+
target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
|
|
1095
|
+
},
|
|
1096
|
+
run({ args }) {
|
|
1097
|
+
return runWithJsonErrors(() => {
|
|
1098
|
+
const result = toggleComponent(args.target, true);
|
|
1099
|
+
output("enable", result);
|
|
1100
|
+
});
|
|
1101
|
+
},
|
|
1102
|
+
}),
|
|
1103
|
+
disable: defineCommand({
|
|
1104
|
+
meta: { name: "disable", description: "Disable an optional component (skills.sh)" },
|
|
1105
|
+
args: {
|
|
1106
|
+
target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
|
|
1107
|
+
},
|
|
1108
|
+
run({ args }) {
|
|
1109
|
+
return runWithJsonErrors(() => {
|
|
1110
|
+
const result = toggleComponent(args.target, false);
|
|
1111
|
+
output("disable", result);
|
|
884
1112
|
});
|
|
885
1113
|
},
|
|
886
1114
|
}),
|
|
@@ -897,15 +1125,51 @@ const configCommand = defineCommand({
|
|
|
897
1125
|
});
|
|
898
1126
|
},
|
|
899
1127
|
});
|
|
900
|
-
|
|
1128
|
+
// Shared `save`/`sync` body. `sync` is the canonical spelling in 0.8; `save`
|
|
1129
|
+
// remains a deprecated alias (removed 0.9.0). Both share this implementation so
|
|
1130
|
+
// the git-commit/push logic and the `--format`-as-name workaround stay in one place.
|
|
1131
|
+
async function runSyncBody(args, verb) {
|
|
1132
|
+
await runWithJsonErrors(async () => {
|
|
1133
|
+
// Fix: citty can consume `--format json` (space-separated) as the
|
|
1134
|
+
// positional `name` argument (e.g. `akm sync --format json` parses
|
|
1135
|
+
// name="json"). Detect the mis-parse by checking argv order — only
|
|
1136
|
+
// treat the positional as consumed by --format when --format appears
|
|
1137
|
+
// before any standalone occurrence of the same value in the sync
|
|
1138
|
+
// subcommand's argv slice. This preserves legitimate invocations
|
|
1139
|
+
// like `akm sync json --format json`.
|
|
1140
|
+
const parsedFormat = parseFlagValue(process.argv, "--format");
|
|
1141
|
+
const effectiveName = args.name !== undefined &&
|
|
1142
|
+
parsedFormat !== undefined &&
|
|
1143
|
+
args.name === parsedFormat &&
|
|
1144
|
+
wasFormatValueConsumedAsName(args.name, parsedFormat, verb)
|
|
1145
|
+
? undefined
|
|
1146
|
+
: args.name;
|
|
1147
|
+
let writable;
|
|
1148
|
+
if (effectiveName === undefined) {
|
|
1149
|
+
// Primary stash — honour the root-level writable flag from config.
|
|
1150
|
+
writable = resolveWritableOverride(loadConfig());
|
|
1151
|
+
}
|
|
1152
|
+
const result = saveGitStash(effectiveName, args.message, writable, { push: args.push !== false });
|
|
1153
|
+
appendEvent({
|
|
1154
|
+
eventType: "save",
|
|
1155
|
+
metadata: {
|
|
1156
|
+
name: effectiveName ?? null,
|
|
1157
|
+
message: args.message ?? null,
|
|
1158
|
+
ok: result.ok !== false,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
output("save", result);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
const syncCommand = defineCommand({
|
|
901
1165
|
meta: {
|
|
902
|
-
name: "
|
|
903
|
-
description: "
|
|
1166
|
+
name: "sync",
|
|
1167
|
+
description: "Sync changes in a git-backed stash: commits (and pushes when writable + remote is configured). No-op for non-git stashes.",
|
|
904
1168
|
},
|
|
905
1169
|
args: {
|
|
906
1170
|
name: {
|
|
907
1171
|
type: "positional",
|
|
908
|
-
description: "Name of the git stash to
|
|
1172
|
+
description: "Name of the git stash to sync (default: primary stash directory)",
|
|
909
1173
|
required: false,
|
|
910
1174
|
},
|
|
911
1175
|
message: {
|
|
@@ -913,56 +1177,59 @@ const saveCommand = defineCommand({
|
|
|
913
1177
|
alias: "m",
|
|
914
1178
|
description: "Commit message (default: timestamp)",
|
|
915
1179
|
},
|
|
1180
|
+
push: {
|
|
1181
|
+
type: "boolean",
|
|
1182
|
+
description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
|
|
1183
|
+
default: true,
|
|
1184
|
+
},
|
|
916
1185
|
},
|
|
917
1186
|
async run({ args }) {
|
|
918
|
-
await
|
|
919
|
-
// Fix: citty can consume `--format json` (space-separated) as the
|
|
920
|
-
// positional `name` argument (e.g. `akm save --format json` parses
|
|
921
|
-
// name="json"). Detect the mis-parse by checking argv order — only
|
|
922
|
-
// treat the positional as consumed by --format when --format appears
|
|
923
|
-
// before any standalone occurrence of the same value in the save
|
|
924
|
-
// subcommand's argv slice. This preserves legitimate invocations
|
|
925
|
-
// like `akm save json --format json`.
|
|
926
|
-
const parsedFormat = parseFlagValue(process.argv, "--format");
|
|
927
|
-
const effectiveName = args.name !== undefined &&
|
|
928
|
-
parsedFormat !== undefined &&
|
|
929
|
-
args.name === parsedFormat &&
|
|
930
|
-
wasFormatValueConsumedAsName(args.name, parsedFormat)
|
|
931
|
-
? undefined
|
|
932
|
-
: args.name;
|
|
933
|
-
let writable;
|
|
934
|
-
if (effectiveName === undefined) {
|
|
935
|
-
// Primary stash — honour the root-level writable flag from config.
|
|
936
|
-
const cfg = loadConfig();
|
|
937
|
-
writable = cfg.writable === true ? true : undefined;
|
|
938
|
-
}
|
|
939
|
-
const result = saveGitStash(effectiveName, args.message, writable);
|
|
940
|
-
appendEvent({
|
|
941
|
-
eventType: "save",
|
|
942
|
-
metadata: {
|
|
943
|
-
name: effectiveName ?? null,
|
|
944
|
-
message: args.message ?? null,
|
|
945
|
-
ok: result.ok !== false,
|
|
946
|
-
},
|
|
947
|
-
});
|
|
948
|
-
output("save", result);
|
|
949
|
-
});
|
|
1187
|
+
await runSyncBody(args, "sync");
|
|
950
1188
|
},
|
|
951
1189
|
});
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1190
|
+
// Deprecated alias (removed 0.9.0): `akm save` → `akm sync`.
|
|
1191
|
+
const saveCommand = defineCommand({
|
|
1192
|
+
meta: {
|
|
1193
|
+
name: "save",
|
|
1194
|
+
description: "DEPRECATED — use `akm sync`. Removed in 0.9.0.",
|
|
1195
|
+
},
|
|
1196
|
+
args: {
|
|
1197
|
+
name: {
|
|
1198
|
+
type: "positional",
|
|
1199
|
+
description: "Name of the git stash to save (default: primary stash directory)",
|
|
1200
|
+
required: false,
|
|
1201
|
+
},
|
|
1202
|
+
message: {
|
|
1203
|
+
type: "string",
|
|
1204
|
+
alias: "m",
|
|
1205
|
+
description: "Commit message (default: timestamp)",
|
|
1206
|
+
},
|
|
1207
|
+
push: {
|
|
1208
|
+
type: "boolean",
|
|
1209
|
+
description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
|
|
1210
|
+
default: true,
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
async run({ args }) {
|
|
1214
|
+
emitCommandDeprecation("save", "sync");
|
|
1215
|
+
await runSyncBody(args, "save");
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
/**
|
|
1219
|
+
* Detect whether `--format <value>` was consumed by citty as the optional
|
|
1220
|
+
* `name` positional of `akm save`. Returns true only when `--format` appears
|
|
1221
|
+
* in the save subcommand's argv slice AND the candidate name does NOT
|
|
1222
|
+
* appear as a standalone positional elsewhere (before or after the flag).
|
|
957
1223
|
*
|
|
958
|
-
* This keeps `akm
|
|
959
|
-
* while `akm
|
|
960
|
-
* primary-stash
|
|
1224
|
+
* This keeps `akm sync json --format json` routing `json` as the stash name,
|
|
1225
|
+
* while `akm sync --format json` (no separate positional) is treated as a
|
|
1226
|
+
* primary-stash sync. `verb` is the subcommand token to anchor on (`sync` or
|
|
1227
|
+
* the deprecated `save`).
|
|
961
1228
|
*/
|
|
962
|
-
function wasFormatValueConsumedAsName(name, formatValue) {
|
|
1229
|
+
function wasFormatValueConsumedAsName(name, formatValue, verb) {
|
|
963
1230
|
const argv = process.argv.slice(2);
|
|
964
|
-
const
|
|
965
|
-
const tokens =
|
|
1231
|
+
const verbIndex = argv.indexOf(verb);
|
|
1232
|
+
const tokens = verbIndex >= 0 ? argv.slice(verbIndex + 1) : argv;
|
|
966
1233
|
let formatIndex = -1;
|
|
967
1234
|
let formatConsumesNextToken = false;
|
|
968
1235
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
@@ -1013,280 +1280,6 @@ const cloneCommand = defineCommand({
|
|
|
1013
1280
|
});
|
|
1014
1281
|
},
|
|
1015
1282
|
});
|
|
1016
|
-
const registryCommand = defineCommand({
|
|
1017
|
-
meta: { name: "registry", description: "Manage stash registries" },
|
|
1018
|
-
subCommands: {
|
|
1019
|
-
list: defineCommand({
|
|
1020
|
-
meta: { name: "list", description: "List configured registries" },
|
|
1021
|
-
run() {
|
|
1022
|
-
return runWithJsonErrors(() => {
|
|
1023
|
-
const config = loadUserConfig();
|
|
1024
|
-
const registries = config.registries ?? DEFAULT_CONFIG.registries;
|
|
1025
|
-
output("registry-list", { registries });
|
|
1026
|
-
});
|
|
1027
|
-
},
|
|
1028
|
-
}),
|
|
1029
|
-
add: defineCommand({
|
|
1030
|
-
meta: { name: "add", description: "Add a registry by URL" },
|
|
1031
|
-
args: {
|
|
1032
|
-
url: { type: "positional", description: "Registry index URL", required: true },
|
|
1033
|
-
name: { type: "string", description: "Human-friendly name for the registry" },
|
|
1034
|
-
provider: { type: "string", description: "Provider type (e.g. static-index, skills-sh)" },
|
|
1035
|
-
options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
|
|
1036
|
-
"allow-insecure": {
|
|
1037
|
-
type: "boolean",
|
|
1038
|
-
description: "Allow a plain HTTP registry URL (otherwise rejected)",
|
|
1039
|
-
default: false,
|
|
1040
|
-
},
|
|
1041
|
-
},
|
|
1042
|
-
run({ args }) {
|
|
1043
|
-
return runWithJsonErrors(() => {
|
|
1044
|
-
if (!args.url.startsWith("http")) {
|
|
1045
|
-
throw new UsageError("Registry URL must start with http:// or https://");
|
|
1046
|
-
}
|
|
1047
|
-
if (args.url.startsWith("http://")) {
|
|
1048
|
-
const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
|
|
1049
|
-
if (!allowInsecure) {
|
|
1050
|
-
throw new UsageError("Registry URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious index. " +
|
|
1051
|
-
"Use https:// or pass --allow-insecure if you have explicitly accepted the risk.");
|
|
1052
|
-
}
|
|
1053
|
-
warn("Warning: registry URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious index.");
|
|
1054
|
-
}
|
|
1055
|
-
const config = loadUserConfig();
|
|
1056
|
-
const registries = [...(config.registries ?? [])];
|
|
1057
|
-
// Deduplicate by URL
|
|
1058
|
-
if (registries.some((r) => r.url === args.url)) {
|
|
1059
|
-
output("registry-add", { registries, added: false, message: "Registry URL already configured" });
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
const entry = { url: args.url };
|
|
1063
|
-
if (args.name)
|
|
1064
|
-
entry.name = args.name;
|
|
1065
|
-
if (args.provider)
|
|
1066
|
-
entry.provider = args.provider;
|
|
1067
|
-
if (args.options) {
|
|
1068
|
-
try {
|
|
1069
|
-
entry.options = JSON.parse(args.options);
|
|
1070
|
-
}
|
|
1071
|
-
catch {
|
|
1072
|
-
throw new UsageError("--options must be valid JSON");
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
registries.push(entry);
|
|
1076
|
-
saveConfig({ ...config, registries });
|
|
1077
|
-
output("registry-add", { registries, added: true });
|
|
1078
|
-
});
|
|
1079
|
-
},
|
|
1080
|
-
}),
|
|
1081
|
-
remove: defineCommand({
|
|
1082
|
-
meta: { name: "remove", description: "Remove a registry by URL or name" },
|
|
1083
|
-
args: {
|
|
1084
|
-
target: { type: "positional", description: "Registry URL or name to remove", required: true },
|
|
1085
|
-
},
|
|
1086
|
-
run({ args }) {
|
|
1087
|
-
return runWithJsonErrors(() => {
|
|
1088
|
-
const config = loadUserConfig();
|
|
1089
|
-
const registries = [...(config.registries ?? [])];
|
|
1090
|
-
const idx = registries.findIndex((r) => r.url === args.target || r.name === args.target);
|
|
1091
|
-
if (idx === -1) {
|
|
1092
|
-
output("registry-remove", { registries, removed: false, message: "No matching registry found" });
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
const removed = registries.splice(idx, 1)[0];
|
|
1096
|
-
saveConfig({ ...config, registries });
|
|
1097
|
-
output("registry-remove", { registries, removed: true, entry: removed });
|
|
1098
|
-
});
|
|
1099
|
-
},
|
|
1100
|
-
}),
|
|
1101
|
-
search: defineCommand({
|
|
1102
|
-
meta: { name: "search", description: "Search enabled registries for stashes" },
|
|
1103
|
-
args: {
|
|
1104
|
-
query: { type: "positional", description: "Search query", required: true },
|
|
1105
|
-
limit: { type: "string", description: "Maximum number of results" },
|
|
1106
|
-
assets: { type: "boolean", description: "Include asset-level search results", default: false },
|
|
1107
|
-
},
|
|
1108
|
-
async run({ args }) {
|
|
1109
|
-
await runWithJsonErrors(async () => {
|
|
1110
|
-
const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
|
|
1111
|
-
const result = await searchRegistry(args.query, { limit: limitRaw, includeAssets: args.assets });
|
|
1112
|
-
output("registry-search", result);
|
|
1113
|
-
});
|
|
1114
|
-
},
|
|
1115
|
-
}),
|
|
1116
|
-
"build-index": defineCommand({
|
|
1117
|
-
meta: { name: "build-index", description: "Build a v2 registry index from discovery and manual entries" },
|
|
1118
|
-
args: {
|
|
1119
|
-
out: { type: "string", description: "Output path for the generated index" },
|
|
1120
|
-
manual: { type: "string", description: "Manual entries JSON file" },
|
|
1121
|
-
"npm-registry": { type: "string", description: "Override npm registry base URL" },
|
|
1122
|
-
"github-api": { type: "string", description: "Override GitHub API base URL" },
|
|
1123
|
-
},
|
|
1124
|
-
async run({ args }) {
|
|
1125
|
-
await runWithJsonErrors(async () => {
|
|
1126
|
-
const result = await buildRegistryIndex({
|
|
1127
|
-
manualEntriesPath: args.manual,
|
|
1128
|
-
npmRegistryBase: getHyphenatedArg(args, "npm-registry"),
|
|
1129
|
-
githubApiBase: getHyphenatedArg(args, "github-api"),
|
|
1130
|
-
});
|
|
1131
|
-
const outPath = writeRegistryIndex(result.index, args.out);
|
|
1132
|
-
output("registry-build-index", {
|
|
1133
|
-
outPath,
|
|
1134
|
-
version: result.index.version,
|
|
1135
|
-
updatedAt: result.index.updatedAt,
|
|
1136
|
-
totalKits: result.counts.total,
|
|
1137
|
-
counts: result.counts,
|
|
1138
|
-
manualEntriesPath: result.paths.manualEntriesPath,
|
|
1139
|
-
});
|
|
1140
|
-
});
|
|
1141
|
-
},
|
|
1142
|
-
}),
|
|
1143
|
-
},
|
|
1144
|
-
});
|
|
1145
|
-
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
|
|
1146
|
-
const MAX_FEEDBACK_TAGS = 10;
|
|
1147
|
-
function validateFeedbackTags(raw) {
|
|
1148
|
-
const seen = new Set();
|
|
1149
|
-
const out = [];
|
|
1150
|
-
for (const tag of raw) {
|
|
1151
|
-
const parts = tag.split(":");
|
|
1152
|
-
if (parts.length < 2 || parts[0] === "" || parts.slice(1).join("") === "") {
|
|
1153
|
-
throw new UsageError(`Invalid tag "${tag}". Tags must be in key:value format where key matches [a-z_][a-z0-9_]* and value is non-empty.`, "INVALID_FLAG_VALUE");
|
|
1154
|
-
}
|
|
1155
|
-
const key = parts[0];
|
|
1156
|
-
if (!TAG_KEY_RE.test(key)) {
|
|
1157
|
-
throw new UsageError(`Invalid tag key "${key}" in "${tag}". Key must match [a-z_][a-z0-9_]*.`, "INVALID_FLAG_VALUE");
|
|
1158
|
-
}
|
|
1159
|
-
if (seen.has(tag))
|
|
1160
|
-
continue;
|
|
1161
|
-
seen.add(tag);
|
|
1162
|
-
out.push(tag);
|
|
1163
|
-
}
|
|
1164
|
-
if (out.length > MAX_FEEDBACK_TAGS) {
|
|
1165
|
-
throw new UsageError(`Too many tags: ${out.length}. Maximum is ${MAX_FEEDBACK_TAGS}.`, "INVALID_FLAG_VALUE");
|
|
1166
|
-
}
|
|
1167
|
-
return out;
|
|
1168
|
-
}
|
|
1169
|
-
const feedbackCommand = defineCommand({
|
|
1170
|
-
meta: {
|
|
1171
|
-
name: "feedback",
|
|
1172
|
-
description: "Record positive or negative feedback for any indexed stash asset.\n\n" +
|
|
1173
|
-
"Positive feedback boosts an asset's EMA utility score, making it rank higher\n" +
|
|
1174
|
-
"in future searches without requiring a full reindex.\n\n" +
|
|
1175
|
-
"Negative feedback records a negative signal in usage_events and state.db events.\n" +
|
|
1176
|
-
"It does NOT immediately lower the asset's ranking — the EMA utility score is\n" +
|
|
1177
|
-
"updated the next time `akm index` runs (incremental or full). Run `akm index`\n" +
|
|
1178
|
-
"after recording negative feedback to have it reflected in search results.",
|
|
1179
|
-
},
|
|
1180
|
-
args: {
|
|
1181
|
-
// Optional in citty so run() is invoked even when omitted; we re-validate
|
|
1182
|
-
// and throw a structured UsageError below so exit code is 2 (USAGE) rather
|
|
1183
|
-
// than citty's default 0 (help banner).
|
|
1184
|
-
ref: { type: "positional", description: "Asset ref (type:name)", required: false },
|
|
1185
|
-
positive: { type: "boolean", description: "Record positive feedback (boosts ranking immediately)", default: false },
|
|
1186
|
-
negative: {
|
|
1187
|
-
type: "boolean",
|
|
1188
|
-
description: "Record negative feedback (suppresses ranking after next `akm index`). " +
|
|
1189
|
-
"Reindexing is required for the signal to affect search results.",
|
|
1190
|
-
default: false,
|
|
1191
|
-
},
|
|
1192
|
-
reason: {
|
|
1193
|
-
type: "string",
|
|
1194
|
-
description: "Reason for the feedback (recommended for negative feedback, used by distillation)",
|
|
1195
|
-
},
|
|
1196
|
-
note: { type: "string", description: "Alias for --reason (backward-compatible, prefer --reason)" },
|
|
1197
|
-
tag: {
|
|
1198
|
-
type: "string",
|
|
1199
|
-
description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
|
|
1200
|
-
},
|
|
1201
|
-
},
|
|
1202
|
-
run({ args }) {
|
|
1203
|
-
return runWithJsonErrors(async () => {
|
|
1204
|
-
const ref = (args.ref ?? "").trim();
|
|
1205
|
-
if (!ref) {
|
|
1206
|
-
throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skill:deploy` and either --positive or --negative.");
|
|
1207
|
-
}
|
|
1208
|
-
parseAssetRef(ref);
|
|
1209
|
-
if (args.positive && args.negative) {
|
|
1210
|
-
throw new UsageError("Specify either --positive or --negative, not both.");
|
|
1211
|
-
}
|
|
1212
|
-
if (!args.positive && !args.negative) {
|
|
1213
|
-
throw new UsageError("Specify --positive or --negative.");
|
|
1214
|
-
}
|
|
1215
|
-
const signal = args.positive ? "positive" : "negative";
|
|
1216
|
-
const reason = args.reason ?? args.note;
|
|
1217
|
-
if (args.negative === true && !reason?.trim()) {
|
|
1218
|
-
const cfg = loadConfig();
|
|
1219
|
-
if (cfg.feedback?.requireReason === true) {
|
|
1220
|
-
throw new UsageError("Negative feedback requires --reason (feedback.requireReason is enabled).", "MISSING_REQUIRED_ARGUMENT");
|
|
1221
|
-
}
|
|
1222
|
-
else {
|
|
1223
|
-
warn("Warning: negative feedback without --reason provides less distillation signal.");
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
const rawTags = parseAllFlagValues("--tag");
|
|
1227
|
-
const validatedTags = validateFeedbackTags(rawTags);
|
|
1228
|
-
const metadataObj = {
|
|
1229
|
-
signal,
|
|
1230
|
-
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
1231
|
-
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
|
|
1232
|
-
};
|
|
1233
|
-
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
1234
|
-
// Auto-index when stale so the index is current before recording feedback.
|
|
1235
|
-
const sources = resolveSourceEntries();
|
|
1236
|
-
if (sources.length > 0) {
|
|
1237
|
-
await ensureIndex(sources[0].path);
|
|
1238
|
-
}
|
|
1239
|
-
const db = openExistingDatabase();
|
|
1240
|
-
try {
|
|
1241
|
-
const entryId = findEntryIdByRef(db, ref);
|
|
1242
|
-
if (entryId === undefined) {
|
|
1243
|
-
throw new UsageError(`Ref "${ref}" is not in the index. ` +
|
|
1244
|
-
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
|
|
1245
|
-
}
|
|
1246
|
-
// Persist the feedback signal into usage_events. For positive signals,
|
|
1247
|
-
// the EMA utility score is updated immediately on the next read path.
|
|
1248
|
-
// For negative signals, the score is adjusted the next time `akm index`
|
|
1249
|
-
// runs — the signal is durable in the DB but does NOT suppress ranking
|
|
1250
|
-
// in search results until after reindexing.
|
|
1251
|
-
insertUsageEvent(db, {
|
|
1252
|
-
event_type: "feedback",
|
|
1253
|
-
entry_ref: ref,
|
|
1254
|
-
entry_id: entryId,
|
|
1255
|
-
signal,
|
|
1256
|
-
metadata: metadataStr,
|
|
1257
|
-
});
|
|
1258
|
-
// Apply feedback-derived utility score adjustment immediately so that
|
|
1259
|
-
// positive/negative signals influence search ranking without requiring
|
|
1260
|
-
// a full reindex. We query the total accumulated feedback counts from
|
|
1261
|
-
// usage_events so the delta reflects the entire signal history.
|
|
1262
|
-
try {
|
|
1263
|
-
const counts = db
|
|
1264
|
-
.prepare(`SELECT
|
|
1265
|
-
SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
|
|
1266
|
-
SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
|
|
1267
|
-
FROM usage_events
|
|
1268
|
-
WHERE event_type = 'feedback' AND entry_id = ?`)
|
|
1269
|
-
.get(entryId);
|
|
1270
|
-
const pos = counts?.pos ?? 0;
|
|
1271
|
-
const neg = counts?.neg ?? 0;
|
|
1272
|
-
applyFeedbackToUtilityScore(db, entryId, pos, neg);
|
|
1273
|
-
}
|
|
1274
|
-
catch {
|
|
1275
|
-
// best-effort — feedback recording succeeds even if utility update fails
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
finally {
|
|
1279
|
-
closeDatabase(db);
|
|
1280
|
-
}
|
|
1281
|
-
appendEvent({
|
|
1282
|
-
eventType: "feedback",
|
|
1283
|
-
ref,
|
|
1284
|
-
metadata: metadataObj,
|
|
1285
|
-
});
|
|
1286
|
-
output("feedback", { ok: true, ref, signal, reason: reason?.trim() ?? null, tags: validatedTags });
|
|
1287
|
-
});
|
|
1288
|
-
},
|
|
1289
|
-
});
|
|
1290
1283
|
const historyCommand = defineCommand({
|
|
1291
1284
|
meta: {
|
|
1292
1285
|
name: "history",
|
|
@@ -1300,20 +1293,49 @@ const historyCommand = defineCommand({
|
|
|
1300
1293
|
args: {
|
|
1301
1294
|
ref: { type: "string", description: "Asset ref (type:name). Omit for stash-wide history." },
|
|
1302
1295
|
since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
|
|
1296
|
+
generator: {
|
|
1297
|
+
type: "string",
|
|
1298
|
+
description: 'Filter by event generator: "user" (default) or "improve" (akm improve operations).',
|
|
1299
|
+
},
|
|
1300
|
+
source: {
|
|
1301
|
+
type: "string",
|
|
1302
|
+
description: "DEPRECATED — use --generator. Removed in 0.9.0.",
|
|
1303
|
+
},
|
|
1303
1304
|
"include-proposals": {
|
|
1304
1305
|
type: "boolean",
|
|
1305
1306
|
description: "Also include proposal lifecycle events (promoted, rejected) from state.db events. " +
|
|
1306
1307
|
"Default: false (usage_events only).",
|
|
1307
1308
|
default: false,
|
|
1308
1309
|
},
|
|
1310
|
+
"accept-rate-by-source": {
|
|
1311
|
+
type: "boolean",
|
|
1312
|
+
description: "Compute accept-rate-per-source metrics from the proposal store and include them in the output (F-4 / #385). " +
|
|
1313
|
+
"Useful for measuring which generators (reflect, distill, …) produce the most accepted proposals.",
|
|
1314
|
+
default: false,
|
|
1315
|
+
},
|
|
1309
1316
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
1310
1317
|
},
|
|
1311
1318
|
run({ args }) {
|
|
1312
1319
|
return runWithJsonErrors(async () => {
|
|
1320
|
+
if (args.generator === undefined && args.source !== undefined) {
|
|
1321
|
+
emitFlagDeprecation("--source", "--generator", "history");
|
|
1322
|
+
}
|
|
1323
|
+
const generatorFlag = (args.generator ?? args.source);
|
|
1324
|
+
if (generatorFlag !== undefined && generatorFlag !== "user" && generatorFlag !== "improve") {
|
|
1325
|
+
// Name the flag the user actually typed so the diagnostic points at
|
|
1326
|
+
// their command line, not the canonical flag they may not have used.
|
|
1327
|
+
const usedFlag = args.generator !== undefined ? "--generator" : "--source";
|
|
1328
|
+
throw new UsageError(`Invalid ${usedFlag} value: "${generatorFlag}". Must be "user" or "improve".`, "INVALID_FLAG_VALUE");
|
|
1329
|
+
}
|
|
1330
|
+
const sources = resolveSourceEntries();
|
|
1331
|
+
const stashDir = sources[0]?.path;
|
|
1313
1332
|
const result = await akmHistory({
|
|
1314
1333
|
ref: args.ref,
|
|
1315
1334
|
since: args.since,
|
|
1335
|
+
source: generatorFlag,
|
|
1316
1336
|
includeProposals: args["include-proposals"],
|
|
1337
|
+
acceptRateBySource: args["accept-rate-by-source"],
|
|
1338
|
+
stashDir,
|
|
1317
1339
|
});
|
|
1318
1340
|
output("history", result);
|
|
1319
1341
|
});
|
|
@@ -1327,10 +1349,17 @@ const workflowStartCommand = defineCommand({
|
|
|
1327
1349
|
args: {
|
|
1328
1350
|
ref: { type: "positional", description: "Workflow ref (workflow:<name>)", required: true },
|
|
1329
1351
|
params: { type: "string", description: "Workflow parameters as a JSON object" },
|
|
1352
|
+
force: {
|
|
1353
|
+
type: "boolean",
|
|
1354
|
+
description: "Allow a parallel run when an active run already exists in this scope (#485)",
|
|
1355
|
+
default: false,
|
|
1356
|
+
},
|
|
1330
1357
|
},
|
|
1331
1358
|
async run({ args }) {
|
|
1332
1359
|
await runWithJsonErrors(async () => {
|
|
1333
|
-
const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params")
|
|
1360
|
+
const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params"), {
|
|
1361
|
+
force: args.force === true,
|
|
1362
|
+
});
|
|
1334
1363
|
output("workflow-start", result);
|
|
1335
1364
|
});
|
|
1336
1365
|
},
|
|
@@ -1343,11 +1372,14 @@ const workflowNextCommand = defineCommand({
|
|
|
1343
1372
|
args: {
|
|
1344
1373
|
target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
|
|
1345
1374
|
params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
|
|
1346
|
-
"dry-run": { type: "boolean", description: "Not supported — rejected with an error", default: false },
|
|
1347
1375
|
},
|
|
1348
1376
|
async run({ args }) {
|
|
1349
1377
|
await runWithJsonErrors(async () => {
|
|
1350
|
-
|
|
1378
|
+
// `--dry-run` is intentionally NOT a declared arg (so it stays out of
|
|
1379
|
+
// --help). The guard reads it straight from process.argv so existing
|
|
1380
|
+
// callers still get a clear, actionable error instead of a generic
|
|
1381
|
+
// "unknown flag" from citty.
|
|
1382
|
+
if (hasBooleanFlag(process.argv, "--dry-run")) {
|
|
1351
1383
|
throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
|
|
1352
1384
|
}
|
|
1353
1385
|
const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
|
|
@@ -1605,234 +1637,6 @@ const workflowCommand = defineCommand({
|
|
|
1605
1637
|
});
|
|
1606
1638
|
},
|
|
1607
1639
|
});
|
|
1608
|
-
const rememberCommand = defineCommand({
|
|
1609
|
-
meta: {
|
|
1610
|
-
name: "remember",
|
|
1611
|
-
description: "Record a memory in the default stash",
|
|
1612
|
-
},
|
|
1613
|
-
args: {
|
|
1614
|
-
content: {
|
|
1615
|
-
type: "positional",
|
|
1616
|
-
description: "Memory content. Omit to read markdown from stdin.",
|
|
1617
|
-
required: false,
|
|
1618
|
-
},
|
|
1619
|
-
name: {
|
|
1620
|
-
type: "string",
|
|
1621
|
-
description: "Memory name (defaults to a slug from the content)",
|
|
1622
|
-
},
|
|
1623
|
-
force: {
|
|
1624
|
-
type: "boolean",
|
|
1625
|
-
description: "Overwrite an existing memory with the same name",
|
|
1626
|
-
default: false,
|
|
1627
|
-
},
|
|
1628
|
-
description: {
|
|
1629
|
-
type: "string",
|
|
1630
|
-
description: "Short description written to frontmatter (persisted as the memory's description field)",
|
|
1631
|
-
},
|
|
1632
|
-
tag: {
|
|
1633
|
-
type: "string",
|
|
1634
|
-
description: "Tag to add to the memory (repeatable: --tag foo --tag bar)",
|
|
1635
|
-
},
|
|
1636
|
-
expires: {
|
|
1637
|
-
type: "string",
|
|
1638
|
-
description: "Expiry duration shorthand (e.g. 30d, 12h, 6m). Resolved to an ISO date.",
|
|
1639
|
-
},
|
|
1640
|
-
source: {
|
|
1641
|
-
type: "string",
|
|
1642
|
-
description: "Source reference (URL, asset ref, file path, or any free-form string)",
|
|
1643
|
-
},
|
|
1644
|
-
auto: {
|
|
1645
|
-
type: "boolean",
|
|
1646
|
-
description: "Apply heuristic tagging (code, subjective, source, observed_at) from the body",
|
|
1647
|
-
default: false,
|
|
1648
|
-
},
|
|
1649
|
-
enrich: {
|
|
1650
|
-
type: "boolean",
|
|
1651
|
-
description: "Call the configured LLM to propose tags and description (requires LLM config)",
|
|
1652
|
-
default: false,
|
|
1653
|
-
},
|
|
1654
|
-
target: {
|
|
1655
|
-
type: "string",
|
|
1656
|
-
description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working stash.",
|
|
1657
|
-
},
|
|
1658
|
-
user: {
|
|
1659
|
-
type: "string",
|
|
1660
|
-
description: "Scope this memory to a user id (persisted as `scope_user` frontmatter)",
|
|
1661
|
-
},
|
|
1662
|
-
agent: {
|
|
1663
|
-
type: "string",
|
|
1664
|
-
description: "Scope this memory to an agent id (persisted as `scope_agent` frontmatter)",
|
|
1665
|
-
},
|
|
1666
|
-
run: {
|
|
1667
|
-
type: "string",
|
|
1668
|
-
description: "Scope this memory to a run id (persisted as `scope_run` frontmatter)",
|
|
1669
|
-
},
|
|
1670
|
-
channel: {
|
|
1671
|
-
type: "string",
|
|
1672
|
-
description: "Scope this memory to a channel name (persisted as `scope_channel` frontmatter)",
|
|
1673
|
-
},
|
|
1674
|
-
showSimilar: {
|
|
1675
|
-
type: "boolean",
|
|
1676
|
-
description: "Return top-3 similar existing memories in output (opt-in)",
|
|
1677
|
-
},
|
|
1678
|
-
},
|
|
1679
|
-
async run({ args }) {
|
|
1680
|
-
return runWithJsonErrors(async () => {
|
|
1681
|
-
const body = readMemoryContent(resolveRememberContentArg(args.content));
|
|
1682
|
-
// Determine if the user has requested any structured metadata mode.
|
|
1683
|
-
// Collect all --tag occurrences directly from process.argv because citty
|
|
1684
|
-
// only exposes the last value for repeated string flags.
|
|
1685
|
-
const rawTags = parseAllFlagValues("--tag");
|
|
1686
|
-
// Collect scope flags. Scope alone counts as structured metadata so we
|
|
1687
|
-
// emit frontmatter, but it does NOT trigger the "tags required" check —
|
|
1688
|
-
// memory + scope (no tags) is a valid combination for multi-tenant use.
|
|
1689
|
-
const scopeFields = {};
|
|
1690
|
-
if (typeof args.user === "string" && args.user.trim())
|
|
1691
|
-
scopeFields.user = args.user.trim();
|
|
1692
|
-
if (typeof args.agent === "string" && args.agent.trim())
|
|
1693
|
-
scopeFields.agent = args.agent.trim();
|
|
1694
|
-
if (typeof args.run === "string" && args.run.trim())
|
|
1695
|
-
scopeFields.run = args.run.trim();
|
|
1696
|
-
if (typeof args.channel === "string" && args.channel.trim())
|
|
1697
|
-
scopeFields.channel = args.channel.trim();
|
|
1698
|
-
const hasScope = Object.keys(scopeFields).length > 0;
|
|
1699
|
-
const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description;
|
|
1700
|
-
const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto;
|
|
1701
|
-
if (!hasStructuredArgs) {
|
|
1702
|
-
const result = await writeMarkdownAsset({
|
|
1703
|
-
type: "memory",
|
|
1704
|
-
content: body,
|
|
1705
|
-
name: args.name,
|
|
1706
|
-
fallbackPrefix: "memory",
|
|
1707
|
-
force: args.force,
|
|
1708
|
-
target: args.target,
|
|
1709
|
-
});
|
|
1710
|
-
appendEvent({
|
|
1711
|
-
eventType: "remember",
|
|
1712
|
-
ref: result.ref,
|
|
1713
|
-
metadata: { path: result.path, force: args.force === true },
|
|
1714
|
-
});
|
|
1715
|
-
if (args.showSimilar) {
|
|
1716
|
-
const similar = await fetchSimilarMemories(body.slice(0, 500), result.ref);
|
|
1717
|
-
output("remember", { ok: true, ...result, similar });
|
|
1718
|
-
}
|
|
1719
|
-
else {
|
|
1720
|
-
output("remember", { ok: true, ...result });
|
|
1721
|
-
}
|
|
1722
|
-
return;
|
|
1723
|
-
}
|
|
1724
|
-
// ── Accumulate metadata from all three modes ──────────────────────────
|
|
1725
|
-
// Start with CLI args (Mode 1: always)
|
|
1726
|
-
const tags = [...rawTags];
|
|
1727
|
-
// --description is persisted as-is; LLM enrichment may fill it if absent.
|
|
1728
|
-
let description = args.description || undefined;
|
|
1729
|
-
let source = args.source;
|
|
1730
|
-
let observed_at;
|
|
1731
|
-
let expires;
|
|
1732
|
-
let subjective;
|
|
1733
|
-
// Resolve --expires to an ISO date string
|
|
1734
|
-
if (args.expires) {
|
|
1735
|
-
const durationMs = parseDuration(args.expires);
|
|
1736
|
-
const expiresDate = new Date(Date.now() + durationMs);
|
|
1737
|
-
expires = expiresDate.toISOString().slice(0, 10);
|
|
1738
|
-
}
|
|
1739
|
-
// Mode 2: --auto heuristics
|
|
1740
|
-
if (args.auto) {
|
|
1741
|
-
const auto = runAutoHeuristics(body);
|
|
1742
|
-
for (const t of auto.tags) {
|
|
1743
|
-
if (!tags.includes(t))
|
|
1744
|
-
tags.push(t);
|
|
1745
|
-
}
|
|
1746
|
-
if (!source && auto.source)
|
|
1747
|
-
source = auto.source;
|
|
1748
|
-
if (!observed_at && auto.observed_at)
|
|
1749
|
-
observed_at = auto.observed_at;
|
|
1750
|
-
if (!subjective && auto.subjective)
|
|
1751
|
-
subjective = auto.subjective;
|
|
1752
|
-
}
|
|
1753
|
-
// Mode 3: --enrich LLM (fail-soft)
|
|
1754
|
-
if (args.enrich) {
|
|
1755
|
-
const enriched = await runLlmEnrich(body);
|
|
1756
|
-
for (const t of enriched.tags) {
|
|
1757
|
-
if (!tags.includes(t))
|
|
1758
|
-
tags.push(t);
|
|
1759
|
-
}
|
|
1760
|
-
if (!description && enriched.description)
|
|
1761
|
-
description = enriched.description;
|
|
1762
|
-
if (!observed_at && enriched.observed_at)
|
|
1763
|
-
observed_at = enriched.observed_at;
|
|
1764
|
-
}
|
|
1765
|
-
// ── Required-field check (before any write) ───────────────────────────
|
|
1766
|
-
// Tags remain required when the user explicitly asked for tag-bearing
|
|
1767
|
-
// metadata (--tag / --enrich / --description / --source / --expires).
|
|
1768
|
-
// `--auto` alone is allowed even when its heuristics derive zero tags.
|
|
1769
|
-
// Scope-only writes (`akm remember "..." --user u1`) also skip this
|
|
1770
|
-
// check — scope is independent metadata and a memory with only scope is
|
|
1771
|
-
// valid.
|
|
1772
|
-
const missing = [];
|
|
1773
|
-
if (hasTagRequiringArgs && tags.length === 0)
|
|
1774
|
-
missing.push("tags");
|
|
1775
|
-
if (missing.length > 0) {
|
|
1776
|
-
throw new UsageError(`Memory is missing required frontmatter field(s): ${missing.join(", ")}. ` +
|
|
1777
|
-
"Provide them via --tag <value>, --auto (heuristics), or --enrich (LLM).");
|
|
1778
|
-
}
|
|
1779
|
-
// ── Build frontmatter and write ───────────────────────────────────────
|
|
1780
|
-
const frontmatterBlock = buildMemoryFrontmatter({
|
|
1781
|
-
description,
|
|
1782
|
-
tags,
|
|
1783
|
-
source,
|
|
1784
|
-
observed_at,
|
|
1785
|
-
expires,
|
|
1786
|
-
subjective,
|
|
1787
|
-
...(hasScope ? { scope: scopeFields } : {}),
|
|
1788
|
-
});
|
|
1789
|
-
const contentWithFrontmatter = `${frontmatterBlock}\n${body}`;
|
|
1790
|
-
const result = await writeMarkdownAsset({
|
|
1791
|
-
type: "memory",
|
|
1792
|
-
content: contentWithFrontmatter,
|
|
1793
|
-
name: args.name,
|
|
1794
|
-
fallbackPrefix: "memory",
|
|
1795
|
-
force: args.force,
|
|
1796
|
-
target: args.target,
|
|
1797
|
-
});
|
|
1798
|
-
appendEvent({
|
|
1799
|
-
eventType: "remember",
|
|
1800
|
-
ref: result.ref,
|
|
1801
|
-
metadata: {
|
|
1802
|
-
path: result.path,
|
|
1803
|
-
force: args.force === true,
|
|
1804
|
-
tagCount: tags.length,
|
|
1805
|
-
enriched: args.enrich === true,
|
|
1806
|
-
auto: args.auto === true,
|
|
1807
|
-
...(hasScope ? { scope: scopeFields } : {}),
|
|
1808
|
-
},
|
|
1809
|
-
});
|
|
1810
|
-
if (args.showSimilar) {
|
|
1811
|
-
const similar = await fetchSimilarMemories((body ?? args.content ?? "").slice(0, 500), result.ref);
|
|
1812
|
-
output("remember", { ok: true, ...result, similar });
|
|
1813
|
-
}
|
|
1814
|
-
else {
|
|
1815
|
-
output("remember", { ok: true, ...result });
|
|
1816
|
-
}
|
|
1817
|
-
});
|
|
1818
|
-
},
|
|
1819
|
-
});
|
|
1820
|
-
/**
|
|
1821
|
-
* Best-effort top-3 similar memory search for `--show-similar`.
|
|
1822
|
-
* Scoped to memory: type; excludes the just-written ref.
|
|
1823
|
-
*/
|
|
1824
|
-
async function fetchSimilarMemories(query, excludeRef) {
|
|
1825
|
-
try {
|
|
1826
|
-
const result = await akmSearch({ query, type: "memory", limit: 4 });
|
|
1827
|
-
return (result.hits ?? [])
|
|
1828
|
-
.filter((h) => "ref" in h && h.ref !== excludeRef)
|
|
1829
|
-
.slice(0, 3)
|
|
1830
|
-
.map((h) => ({ ref: h.ref, ...(h.name ? { title: h.name } : {}) }));
|
|
1831
|
-
}
|
|
1832
|
-
catch {
|
|
1833
|
-
return [];
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
1640
|
const importKnowledgeCommand = defineCommand({
|
|
1837
1641
|
meta: {
|
|
1838
1642
|
name: "import",
|
|
@@ -1887,15 +1691,18 @@ const hintsCommand = defineCommand({
|
|
|
1887
1691
|
args: {
|
|
1888
1692
|
detail: {
|
|
1889
1693
|
type: "string",
|
|
1890
|
-
description: "Hints detail level
|
|
1694
|
+
description: "Hints detail level (brief|normal|full). `brief` prints the short guide; `normal`/`full` print the complete guide.",
|
|
1891
1695
|
default: "normal",
|
|
1892
1696
|
},
|
|
1893
1697
|
},
|
|
1894
1698
|
run({ args }) {
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1699
|
+
return runWithJsonErrors(() => {
|
|
1700
|
+
// Let the global parser validate the value so an invalid `--detail`
|
|
1701
|
+
// returns the standard JSON error envelope (exit 2) rather than a raw
|
|
1702
|
+
// stack trace + exit 1. `brief` → short doc; `normal`/`full` → full doc.
|
|
1703
|
+
const detail = parseDetailLevel(args.detail) ?? "normal";
|
|
1704
|
+
process.stdout.write(loadHints(detail === "brief" ? "brief" : "full"));
|
|
1705
|
+
});
|
|
1899
1706
|
},
|
|
1900
1707
|
});
|
|
1901
1708
|
const helpCommand = defineCommand({
|
|
@@ -1997,38 +1804,44 @@ function toggleComponent(targetRaw, enabled) {
|
|
|
1997
1804
|
// normalizeToggleTarget throws for any unsupported target; this is unreachable.
|
|
1998
1805
|
throw new UsageError(`Unsupported target "${targetRaw}". Supported targets: skills.sh`);
|
|
1999
1806
|
}
|
|
1807
|
+
// Deprecated top-level aliases (removed 0.9.0) — delegate to `config enable|disable`.
|
|
2000
1808
|
const enableCommand = defineCommand({
|
|
2001
|
-
meta: { name: "enable", description: "
|
|
1809
|
+
meta: { name: "enable", description: "DEPRECATED — use `akm config enable`. Removed in 0.9.0." },
|
|
2002
1810
|
args: {
|
|
2003
1811
|
target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
|
|
2004
1812
|
},
|
|
2005
1813
|
run({ args }) {
|
|
2006
1814
|
return runWithJsonErrors(() => {
|
|
1815
|
+
emitCommandDeprecation("enable", "config enable");
|
|
2007
1816
|
const result = toggleComponent(args.target, true);
|
|
2008
1817
|
output("enable", result);
|
|
2009
1818
|
});
|
|
2010
1819
|
},
|
|
2011
1820
|
});
|
|
2012
1821
|
const disableCommand = defineCommand({
|
|
2013
|
-
meta: { name: "disable", description: "
|
|
1822
|
+
meta: { name: "disable", description: "DEPRECATED — use `akm config disable`. Removed in 0.9.0." },
|
|
2014
1823
|
args: {
|
|
2015
1824
|
target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
|
|
2016
1825
|
},
|
|
2017
1826
|
run({ args }) {
|
|
2018
1827
|
return runWithJsonErrors(() => {
|
|
1828
|
+
emitCommandDeprecation("disable", "config disable");
|
|
2019
1829
|
const result = toggleComponent(args.target, false);
|
|
2020
1830
|
output("disable", result);
|
|
2021
1831
|
});
|
|
2022
1832
|
},
|
|
2023
1833
|
});
|
|
2024
|
-
// ──
|
|
1834
|
+
// ── env ───────────────────────────────────────────────────────────────────
|
|
2025
1835
|
//
|
|
2026
|
-
// `akm
|
|
2027
|
-
//
|
|
2028
|
-
|
|
2029
|
-
|
|
1836
|
+
// `akm env` manages whole `.env` files under each stash's env/ directory.
|
|
1837
|
+
// Values are NEVER written to stdout or structured output — only key NAMES and
|
|
1838
|
+
// start-of-line comments are surfaced. akm does not manage individual entries;
|
|
1839
|
+
// you edit the `.env` file yourself and akm loads it. Replaces the deprecated
|
|
1840
|
+
// `vault` type (see the shim further below; removed in 0.9.0).
|
|
1841
|
+
function parseEnvRef(ref) {
|
|
1842
|
+
return parseAssetRef(ref.includes(":") ? ref : `env:${ref}`);
|
|
2030
1843
|
}
|
|
2031
|
-
function
|
|
1844
|
+
function findEnvSource(origin) {
|
|
2032
1845
|
const sources = resolveSourceEntries(undefined, loadConfig());
|
|
2033
1846
|
if (sources.length === 0) {
|
|
2034
1847
|
throw new UsageError("No stashes configured. Run `akm init` to create your working stash.");
|
|
@@ -2041,30 +1854,59 @@ function findVaultSource(origin) {
|
|
|
2041
1854
|
}
|
|
2042
1855
|
return named;
|
|
2043
1856
|
}
|
|
2044
|
-
function
|
|
2045
|
-
return source?.registryId ? `${source.registryId}//
|
|
1857
|
+
function makeEnvRef(name, source) {
|
|
1858
|
+
return source?.registryId ? `${source.registryId}//env:${name}` : `env:${name}`;
|
|
2046
1859
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
1860
|
+
/**
|
|
1861
|
+
* Resolve an env ref to an absolute `.env` path. Accepts `env:`, `environment:`
|
|
1862
|
+
* (alias), and `vault:` (deprecated) refs as well as bare names. Prefers the
|
|
1863
|
+
* `env/` directory; falls back to the legacy `vaults/` directory when the env
|
|
1864
|
+
* file is absent there (handles an upgraded-but-not-yet-migrated stash). When
|
|
1865
|
+
* neither exists the env path is returned (so `create` writes under `env/`).
|
|
1866
|
+
*/
|
|
1867
|
+
function resolveEnvPath(ref) {
|
|
1868
|
+
const parsed = parseEnvRef(ref);
|
|
1869
|
+
if (parsed.type !== "env" && parsed.type !== "vault") {
|
|
1870
|
+
throw new UsageError(`Expected an env ref (env:<name>); got "${ref}".`);
|
|
1871
|
+
}
|
|
1872
|
+
const source = findEnvSource(parsed.origin);
|
|
1873
|
+
const envRoot = path.join(source.path, "env");
|
|
1874
|
+
const envPath = resolveAssetPathFromName("env", envRoot, parsed.name);
|
|
1875
|
+
// Defense-in-depth: ensure the resolved path stays inside the env directory.
|
|
1876
|
+
// validateName already rejects traversal patterns like "../../foo", but an
|
|
1877
|
+
// absolute-path override or symlink-based attack could still escape without
|
|
1878
|
+
// this second check.
|
|
1879
|
+
if (!isWithin(envPath, envRoot)) {
|
|
1880
|
+
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
|
|
1881
|
+
}
|
|
1882
|
+
const vaultRoot = path.join(source.path, "vaults");
|
|
1883
|
+
const vaultPath = resolveAssetPathFromName("vault", vaultRoot, parsed.name);
|
|
1884
|
+
if (!isWithin(vaultPath, vaultRoot)) {
|
|
1885
|
+
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
|
|
2051
1886
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
1887
|
+
// Prefer env/; fall back to the frozen vaults/ copy only when the env file
|
|
1888
|
+
// is absent and the legacy vault file is present.
|
|
1889
|
+
if (!fs.existsSync(envPath) && fs.existsSync(vaultPath)) {
|
|
1890
|
+
return { name: parsed.name, absPath: vaultPath, source, parsedRef: parsed, dir: "vaults" };
|
|
1891
|
+
}
|
|
1892
|
+
return { name: parsed.name, absPath: envPath, source, parsedRef: parsed, dir: "env" };
|
|
2056
1893
|
}
|
|
2057
1894
|
/**
|
|
2058
|
-
* Walk
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
* `
|
|
1895
|
+
* Walk each stash's env files and return one entry per `.env` file, using the
|
|
1896
|
+
* env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
|
|
1897
|
+
* `env:team/prod`, `env/team/.env` → `env:team/default`). When a stash has not
|
|
1898
|
+
* yet migrated (no `env/` dir) the legacy `vaults/` dir is listed instead, so
|
|
1899
|
+
* `env list` stays continuous across the upgrade.
|
|
2062
1900
|
*/
|
|
2063
|
-
function
|
|
1901
|
+
function listEnvsRecursive(listKeysFn) {
|
|
2064
1902
|
const result = [];
|
|
2065
1903
|
for (const source of resolveSourceEntries(undefined, loadConfig())) {
|
|
2066
|
-
const
|
|
2067
|
-
|
|
1904
|
+
const envDir = path.join(source.path, "env");
|
|
1905
|
+
const legacyDir = path.join(source.path, "vaults");
|
|
1906
|
+
// Prefer env/; only fall back to the frozen vaults/ copy when env/ is absent.
|
|
1907
|
+
const scanType = fs.existsSync(envDir) ? "env" : "vault";
|
|
1908
|
+
const root = scanType === "env" ? envDir : legacyDir;
|
|
1909
|
+
if (!fs.existsSync(root))
|
|
2068
1910
|
continue;
|
|
2069
1911
|
const walk = (dir) => {
|
|
2070
1912
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -2077,227 +1919,730 @@ function listVaultsRecursive(listKeysFn) {
|
|
|
2077
1919
|
continue;
|
|
2078
1920
|
if (entry.name !== ".env" && !entry.name.endsWith(".env"))
|
|
2079
1921
|
continue;
|
|
2080
|
-
const canonical = deriveCanonicalAssetName(
|
|
1922
|
+
const canonical = deriveCanonicalAssetName(scanType, root, full);
|
|
2081
1923
|
if (!canonical)
|
|
2082
1924
|
continue;
|
|
2083
|
-
// Skip sensitive
|
|
1925
|
+
// Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
|
|
2084
1926
|
const markerPath = full.replace(/\.env$/, ".sensitive");
|
|
2085
1927
|
if (fs.existsSync(markerPath))
|
|
2086
1928
|
continue;
|
|
2087
1929
|
const { keys } = listKeysFn(full);
|
|
2088
|
-
result.push({ ref:
|
|
1930
|
+
result.push({ ref: makeEnvRef(canonical, source), path: full, keys });
|
|
2089
1931
|
}
|
|
2090
1932
|
};
|
|
2091
|
-
walk(
|
|
1933
|
+
walk(root);
|
|
2092
1934
|
}
|
|
2093
1935
|
return result;
|
|
2094
1936
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
if (fs.existsSync(full.absPath)) {
|
|
2098
|
-
return { ref: makeVaultRef(full.name, full.source) };
|
|
2099
|
-
}
|
|
2100
|
-
const slashIndex = target.lastIndexOf("/");
|
|
2101
|
-
if (slashIndex <= 0) {
|
|
2102
|
-
throw new NotFoundError(`Vault not found: ${target.includes(":") ? target : `vault:${target}`}`);
|
|
2103
|
-
}
|
|
2104
|
-
const refPart = target.slice(0, slashIndex);
|
|
2105
|
-
const key = target.slice(slashIndex + 1).trim();
|
|
2106
|
-
if (!key) {
|
|
2107
|
-
throw new UsageError("Expected vault run target in the form <ref> or <ref/KEY>.");
|
|
2108
|
-
}
|
|
2109
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
2110
|
-
throw new UsageError(`"${key}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
|
|
2111
|
-
}
|
|
2112
|
-
const resolved = resolveVaultPath(refPart);
|
|
2113
|
-
if (!fs.existsSync(resolved.absPath)) {
|
|
2114
|
-
throw new NotFoundError(`Vault not found: ${makeVaultRef(resolved.name, resolved.source)}`);
|
|
2115
|
-
}
|
|
2116
|
-
return { ref: makeVaultRef(resolved.name, resolved.source), key };
|
|
2117
|
-
}
|
|
2118
|
-
const vaultListCommand = defineCommand({
|
|
2119
|
-
meta: { name: "list", description: "List all vaults across all stashes with their available key names (no values)" },
|
|
1937
|
+
const envListCommand = defineCommand({
|
|
1938
|
+
meta: { name: "list", description: "List all env files across all stashes with their key names (no values)" },
|
|
2120
1939
|
run() {
|
|
2121
1940
|
return runWithJsonErrors(async () => {
|
|
2122
|
-
const { listKeys } = await import("./commands/
|
|
2123
|
-
|
|
2124
|
-
output("vault-list", { vaults });
|
|
1941
|
+
const { listKeys } = await import("./commands/env.js");
|
|
1942
|
+
output("env-list", { envs: listEnvsRecursive(listKeys) });
|
|
2125
1943
|
});
|
|
2126
1944
|
},
|
|
2127
1945
|
});
|
|
2128
|
-
const
|
|
2129
|
-
meta: {
|
|
1946
|
+
const envCreateCommand = defineCommand({
|
|
1947
|
+
meta: {
|
|
1948
|
+
name: "create",
|
|
1949
|
+
description: "Create an env file (empty by default; seed an existing `.env` with --from-file or --from-stdin). No-op if it already exists and no source is given.",
|
|
1950
|
+
},
|
|
2130
1951
|
args: {
|
|
2131
|
-
name: { type: "positional", description: "
|
|
1952
|
+
name: { type: "positional", description: "Env name (e.g. prod) — file becomes <name>.env", required: true },
|
|
1953
|
+
"from-file": { type: "string", description: "Seed the env file from an existing .env at this path" },
|
|
1954
|
+
"from-stdin": { type: "boolean", description: "Seed the env file from stdin", default: false },
|
|
2132
1955
|
sensitive: {
|
|
2133
1956
|
type: "boolean",
|
|
2134
|
-
description: "Exclude this
|
|
1957
|
+
description: "Exclude this env file from env list output and the search index",
|
|
2135
1958
|
default: false,
|
|
2136
1959
|
},
|
|
2137
1960
|
},
|
|
2138
1961
|
run({ args }) {
|
|
2139
1962
|
return runWithJsonErrors(async () => {
|
|
2140
|
-
const {
|
|
2141
|
-
|
|
2142
|
-
|
|
1963
|
+
const { createEnv, writeEnv } = await import("./commands/env.js");
|
|
1964
|
+
// `create` always targets env/, never the frozen vaults/ copy.
|
|
1965
|
+
const parsed = parseEnvRef(args.name);
|
|
1966
|
+
const source = findEnvSource(parsed.origin);
|
|
1967
|
+
const envRoot = path.join(source.path, "env");
|
|
1968
|
+
const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
|
|
1969
|
+
if (!isWithin(absPath, envRoot)) {
|
|
1970
|
+
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
|
|
1971
|
+
}
|
|
1972
|
+
const fromFile = getHyphenatedArg(args, "from-file");
|
|
1973
|
+
const fromStdin = getHyphenatedArg(args, "from-stdin") === true;
|
|
1974
|
+
if (fromFile !== undefined && fromStdin) {
|
|
1975
|
+
throw new UsageError("Pass only one of --from-file or --from-stdin.", "INVALID_FLAG_VALUE");
|
|
1976
|
+
}
|
|
1977
|
+
if (fromFile !== undefined || fromStdin) {
|
|
1978
|
+
// Ingest path: never silently clobber an existing env file.
|
|
1979
|
+
if (fs.existsSync(absPath)) {
|
|
1980
|
+
throw new UsageError(`Env "${makeEnvRef(parsed.name, source)}" already exists. Remove it first (\`akm env remove\`) or edit the file directly.`, "RESOURCE_ALREADY_EXISTS");
|
|
1981
|
+
}
|
|
1982
|
+
let content;
|
|
1983
|
+
if (fromFile !== undefined) {
|
|
1984
|
+
if (!fs.existsSync(fromFile)) {
|
|
1985
|
+
throw new NotFoundError(`Source file not found: ${fromFile}`, "FILE_NOT_FOUND");
|
|
1986
|
+
}
|
|
1987
|
+
content = fs.readFileSync(fromFile, "utf8");
|
|
1988
|
+
}
|
|
1989
|
+
else {
|
|
1990
|
+
const MAX_ENV_BYTES = 1024 * 1024; // 1 MB
|
|
1991
|
+
let total = 0;
|
|
1992
|
+
const chunks = [];
|
|
1993
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
1994
|
+
total += chunk.byteLength;
|
|
1995
|
+
if (total > MAX_ENV_BYTES) {
|
|
1996
|
+
throw new UsageError("Env file exceeds 1 MB limit.", "INVALID_FLAG_VALUE");
|
|
1997
|
+
}
|
|
1998
|
+
chunks.push(chunk);
|
|
1999
|
+
}
|
|
2000
|
+
content = Buffer.concat(chunks).toString("utf8");
|
|
2001
|
+
}
|
|
2002
|
+
writeEnv(absPath, content);
|
|
2003
|
+
}
|
|
2004
|
+
else {
|
|
2005
|
+
createEnv(absPath);
|
|
2006
|
+
}
|
|
2143
2007
|
if (args.sensitive) {
|
|
2144
2008
|
const markerPath = absPath.replace(/\.env$/, ".sensitive");
|
|
2145
2009
|
if (!fs.existsSync(markerPath)) {
|
|
2146
2010
|
fs.writeFileSync(markerPath, "", { mode: 0o600 });
|
|
2147
2011
|
}
|
|
2148
2012
|
}
|
|
2149
|
-
output("
|
|
2013
|
+
output("env-create", { ref: makeEnvRef(parsed.name, source) });
|
|
2150
2014
|
});
|
|
2151
2015
|
},
|
|
2152
2016
|
});
|
|
2153
|
-
const
|
|
2017
|
+
const envPathCommand = defineCommand({
|
|
2154
2018
|
meta: {
|
|
2155
|
-
name: "
|
|
2156
|
-
description:
|
|
2019
|
+
name: "path",
|
|
2020
|
+
description: "Print the absolute env file path (Docker `_FILE` convention / `--env-file`). To inject values, use `akm env run <ref> -- <cmd>` — do NOT `source` the raw file.",
|
|
2157
2021
|
},
|
|
2158
2022
|
args: {
|
|
2159
|
-
ref: { type: "positional", description: "
|
|
2160
|
-
|
|
2161
|
-
comment: { type: "string", description: "Optional comment written above the key line", required: false },
|
|
2162
|
-
"from-env": {
|
|
2163
|
-
type: "string",
|
|
2164
|
-
description: "Read value from the named environment variable instead of stdin",
|
|
2165
|
-
},
|
|
2023
|
+
ref: { type: "positional", description: "Env ref", required: true },
|
|
2024
|
+
quiet: { type: "boolean", alias: "q", description: "Suppress the unsafe-source warning", default: false },
|
|
2166
2025
|
},
|
|
2167
2026
|
run({ args }) {
|
|
2168
2027
|
return runWithJsonErrors(async () => {
|
|
2169
|
-
const {
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
let realValue;
|
|
2173
|
-
if (fromEnv !== undefined) {
|
|
2174
|
-
const envVal = process.env[fromEnv];
|
|
2175
|
-
if (envVal === undefined) {
|
|
2176
|
-
throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
|
|
2177
|
-
}
|
|
2178
|
-
realValue = envVal;
|
|
2028
|
+
const { name, absPath, source } = resolveEnvPath(args.ref);
|
|
2029
|
+
if (!fs.existsSync(absPath)) {
|
|
2030
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
|
|
2179
2031
|
}
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2032
|
+
// The raw `.env` may contain `X=$(cmd)`, which executes if `source`d.
|
|
2033
|
+
// Warning goes to stderr (never contaminates the path on stdout) and is
|
|
2034
|
+
// suppressed with --quiet for the legitimate `_FILE` / `--env-file` use.
|
|
2035
|
+
if (args.quiet !== true) {
|
|
2036
|
+
process.stderr.write(`warning: this is the raw file path. Do NOT \`source\` it (shell substitutions in the file would execute).\n` +
|
|
2037
|
+
` To inject values run: akm env run ${args.ref} -- <command>\n`);
|
|
2038
|
+
}
|
|
2039
|
+
process.stdout.write(`${absPath}\n`);
|
|
2040
|
+
});
|
|
2041
|
+
},
|
|
2042
|
+
});
|
|
2043
|
+
const envExportCommand = defineCommand({
|
|
2044
|
+
meta: {
|
|
2045
|
+
name: "export",
|
|
2046
|
+
description: "Write safe `export KEY='value'` lines to a file (mode 0600) for `source`-ing — requires --out <path>. Values are re-serialised single-quoted so a raw `.env` cannot execute on load, and are NEVER printed to stdout. To use values directly, prefer `akm env run <ref> -- <command>`.",
|
|
2047
|
+
},
|
|
2048
|
+
args: {
|
|
2049
|
+
ref: { type: "positional", description: "Env ref", required: true },
|
|
2050
|
+
out: { type: "string", alias: "o", description: "Destination file (required). Written at mode 0600." },
|
|
2051
|
+
},
|
|
2052
|
+
run({ args }) {
|
|
2053
|
+
return runWithJsonErrors(async () => {
|
|
2054
|
+
const outPath = getHyphenatedArg(args, "out");
|
|
2055
|
+
if (!outPath) {
|
|
2056
|
+
throw new UsageError("`akm env export` writes to a file — pass --out <path>.\n" +
|
|
2057
|
+
" To use values directly, run `akm env run <ref> -- <command>` (or `-- $SHELL` for an interactive\n" +
|
|
2058
|
+
" session). export never prints values to stdout, to avoid leaking them into a captured context.", "MISSING_REQUIRED_ARGUMENT");
|
|
2059
|
+
}
|
|
2060
|
+
const { name, absPath, source } = resolveEnvPath(args.ref);
|
|
2061
|
+
if (!fs.existsSync(absPath)) {
|
|
2062
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
|
|
2063
|
+
}
|
|
2064
|
+
const { buildShellExportScript } = await import("./commands/env.js");
|
|
2065
|
+
const resolvedOut = path.resolve(outPath);
|
|
2066
|
+
writeFileAtomic(resolvedOut, buildShellExportScript(absPath), 0o600);
|
|
2067
|
+
output("env-export", { ref: makeEnvRef(name, source), out: resolvedOut });
|
|
2068
|
+
});
|
|
2069
|
+
},
|
|
2070
|
+
});
|
|
2071
|
+
/**
|
|
2072
|
+
* Shared implementation for `env run` (and the deprecated `vault run` shim).
|
|
2073
|
+
* Injects an entire env file's values into the child process env — never via a
|
|
2074
|
+
* shell — after scanning the injected keys for process-hijacking variables.
|
|
2075
|
+
*/
|
|
2076
|
+
async function runEnvInjected(target, opts) {
|
|
2077
|
+
const dashIndex = process.argv.indexOf("--");
|
|
2078
|
+
if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
|
|
2079
|
+
throw new UsageError("Missing command. Usage: akm env run <ref> -- <command>");
|
|
2080
|
+
}
|
|
2081
|
+
const command = process.argv.slice(dashIndex + 1);
|
|
2082
|
+
const { name, absPath, source } = resolveEnvPath(target);
|
|
2083
|
+
if (!fs.existsSync(absPath)) {
|
|
2084
|
+
// Help users who reach for the removed single-key `ref/KEY` form.
|
|
2085
|
+
const slash = target.lastIndexOf("/");
|
|
2086
|
+
if (slash > 0) {
|
|
2087
|
+
const maybeKey = target.slice(slash + 1);
|
|
2088
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(maybeKey)) {
|
|
2089
|
+
let baseExists = false;
|
|
2090
|
+
try {
|
|
2091
|
+
baseExists = fs.existsSync(resolveEnvPath(target.slice(0, slash)).absPath);
|
|
2092
|
+
}
|
|
2093
|
+
catch {
|
|
2094
|
+
baseExists = false;
|
|
2095
|
+
}
|
|
2096
|
+
if (baseExists) {
|
|
2097
|
+
throw new UsageError(`'akm env run' injects the whole file; the single-key '<ref>/${maybeKey}' form was removed.\n` +
|
|
2098
|
+
` For one value use a secret: \`akm secret run secret:${maybeKey} ${maybeKey} -- <command>\`.`, "INVALID_FLAG_VALUE");
|
|
2184
2099
|
}
|
|
2185
|
-
realValue = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
2186
2100
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2101
|
+
}
|
|
2102
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
|
|
2103
|
+
}
|
|
2104
|
+
const { loadEnv } = await import("./commands/env.js");
|
|
2105
|
+
const allValues = loadEnv(absPath);
|
|
2106
|
+
// Value-safe key filtering (--only / --except operate on key NAMES only).
|
|
2107
|
+
let envValues = allValues;
|
|
2108
|
+
if (opts.only && opts.except) {
|
|
2109
|
+
throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
|
|
2110
|
+
}
|
|
2111
|
+
if (opts.only) {
|
|
2112
|
+
const wanted = new Set(opts.only);
|
|
2113
|
+
const missing = opts.only.filter((k) => !(k in allValues));
|
|
2114
|
+
if (missing.length > 0) {
|
|
2115
|
+
process.stderr.write(`warning: --only key(s) not present in ${makeEnvRef(name, source)}: ${missing.join(", ")}\n`);
|
|
2116
|
+
}
|
|
2117
|
+
envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
|
|
2118
|
+
}
|
|
2119
|
+
else if (opts.except) {
|
|
2120
|
+
const excluded = new Set(opts.except);
|
|
2121
|
+
envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
|
|
2122
|
+
}
|
|
2123
|
+
const keys = Object.keys(envValues);
|
|
2124
|
+
// Scan injected keys for known process-hijacking variables (LD_PRELOAD,
|
|
2125
|
+
// PATH, ...). Block for third-party-sourced stashes (origin has a registryId);
|
|
2126
|
+
// warn for the operator's own first-party stash, where they own the file.
|
|
2127
|
+
const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
|
|
2128
|
+
const dangerous = keys.filter(isDangerousEnvKey);
|
|
2129
|
+
if (dangerous.length > 0) {
|
|
2130
|
+
const detail = `Env "${makeEnvRef(name, source)}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
|
|
2131
|
+
if (source.registryId) {
|
|
2132
|
+
throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
|
|
2133
|
+
` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
|
|
2134
|
+
}
|
|
2135
|
+
process.stderr.write(`warning: ${detail} Injecting anyway (first-party stash).\n`);
|
|
2136
|
+
}
|
|
2137
|
+
const mergedEnv = { ...process.env };
|
|
2138
|
+
for (const [envKey, envValue] of Object.entries(envValues)) {
|
|
2139
|
+
mergedEnv[envKey] = envValue;
|
|
2140
|
+
}
|
|
2141
|
+
// Audit trail: keys only, never values. A single `env_access` event carries a
|
|
2142
|
+
// `deprecatedAlias` marker when reached via the `vault run` shim, so log
|
|
2143
|
+
// consumers see one stable event type without a doubled physical record.
|
|
2144
|
+
appendEvent({
|
|
2145
|
+
eventType: "env_access",
|
|
2146
|
+
ref: makeEnvRef(name, source),
|
|
2147
|
+
metadata: opts.viaVault ? { keys, deprecatedAlias: "vault_access" } : { keys },
|
|
2148
|
+
});
|
|
2149
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
2150
|
+
stdio: "inherit",
|
|
2151
|
+
env: mergedEnv,
|
|
2152
|
+
});
|
|
2153
|
+
if (result.error) {
|
|
2154
|
+
// Classify spawn failures (#483). Raw ErrnoException leaks a bare
|
|
2155
|
+
// "spawn ENOENT" with no hint — wrap it so consumers get a usable
|
|
2156
|
+
// code + hint in the standard JSON envelope.
|
|
2157
|
+
const err = result.error;
|
|
2158
|
+
if (err.code === "ENOENT") {
|
|
2159
|
+
throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm env run'.`);
|
|
2160
|
+
}
|
|
2161
|
+
if (err.code === "EACCES") {
|
|
2162
|
+
throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
|
|
2163
|
+
}
|
|
2164
|
+
throw err;
|
|
2165
|
+
}
|
|
2166
|
+
process.exit(result.status ?? 0);
|
|
2167
|
+
}
|
|
2168
|
+
/** Parse a comma/space-separated key list flag into a trimmed, non-empty array. */
|
|
2169
|
+
function parseKeyListFlag(raw) {
|
|
2170
|
+
if (raw === undefined)
|
|
2171
|
+
return undefined;
|
|
2172
|
+
const keys = raw
|
|
2173
|
+
.split(/[,\s]+/)
|
|
2174
|
+
.map((k) => k.trim())
|
|
2175
|
+
.filter(Boolean);
|
|
2176
|
+
return keys.length > 0 ? keys : undefined;
|
|
2177
|
+
}
|
|
2178
|
+
const envRunCommand = defineCommand({
|
|
2179
|
+
meta: {
|
|
2180
|
+
name: "run",
|
|
2181
|
+
description: "Run a command with the env file injected into its environment: `akm env run <ref> -- <command>`. Use `-- $SHELL` for an interactive session. Restrict which variables are injected with --only / --except.",
|
|
2182
|
+
},
|
|
2183
|
+
args: {
|
|
2184
|
+
target: { type: "positional", description: "Env ref", required: true },
|
|
2185
|
+
only: {
|
|
2186
|
+
type: "string",
|
|
2187
|
+
description: "Inject ONLY these keys (comma-separated). Mutually exclusive with --except.",
|
|
2188
|
+
},
|
|
2189
|
+
except: { type: "string", description: "Inject all keys EXCEPT these (comma-separated)." },
|
|
2190
|
+
},
|
|
2191
|
+
run({ args }) {
|
|
2192
|
+
return runWithJsonErrors(() => runEnvInjected(args.target, {
|
|
2193
|
+
viaVault: false,
|
|
2194
|
+
only: parseKeyListFlag(getHyphenatedArg(args, "only")),
|
|
2195
|
+
except: parseKeyListFlag(getHyphenatedArg(args, "except")),
|
|
2196
|
+
}));
|
|
2197
|
+
},
|
|
2198
|
+
});
|
|
2199
|
+
const envRemoveCommand = defineCommand({
|
|
2200
|
+
meta: { name: "remove", description: "Remove an env file (and its .sensitive marker, if any)" },
|
|
2201
|
+
args: {
|
|
2202
|
+
ref: { type: "positional", description: "Env ref", required: true },
|
|
2203
|
+
yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
|
|
2204
|
+
},
|
|
2205
|
+
run({ args }) {
|
|
2206
|
+
return runWithJsonErrors(async () => {
|
|
2207
|
+
// Resolve against env/ specifically — never delete the frozen vaults/ copy.
|
|
2208
|
+
const parsed = parseEnvRef(args.ref);
|
|
2209
|
+
const source = findEnvSource(parsed.origin);
|
|
2210
|
+
const envRoot = path.join(source.path, "env");
|
|
2211
|
+
const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
|
|
2212
|
+
if (!isWithin(absPath, envRoot)) {
|
|
2213
|
+
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
|
|
2214
|
+
}
|
|
2215
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
2216
|
+
const confirmed = await confirmDestructive(`Remove env "${args.ref}"? This cannot be undone.`, {
|
|
2217
|
+
yes: args.yes === true,
|
|
2218
|
+
});
|
|
2219
|
+
if (!confirmed) {
|
|
2220
|
+
process.stderr.write("Aborted.\n");
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (!fs.existsSync(absPath)) {
|
|
2224
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(parsed.name, source)}`);
|
|
2225
|
+
}
|
|
2226
|
+
const { removeEnv } = await import("./commands/env.js");
|
|
2227
|
+
const removed = removeEnv(absPath);
|
|
2228
|
+
output("env-remove", { ref: makeEnvRef(parsed.name, source), removed });
|
|
2229
|
+
});
|
|
2230
|
+
},
|
|
2231
|
+
});
|
|
2232
|
+
const envCommand = defineCommand({
|
|
2233
|
+
meta: {
|
|
2234
|
+
name: "env",
|
|
2235
|
+
description: "Manage `.env` files — a group of related CONFIGURATION values for an app or service (URLs, flags, plus any credentials it needs), loaded together. Values may or may not be sensitive; akm protects them all the same (key names visible, values never in structured output). For a single sensitive value used on its own (an auth token, key, or cert), use `akm secret`.",
|
|
2236
|
+
},
|
|
2237
|
+
subCommands: {
|
|
2238
|
+
list: envListCommand,
|
|
2239
|
+
path: envPathCommand,
|
|
2240
|
+
export: envExportCommand,
|
|
2241
|
+
run: envRunCommand,
|
|
2242
|
+
create: envCreateCommand,
|
|
2243
|
+
remove: envRemoveCommand,
|
|
2244
|
+
},
|
|
2245
|
+
run({ args }) {
|
|
2246
|
+
return runWithJsonErrors(async () => {
|
|
2247
|
+
if (hasSubcommand(args, ENV_SUBCOMMAND_SET))
|
|
2248
|
+
return;
|
|
2249
|
+
const { listKeys } = await import("./commands/env.js");
|
|
2250
|
+
output("env-list", { envs: listEnvsRecursive(listKeys) });
|
|
2251
|
+
});
|
|
2252
|
+
},
|
|
2253
|
+
});
|
|
2254
|
+
// ── vault (DEPRECATED) ────────────────────────────────────────────────────────
|
|
2255
|
+
//
|
|
2256
|
+
// `akm vault` is deprecated in 0.8.0 and removed in 0.9.0. The verb now warns
|
|
2257
|
+
// to stderr and delegates to the `env` handlers. Entry management (`set` /
|
|
2258
|
+
// `unset`) and the single-key `run <ref>/KEY` form are hard-errors with a
|
|
2259
|
+
// signpost to `akm secret` — silent behaviour changes around secret material
|
|
2260
|
+
// are unacceptable.
|
|
2261
|
+
function emitVaultDeprecation(sub) {
|
|
2262
|
+
process.stderr.write(`warning: 'akm vault ${sub}' is deprecated and will be removed in 0.9.0. Use 'akm env ${sub}'.\n` +
|
|
2263
|
+
" For single-value injection use 'akm secret'.\n");
|
|
2264
|
+
}
|
|
2265
|
+
function emitFlagDeprecation(oldFlag, newFlag, cmd) {
|
|
2266
|
+
if (isQuiet())
|
|
2267
|
+
return;
|
|
2268
|
+
process.stderr.write(`warning: '${oldFlag}' is deprecated for 'akm ${cmd}'; use '${newFlag}'. Removed in 0.9.0.\n`);
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* Emit a stderr deprecation warning for a renamed top-level command. The old
|
|
2272
|
+
* spelling keeps working in 0.8 (wrap-and-delegate) and is removed in 0.9.0.
|
|
2273
|
+
* Suppressed under --quiet; never written to stdout so JSON consumers are
|
|
2274
|
+
* unaffected.
|
|
2275
|
+
*/
|
|
2276
|
+
function emitCommandDeprecation(oldCmd, newCmd) {
|
|
2277
|
+
if (isQuiet())
|
|
2278
|
+
return;
|
|
2279
|
+
process.stderr.write(`warning: 'akm ${oldCmd}' is deprecated and will be removed in 0.9.0. Use 'akm ${newCmd}'.\n`);
|
|
2280
|
+
}
|
|
2281
|
+
const vaultSetCommand = defineCommand({
|
|
2282
|
+
meta: { name: "set", description: "DEPRECATED — removed. Edit the .env file directly, or use `akm secret set`." },
|
|
2283
|
+
args: {
|
|
2284
|
+
ref: { type: "positional", description: "(deprecated)", required: false },
|
|
2285
|
+
key: { type: "positional", description: "(deprecated)", required: false },
|
|
2286
|
+
},
|
|
2287
|
+
run() {
|
|
2288
|
+
return runWithJsonErrors(async () => {
|
|
2289
|
+
throw new UsageError("'akm vault set' was removed: akm no longer manages individual env entries.\n" +
|
|
2290
|
+
" Edit the .env file directly (then run with `akm env run <ref> -- <cmd>`),\n" +
|
|
2291
|
+
" or store a single value as a secret: `akm secret set secret:<name>`.", "INVALID_FLAG_VALUE");
|
|
2189
2292
|
});
|
|
2190
2293
|
},
|
|
2191
2294
|
});
|
|
2192
2295
|
const vaultUnsetCommand = defineCommand({
|
|
2193
|
-
meta: { name: "unset", description: "
|
|
2296
|
+
meta: { name: "unset", description: "DEPRECATED — removed. Edit the .env file directly." },
|
|
2297
|
+
args: {
|
|
2298
|
+
ref: { type: "positional", description: "(deprecated)", required: false },
|
|
2299
|
+
key: { type: "positional", description: "(deprecated)", required: false },
|
|
2300
|
+
},
|
|
2301
|
+
run() {
|
|
2302
|
+
return runWithJsonErrors(async () => {
|
|
2303
|
+
throw new UsageError("'akm vault unset' was removed: akm no longer manages individual env entries.\n" +
|
|
2304
|
+
" Edit the .env file directly, or remove a secret with `akm secret remove secret:<name>`.", "INVALID_FLAG_VALUE");
|
|
2305
|
+
});
|
|
2306
|
+
},
|
|
2307
|
+
});
|
|
2308
|
+
const vaultListCommand = defineCommand({
|
|
2309
|
+
meta: { name: "list", description: "DEPRECATED — use `akm env list`." },
|
|
2310
|
+
run() {
|
|
2311
|
+
return runWithJsonErrors(async () => {
|
|
2312
|
+
emitVaultDeprecation("list");
|
|
2313
|
+
const { listKeys } = await import("./commands/env.js");
|
|
2314
|
+
output("env-list", { envs: listEnvsRecursive(listKeys) });
|
|
2315
|
+
});
|
|
2316
|
+
},
|
|
2317
|
+
});
|
|
2318
|
+
const vaultCreateCommand = defineCommand({
|
|
2319
|
+
meta: { name: "create", description: "DEPRECATED — use `akm env create`." },
|
|
2194
2320
|
args: {
|
|
2195
|
-
|
|
2196
|
-
|
|
2321
|
+
name: { type: "positional", description: "Env name", required: true },
|
|
2322
|
+
sensitive: { type: "boolean", description: "Exclude from list output and the search index", default: false },
|
|
2197
2323
|
},
|
|
2198
2324
|
run({ args }) {
|
|
2199
2325
|
return runWithJsonErrors(async () => {
|
|
2200
|
-
|
|
2201
|
-
const {
|
|
2202
|
-
|
|
2203
|
-
|
|
2326
|
+
emitVaultDeprecation("create");
|
|
2327
|
+
const { createEnv } = await import("./commands/env.js");
|
|
2328
|
+
const parsed = parseEnvRef(args.name);
|
|
2329
|
+
const source = findEnvSource(parsed.origin);
|
|
2330
|
+
const envRoot = path.join(source.path, "env");
|
|
2331
|
+
const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
|
|
2332
|
+
if (!isWithin(absPath, envRoot)) {
|
|
2333
|
+
throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
|
|
2204
2334
|
}
|
|
2205
|
-
|
|
2206
|
-
|
|
2335
|
+
createEnv(absPath);
|
|
2336
|
+
if (args.sensitive) {
|
|
2337
|
+
const markerPath = absPath.replace(/\.env$/, ".sensitive");
|
|
2338
|
+
if (!fs.existsSync(markerPath))
|
|
2339
|
+
fs.writeFileSync(markerPath, "", { mode: 0o600 });
|
|
2340
|
+
}
|
|
2341
|
+
output("env-create", { ref: makeEnvRef(parsed.name, source) });
|
|
2207
2342
|
});
|
|
2208
2343
|
},
|
|
2209
2344
|
});
|
|
2210
2345
|
const vaultPathCommand = defineCommand({
|
|
2346
|
+
meta: { name: "path", description: "DEPRECATED — use `akm env path`." },
|
|
2347
|
+
args: {
|
|
2348
|
+
ref: { type: "positional", description: "Env ref", required: true },
|
|
2349
|
+
},
|
|
2350
|
+
run({ args }) {
|
|
2351
|
+
return runWithJsonErrors(async () => {
|
|
2352
|
+
emitVaultDeprecation("path");
|
|
2353
|
+
const { name, absPath, source } = resolveEnvPath(args.ref);
|
|
2354
|
+
if (!fs.existsSync(absPath)) {
|
|
2355
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
|
|
2356
|
+
}
|
|
2357
|
+
process.stderr.write(`warning: sourcing the raw file executes shell substitutions it contains. Use: akm env run ${args.ref} -- <command>\n`);
|
|
2358
|
+
process.stdout.write(`${absPath}\n`);
|
|
2359
|
+
});
|
|
2360
|
+
},
|
|
2361
|
+
});
|
|
2362
|
+
const vaultRunCommand = defineCommand({
|
|
2363
|
+
meta: { name: "run", description: "DEPRECATED — use `akm env run`. The single-key `<ref>/KEY` form was removed." },
|
|
2364
|
+
args: {
|
|
2365
|
+
target: { type: "positional", description: "Env ref", required: true },
|
|
2366
|
+
},
|
|
2367
|
+
run({ args }) {
|
|
2368
|
+
return runWithJsonErrors(async () => {
|
|
2369
|
+
emitVaultDeprecation("run");
|
|
2370
|
+
await runEnvInjected(args.target, { viaVault: true });
|
|
2371
|
+
});
|
|
2372
|
+
},
|
|
2373
|
+
});
|
|
2374
|
+
const vaultCommand = defineCommand({
|
|
2375
|
+
meta: {
|
|
2376
|
+
name: "vault",
|
|
2377
|
+
description: "DEPRECATED (use `akm env`) — removed in 0.9.0. Manages whole `.env` files; values never printed.",
|
|
2378
|
+
},
|
|
2379
|
+
subCommands: {
|
|
2380
|
+
list: vaultListCommand,
|
|
2381
|
+
path: vaultPathCommand,
|
|
2382
|
+
run: vaultRunCommand,
|
|
2383
|
+
create: vaultCreateCommand,
|
|
2384
|
+
set: vaultSetCommand,
|
|
2385
|
+
unset: vaultUnsetCommand,
|
|
2386
|
+
},
|
|
2387
|
+
run({ args }) {
|
|
2388
|
+
return runWithJsonErrors(async () => {
|
|
2389
|
+
if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
|
|
2390
|
+
return;
|
|
2391
|
+
emitVaultDeprecation("list");
|
|
2392
|
+
const { listKeys } = await import("./commands/env.js");
|
|
2393
|
+
output("env-list", { envs: listEnvsRecursive(listKeys) });
|
|
2394
|
+
});
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
// ── secret ──────────────────────────────────────────────────────────────────
|
|
2398
|
+
//
|
|
2399
|
+
// `akm secret` manages whole-file secrets under each stash's secrets/ directory.
|
|
2400
|
+
// Unlike vaults (.env key/value), the ENTIRE file is the secret value. The bytes
|
|
2401
|
+
// are NEVER written to stdout or structured output. Values reach a command only
|
|
2402
|
+
// via `akm secret run` (injected into a child env var) or `akm secret path`
|
|
2403
|
+
// (the Docker /run/secrets + `_FILE` convention).
|
|
2404
|
+
function parseSecretRef(ref) {
|
|
2405
|
+
return parseAssetRef(ref.includes(":") ? ref : `secret:${ref}`);
|
|
2406
|
+
}
|
|
2407
|
+
function makeSecretRef(name, source) {
|
|
2408
|
+
return source?.registryId ? `${source.registryId}//secret:${name}` : `secret:${name}`;
|
|
2409
|
+
}
|
|
2410
|
+
function resolveSecretPath(ref) {
|
|
2411
|
+
const parsed = parseSecretRef(ref);
|
|
2412
|
+
if (parsed.type !== "secret") {
|
|
2413
|
+
throw new UsageError(`Expected a secret ref (secret:<name>); got "${ref}".`);
|
|
2414
|
+
}
|
|
2415
|
+
// Source resolution is identical for every asset type; reuse the env helper.
|
|
2416
|
+
const source = findEnvSource(parsed.origin);
|
|
2417
|
+
const typeRoot = path.join(source.path, "secrets");
|
|
2418
|
+
const absPath = resolveAssetPathFromName("secret", typeRoot, parsed.name);
|
|
2419
|
+
// Defense-in-depth: ensure the resolved path stays inside the secrets dir.
|
|
2420
|
+
if (!isWithin(absPath, typeRoot)) {
|
|
2421
|
+
throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
|
|
2422
|
+
}
|
|
2423
|
+
return { name: parsed.name, absPath, source };
|
|
2424
|
+
}
|
|
2425
|
+
/** Walk `secrets/` across all stashes, returning one entry per secret file. */
|
|
2426
|
+
function listSecretsRecursive() {
|
|
2427
|
+
const result = [];
|
|
2428
|
+
for (const source of resolveSourceEntries(undefined, loadConfig())) {
|
|
2429
|
+
const secretsDir = path.join(source.path, "secrets");
|
|
2430
|
+
if (!fs.existsSync(secretsDir))
|
|
2431
|
+
continue;
|
|
2432
|
+
const walk = (dir) => {
|
|
2433
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2434
|
+
const full = path.join(dir, entry.name);
|
|
2435
|
+
if (entry.isDirectory()) {
|
|
2436
|
+
walk(full);
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
if (!entry.isFile())
|
|
2440
|
+
continue;
|
|
2441
|
+
if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
|
|
2442
|
+
continue;
|
|
2443
|
+
// A sibling `<name>.sensitive` marker suppresses listing.
|
|
2444
|
+
if (fs.existsSync(`${full}.sensitive`))
|
|
2445
|
+
continue;
|
|
2446
|
+
const canonical = deriveCanonicalAssetName("secret", secretsDir, full);
|
|
2447
|
+
if (!canonical)
|
|
2448
|
+
continue;
|
|
2449
|
+
result.push({ ref: makeSecretRef(canonical, source), path: full });
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
walk(secretsDir);
|
|
2453
|
+
}
|
|
2454
|
+
return result;
|
|
2455
|
+
}
|
|
2456
|
+
const secretListCommand = defineCommand({
|
|
2457
|
+
meta: {
|
|
2458
|
+
name: "list",
|
|
2459
|
+
description: "List all secrets across all stashes by name (the file contents are never shown)",
|
|
2460
|
+
},
|
|
2461
|
+
run() {
|
|
2462
|
+
return runWithJsonErrors(async () => {
|
|
2463
|
+
output("secret-list", { secrets: listSecretsRecursive() });
|
|
2464
|
+
});
|
|
2465
|
+
},
|
|
2466
|
+
});
|
|
2467
|
+
const secretSetCommand = defineCommand({
|
|
2468
|
+
meta: {
|
|
2469
|
+
name: "set",
|
|
2470
|
+
description: "Create or overwrite a secret. The value is read from stdin by default (never via argv). Use --from-file <path> to import an existing file byte-exact, or --from-env <VAR> to read from an environment variable. Multi-line values are allowed.",
|
|
2471
|
+
},
|
|
2472
|
+
args: {
|
|
2473
|
+
ref: { type: "positional", description: "Secret ref (e.g. secret:deploy-key or just deploy-key)", required: true },
|
|
2474
|
+
"from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" },
|
|
2475
|
+
"from-env": { type: "string", description: "Read the value from the named environment variable" },
|
|
2476
|
+
},
|
|
2477
|
+
run({ args }) {
|
|
2478
|
+
return runWithJsonErrors(async () => {
|
|
2479
|
+
const { setSecret } = await import("./commands/secret.js");
|
|
2480
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2481
|
+
const fromEnv = getHyphenatedArg(args, "from-env");
|
|
2482
|
+
const fromFile = getHyphenatedArg(args, "from-file");
|
|
2483
|
+
if (fromEnv !== undefined && fromFile !== undefined) {
|
|
2484
|
+
throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
|
|
2485
|
+
}
|
|
2486
|
+
const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
2487
|
+
let value;
|
|
2488
|
+
if (fromFile !== undefined) {
|
|
2489
|
+
if (!fs.existsSync(fromFile)) {
|
|
2490
|
+
throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
|
|
2491
|
+
}
|
|
2492
|
+
value = fs.readFileSync(fromFile);
|
|
2493
|
+
if (value.byteLength > MAX_SECRET_BYTES) {
|
|
2494
|
+
throw new UsageError("Secret exceeds the 5 MB limit.");
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
else if (fromEnv !== undefined) {
|
|
2498
|
+
const envVal = process.env[fromEnv];
|
|
2499
|
+
if (envVal === undefined) {
|
|
2500
|
+
throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
|
|
2501
|
+
}
|
|
2502
|
+
value = Buffer.from(envVal, "utf8");
|
|
2503
|
+
}
|
|
2504
|
+
else {
|
|
2505
|
+
if (process.stdin.isTTY) {
|
|
2506
|
+
process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
|
|
2507
|
+
}
|
|
2508
|
+
let totalBytes = 0;
|
|
2509
|
+
const chunks = [];
|
|
2510
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
2511
|
+
totalBytes += chunk.byteLength;
|
|
2512
|
+
if (totalBytes > MAX_SECRET_BYTES) {
|
|
2513
|
+
throw new UsageError("Secret exceeds the 5 MB limit.");
|
|
2514
|
+
}
|
|
2515
|
+
chunks.push(chunk);
|
|
2516
|
+
}
|
|
2517
|
+
// Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
|
|
2518
|
+
// stores the token without the shell-added newline. Use --from-file for
|
|
2519
|
+
// byte-exact storage of multi-line material (PEM keys, certs).
|
|
2520
|
+
value = Buffer.from(Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""), "utf8");
|
|
2521
|
+
}
|
|
2522
|
+
setSecret(absPath, value);
|
|
2523
|
+
output("secret-set", { ref: makeSecretRef(name, source) });
|
|
2524
|
+
});
|
|
2525
|
+
},
|
|
2526
|
+
});
|
|
2527
|
+
const secretPathCommand = defineCommand({
|
|
2211
2528
|
meta: {
|
|
2212
2529
|
name: "path",
|
|
2213
|
-
description:
|
|
2530
|
+
description: "Print the absolute secret file path for the Docker `_FILE` convention, e.g. `MY_SECRET_FILE=$(akm secret path secret:deploy-key)`.",
|
|
2214
2531
|
},
|
|
2215
2532
|
args: {
|
|
2216
|
-
ref: { type: "positional", description: "
|
|
2533
|
+
ref: { type: "positional", description: "Secret ref", required: true },
|
|
2217
2534
|
},
|
|
2218
2535
|
run({ args }) {
|
|
2219
2536
|
return runWithJsonErrors(async () => {
|
|
2220
|
-
const { name, absPath, source } =
|
|
2537
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2221
2538
|
if (!fs.existsSync(absPath)) {
|
|
2222
|
-
throw new NotFoundError(`
|
|
2539
|
+
throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
|
|
2223
2540
|
}
|
|
2224
2541
|
process.stdout.write(`${absPath}\n`);
|
|
2225
2542
|
});
|
|
2226
2543
|
},
|
|
2227
2544
|
});
|
|
2228
|
-
const
|
|
2545
|
+
const secretRunCommand = defineCommand({
|
|
2229
2546
|
meta: {
|
|
2230
2547
|
name: "run",
|
|
2231
|
-
description: "Run a command with
|
|
2548
|
+
description: "Run a command with a secret's value injected into an env var: `akm secret run <ref> <VAR> -- <command>`. The value is set as $VAR in the child process only.",
|
|
2232
2549
|
},
|
|
2233
2550
|
args: {
|
|
2234
|
-
|
|
2551
|
+
ref: { type: "positional", description: "Secret ref", required: true },
|
|
2552
|
+
var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
|
|
2235
2553
|
},
|
|
2236
2554
|
run({ args }) {
|
|
2237
2555
|
return runWithJsonErrors(async () => {
|
|
2556
|
+
// Validate the target env var name FIRST (before the command split) so a
|
|
2557
|
+
// dangerous/invalid name is rejected regardless of how the command is
|
|
2558
|
+
// supplied — and so the failure does not depend on argv parsing.
|
|
2559
|
+
const varName = args.var;
|
|
2560
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
|
|
2561
|
+
throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
|
|
2562
|
+
}
|
|
2563
|
+
const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
|
|
2564
|
+
if (isDangerousEnvKey(varName)) {
|
|
2565
|
+
throw new UsageError(`Refusing to inject a secret into "${varName}": it is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`, "INVALID_FLAG_VALUE");
|
|
2566
|
+
}
|
|
2238
2567
|
const dashIndex = process.argv.indexOf("--");
|
|
2239
2568
|
if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
|
|
2240
|
-
throw new UsageError("Missing command. Usage: akm
|
|
2569
|
+
throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
|
|
2241
2570
|
}
|
|
2242
2571
|
const command = process.argv.slice(dashIndex + 1);
|
|
2243
|
-
const {
|
|
2244
|
-
const { ref, key } = splitVaultRunTarget(args.target);
|
|
2245
|
-
const { name, absPath, source } = resolveVaultPath(ref);
|
|
2572
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2246
2573
|
if (!fs.existsSync(absPath)) {
|
|
2247
|
-
throw new NotFoundError(`
|
|
2574
|
+
throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
|
|
2248
2575
|
}
|
|
2249
|
-
const
|
|
2576
|
+
const { readValue } = await import("./commands/secret.js");
|
|
2250
2577
|
const mergedEnv = { ...process.env };
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
throw new NotFoundError(`Key not found in ${makeVaultRef(name, source)}: ${key}`);
|
|
2254
|
-
}
|
|
2255
|
-
mergedEnv[key] = envValues[key];
|
|
2256
|
-
}
|
|
2257
|
-
else {
|
|
2258
|
-
for (const [envKey, envValue] of Object.entries(envValues)) {
|
|
2259
|
-
mergedEnv[envKey] = envValue;
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
// Emit vault access event (keys only, no values) for audit trail.
|
|
2263
|
-
// Best-effort: never block vault run on event write failure.
|
|
2578
|
+
mergedEnv[varName] = readValue(absPath).toString("utf8");
|
|
2579
|
+
// Audit trail: record access by ref + var name only — never the value.
|
|
2264
2580
|
appendEvent({
|
|
2265
|
-
eventType: "
|
|
2266
|
-
ref:
|
|
2267
|
-
metadata: {
|
|
2268
|
-
keys: key ? [key] : Object.keys(envValues),
|
|
2269
|
-
},
|
|
2581
|
+
eventType: "secret_access",
|
|
2582
|
+
ref: makeSecretRef(name, source),
|
|
2583
|
+
metadata: { var: varName },
|
|
2270
2584
|
});
|
|
2271
2585
|
const result = spawnSync(command[0], command.slice(1), {
|
|
2272
2586
|
stdio: "inherit",
|
|
2273
2587
|
env: mergedEnv,
|
|
2274
2588
|
});
|
|
2275
|
-
if (result.error)
|
|
2276
|
-
|
|
2589
|
+
if (result.error) {
|
|
2590
|
+
const err = result.error;
|
|
2591
|
+
if (err.code === "ENOENT") {
|
|
2592
|
+
throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm secret run'.`);
|
|
2593
|
+
}
|
|
2594
|
+
if (err.code === "EACCES") {
|
|
2595
|
+
throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
|
|
2596
|
+
}
|
|
2597
|
+
throw err;
|
|
2598
|
+
}
|
|
2277
2599
|
process.exit(result.status ?? 0);
|
|
2278
2600
|
});
|
|
2279
2601
|
},
|
|
2280
2602
|
});
|
|
2281
|
-
const
|
|
2603
|
+
const secretRemoveCommand = defineCommand({
|
|
2604
|
+
meta: { name: "remove", description: "Remove a secret (and its .sensitive marker, if any)" },
|
|
2605
|
+
args: {
|
|
2606
|
+
ref: { type: "positional", description: "Secret ref", required: true },
|
|
2607
|
+
yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
|
|
2608
|
+
},
|
|
2609
|
+
run({ args }) {
|
|
2610
|
+
return runWithJsonErrors(async () => {
|
|
2611
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2612
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
2613
|
+
const confirmed = await confirmDestructive(`Remove secret "${args.ref}"? This cannot be undone.`, {
|
|
2614
|
+
yes: args.yes === true,
|
|
2615
|
+
});
|
|
2616
|
+
if (!confirmed) {
|
|
2617
|
+
process.stderr.write("Aborted.\n");
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
const { removeSecret } = await import("./commands/secret.js");
|
|
2621
|
+
if (!fs.existsSync(absPath)) {
|
|
2622
|
+
throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
|
|
2623
|
+
}
|
|
2624
|
+
const removed = removeSecret(absPath);
|
|
2625
|
+
output("secret-remove", { ref: makeSecretRef(name, source), removed });
|
|
2626
|
+
});
|
|
2627
|
+
},
|
|
2628
|
+
});
|
|
2629
|
+
const secretCommand = defineCommand({
|
|
2282
2630
|
meta: {
|
|
2283
|
-
name: "
|
|
2284
|
-
description: "Manage
|
|
2631
|
+
name: "secret",
|
|
2632
|
+
description: "Manage secrets — a single sensitive value used on its own for authentication (an API token, a PEM private key, a TLS cert), one value per file. Names are visible; the file contents are the value and never appear in structured output. For a group of related configuration loaded together, use `akm env`.",
|
|
2285
2633
|
},
|
|
2286
2634
|
subCommands: {
|
|
2287
|
-
list:
|
|
2288
|
-
path:
|
|
2289
|
-
run:
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
unset: vaultUnsetCommand,
|
|
2635
|
+
list: secretListCommand,
|
|
2636
|
+
path: secretPathCommand,
|
|
2637
|
+
run: secretRunCommand,
|
|
2638
|
+
set: secretSetCommand,
|
|
2639
|
+
remove: secretRemoveCommand,
|
|
2293
2640
|
},
|
|
2294
2641
|
run({ args }) {
|
|
2295
2642
|
return runWithJsonErrors(async () => {
|
|
2296
|
-
if (hasSubcommand(args,
|
|
2643
|
+
if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
|
|
2297
2644
|
return;
|
|
2298
|
-
|
|
2299
|
-
const { listKeys } = await import("./commands/vault.js");
|
|
2300
|
-
output("vault-list", { vaults: listVaultsRecursive(listKeys) });
|
|
2645
|
+
output("secret-list", { secrets: listSecretsRecursive() });
|
|
2301
2646
|
});
|
|
2302
2647
|
},
|
|
2303
2648
|
});
|
|
@@ -2329,11 +2674,6 @@ const wikiRegisterCommand = defineCommand({
|
|
|
2329
2674
|
description: "Mark a git-backed source as writable so changes can be pushed back",
|
|
2330
2675
|
default: false,
|
|
2331
2676
|
},
|
|
2332
|
-
trust: {
|
|
2333
|
-
type: "boolean",
|
|
2334
|
-
description: "Bypass install-audit blocking for this registration only",
|
|
2335
|
-
default: false,
|
|
2336
|
-
},
|
|
2337
2677
|
"max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
|
|
2338
2678
|
"max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
|
|
2339
2679
|
},
|
|
@@ -2344,7 +2684,6 @@ const wikiRegisterCommand = defineCommand({
|
|
|
2344
2684
|
ref: args.ref.trim(),
|
|
2345
2685
|
name: args.name,
|
|
2346
2686
|
options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
|
|
2347
|
-
trustThisInstall: args.trust,
|
|
2348
2687
|
writable: args.writable,
|
|
2349
2688
|
});
|
|
2350
2689
|
output("wiki-register", result);
|
|
@@ -2383,9 +2722,15 @@ const wikiRemoveCommand = defineCommand({
|
|
|
2383
2722
|
},
|
|
2384
2723
|
args: {
|
|
2385
2724
|
name: { type: "positional", description: "Wiki name", required: true },
|
|
2725
|
+
yes: {
|
|
2726
|
+
type: "boolean",
|
|
2727
|
+
alias: "y",
|
|
2728
|
+
description: "Skip confirmation prompt (required in non-interactive shells)",
|
|
2729
|
+
default: false,
|
|
2730
|
+
},
|
|
2386
2731
|
force: {
|
|
2387
2732
|
type: "boolean",
|
|
2388
|
-
description: "
|
|
2733
|
+
description: "DEPRECATED — use -y/--yes. Removed in 0.9.0.",
|
|
2389
2734
|
default: false,
|
|
2390
2735
|
},
|
|
2391
2736
|
"with-sources": {
|
|
@@ -2396,8 +2741,16 @@ const wikiRemoveCommand = defineCommand({
|
|
|
2396
2741
|
},
|
|
2397
2742
|
run({ args }) {
|
|
2398
2743
|
return runWithJsonErrors(async () => {
|
|
2399
|
-
if (
|
|
2400
|
-
|
|
2744
|
+
if (args.yes !== true && args.force === true) {
|
|
2745
|
+
emitFlagDeprecation("--force", "-y/--yes", "wiki remove");
|
|
2746
|
+
}
|
|
2747
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
2748
|
+
const confirmed = await confirmDestructive(`Remove wiki "${args.name}"? This cannot be undone.`, {
|
|
2749
|
+
yes: args.yes === true || args.force === true,
|
|
2750
|
+
});
|
|
2751
|
+
if (!confirmed) {
|
|
2752
|
+
process.stderr.write("Aborted.\n");
|
|
2753
|
+
return;
|
|
2401
2754
|
}
|
|
2402
2755
|
const withSources = getHyphenatedBoolean(args, "with-sources");
|
|
2403
2756
|
const { removeWiki } = await import("./wiki/wiki.js");
|
|
@@ -2525,17 +2878,52 @@ const wikiLintCommand = defineCommand({
|
|
|
2525
2878
|
const wikiIngestCommand = defineCommand({
|
|
2526
2879
|
meta: {
|
|
2527
2880
|
name: "ingest",
|
|
2528
|
-
description: "
|
|
2881
|
+
description: "Dispatch an agent to execute the ingest workflow for this wiki. Uses --profile or config.defaults.agent.",
|
|
2529
2882
|
},
|
|
2530
2883
|
args: {
|
|
2531
2884
|
name: { type: "positional", description: "Wiki name", required: true },
|
|
2885
|
+
profile: {
|
|
2886
|
+
type: "string",
|
|
2887
|
+
description: "Agent profile to use (default: config.defaults.agent).",
|
|
2888
|
+
},
|
|
2889
|
+
model: {
|
|
2890
|
+
type: "string",
|
|
2891
|
+
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs.",
|
|
2892
|
+
},
|
|
2893
|
+
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds." },
|
|
2532
2894
|
},
|
|
2533
2895
|
run({ args }) {
|
|
2534
2896
|
return runWithJsonErrors(async () => {
|
|
2535
2897
|
const { buildIngestWorkflow } = await import("./wiki/wiki.js");
|
|
2536
2898
|
const stashDir = resolveStashDir();
|
|
2537
|
-
const
|
|
2538
|
-
|
|
2899
|
+
const built = buildIngestWorkflow(stashDir, args.name);
|
|
2900
|
+
const config = loadConfig();
|
|
2901
|
+
const profileName = getStringArg(args, "profile") ?? config.defaults?.agent;
|
|
2902
|
+
if (!profileName) {
|
|
2903
|
+
throw new UsageError("akm wiki ingest requires an agent profile. Pass --profile <name> or set defaults.agent in config.", "MISSING_REQUIRED_ARGUMENT", "Available profiles are listed under profiles.agent in your config. Run `akm config get profiles.agent` to inspect.");
|
|
2904
|
+
}
|
|
2905
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2906
|
+
const model = getStringArg(args, "model");
|
|
2907
|
+
const { getDefaultLlmConfig } = await import("./core/config.js");
|
|
2908
|
+
const dispatchResult = await akmAgentDispatch({
|
|
2909
|
+
profileName,
|
|
2910
|
+
agentConfig: config,
|
|
2911
|
+
llmConfig: getDefaultLlmConfig(config),
|
|
2912
|
+
prompt: built.workflow,
|
|
2913
|
+
dispatch: {
|
|
2914
|
+
prompt: built.workflow,
|
|
2915
|
+
...(model !== undefined ? { model } : {}),
|
|
2916
|
+
},
|
|
2917
|
+
...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
|
|
2918
|
+
});
|
|
2919
|
+
output("wiki-ingest", {
|
|
2920
|
+
wiki: built.wiki,
|
|
2921
|
+
path: built.path,
|
|
2922
|
+
schemaPath: built.schemaPath,
|
|
2923
|
+
dispatched: true,
|
|
2924
|
+
profile: profileName,
|
|
2925
|
+
agentResult: dispatchResult,
|
|
2926
|
+
});
|
|
2539
2927
|
});
|
|
2540
2928
|
},
|
|
2541
2929
|
});
|
|
@@ -2626,9 +3014,9 @@ const eventsTailCommand = defineCommand({
|
|
|
2626
3014
|
},
|
|
2627
3015
|
async run({ args }) {
|
|
2628
3016
|
await runWithJsonErrors(async () => {
|
|
2629
|
-
const intervalMs =
|
|
2630
|
-
const maxDurationMs =
|
|
2631
|
-
const maxEvents =
|
|
3017
|
+
const intervalMs = parsePositiveIntFlag(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
|
|
3018
|
+
const maxDurationMs = parsePositiveIntFlag(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
|
|
3019
|
+
const maxEvents = parsePositiveIntFlag(getHyphenatedArg(args, "max-events"), "--max-events");
|
|
2632
3020
|
const mode = getOutputMode();
|
|
2633
3021
|
// In streaming text mode we want each event to print as soon as it
|
|
2634
3022
|
// arrives. The polling loop emits via `onEvent`; the final result is
|
|
@@ -2663,121 +3051,631 @@ const eventsTailCommand = defineCommand({
|
|
|
2663
3051
|
if (!stream) {
|
|
2664
3052
|
output("events-tail", result);
|
|
2665
3053
|
}
|
|
2666
|
-
else if (mode.format === "jsonl") {
|
|
2667
|
-
// Final discriminated trailer row so jsonl consumers can resume.
|
|
2668
|
-
const trailer = {
|
|
2669
|
-
_kind: "trailer",
|
|
2670
|
-
schemaVersion: 1,
|
|
2671
|
-
nextOffset: result.nextOffset,
|
|
2672
|
-
totalCount: result.totalCount,
|
|
2673
|
-
reason: result.reason,
|
|
2674
|
-
};
|
|
2675
|
-
console.log(JSON.stringify(trailer));
|
|
3054
|
+
else if (mode.format === "jsonl") {
|
|
3055
|
+
// Final discriminated trailer row so jsonl consumers can resume.
|
|
3056
|
+
const trailer = {
|
|
3057
|
+
_kind: "trailer",
|
|
3058
|
+
schemaVersion: 1,
|
|
3059
|
+
nextOffset: result.nextOffset,
|
|
3060
|
+
totalCount: result.totalCount,
|
|
3061
|
+
reason: result.reason,
|
|
3062
|
+
};
|
|
3063
|
+
console.log(JSON.stringify(trailer));
|
|
3064
|
+
}
|
|
3065
|
+
else {
|
|
3066
|
+
// text mode: keep stdout pristine for line-oriented parsers and
|
|
3067
|
+
// emit the trailer on stderr.
|
|
3068
|
+
process.stderr.write(`[events-tail] reason=${result.reason} nextOffset=${result.nextOffset} total=${result.totalCount}\n`);
|
|
3069
|
+
}
|
|
3070
|
+
});
|
|
3071
|
+
},
|
|
3072
|
+
});
|
|
3073
|
+
const eventsCommand = defineCommand({
|
|
3074
|
+
meta: {
|
|
3075
|
+
name: "events",
|
|
3076
|
+
alias: "log",
|
|
3077
|
+
description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
|
|
3078
|
+
},
|
|
3079
|
+
subCommands: {
|
|
3080
|
+
list: eventsListCommand,
|
|
3081
|
+
tail: eventsTailCommand,
|
|
3082
|
+
},
|
|
3083
|
+
});
|
|
3084
|
+
// ── lessons subcommands (Phase 7A / Advantage D4c) ──────────────────────────
|
|
3085
|
+
const lessonsCoverageCommand = defineCommand({
|
|
3086
|
+
meta: {
|
|
3087
|
+
name: "coverage",
|
|
3088
|
+
description: "Report tags that exist on indexed assets but are NOT yet covered by any lesson.\n\n" +
|
|
3089
|
+
"Useful for spotting topics where the stash has skills/commands/scripts but no\n" +
|
|
3090
|
+
"crystallized lesson — a signal that the team has tacit knowledge worth distilling.\n\n" +
|
|
3091
|
+
"Default output is JSON: { uncoveredTags: string[], lessonTagCount: number, totalTagCount: number }.\n" +
|
|
3092
|
+
"Pass --format text for a plain-text bulleted list.",
|
|
3093
|
+
},
|
|
3094
|
+
args: {},
|
|
3095
|
+
run() {
|
|
3096
|
+
return runWithJsonErrors(() => {
|
|
3097
|
+
const db = openExistingDatabase();
|
|
3098
|
+
try {
|
|
3099
|
+
const allTagSet = collectTagSetFromEntries(db, undefined);
|
|
3100
|
+
const lessonTagSet = collectTagSetFromEntries(db, "lesson");
|
|
3101
|
+
const uncovered = [];
|
|
3102
|
+
for (const tag of allTagSet) {
|
|
3103
|
+
if (!lessonTagSet.has(tag))
|
|
3104
|
+
uncovered.push(tag);
|
|
3105
|
+
}
|
|
3106
|
+
uncovered.sort((a, b) => a.localeCompare(b));
|
|
3107
|
+
output("lessons-coverage", {
|
|
3108
|
+
ok: true,
|
|
3109
|
+
uncoveredTags: uncovered,
|
|
3110
|
+
lessonTagCount: lessonTagSet.size,
|
|
3111
|
+
totalTagCount: allTagSet.size,
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
finally {
|
|
3115
|
+
closeDatabase(db);
|
|
3116
|
+
}
|
|
3117
|
+
});
|
|
3118
|
+
},
|
|
3119
|
+
});
|
|
3120
|
+
/**
|
|
3121
|
+
* Walk indexed entries and collect a deduplicated set of tags. When
|
|
3122
|
+
* `entryType` is provided, only entries of that type contribute tags.
|
|
3123
|
+
*
|
|
3124
|
+
* Pure read; never mutates the DB. Used by `akm lessons coverage` (Phase 7A)
|
|
3125
|
+
* to compute the diff between all-asset tags and lesson tags.
|
|
3126
|
+
*/
|
|
3127
|
+
function collectTagSetFromEntries(db, entryType) {
|
|
3128
|
+
const tags = new Set();
|
|
3129
|
+
const stmt = entryType
|
|
3130
|
+
? db.prepare("SELECT entry_json FROM entries WHERE entry_type = ?")
|
|
3131
|
+
: db.prepare("SELECT entry_json FROM entries");
|
|
3132
|
+
const rows = (entryType ? stmt.all(entryType) : stmt.all());
|
|
3133
|
+
for (const row of rows) {
|
|
3134
|
+
let parsed;
|
|
3135
|
+
try {
|
|
3136
|
+
parsed = JSON.parse(row.entry_json);
|
|
3137
|
+
}
|
|
3138
|
+
catch {
|
|
3139
|
+
continue;
|
|
3140
|
+
}
|
|
3141
|
+
if (!Array.isArray(parsed.tags))
|
|
3142
|
+
continue;
|
|
3143
|
+
for (const tag of parsed.tags) {
|
|
3144
|
+
if (typeof tag === "string" && tag.trim().length > 0) {
|
|
3145
|
+
tags.add(tag.trim().toLowerCase());
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
return tags;
|
|
3150
|
+
}
|
|
3151
|
+
const lessonsCommand = defineCommand({
|
|
3152
|
+
meta: {
|
|
3153
|
+
name: "lessons",
|
|
3154
|
+
alias: "lesson",
|
|
3155
|
+
description: "Lesson-asset tooling: tag-coverage gaps, strength queries.",
|
|
3156
|
+
},
|
|
3157
|
+
subCommands: {
|
|
3158
|
+
coverage: lessonsCoverageCommand,
|
|
3159
|
+
},
|
|
3160
|
+
});
|
|
3161
|
+
// ── proposal substrate (#225) ────────────────────────────────────────────────
|
|
3162
|
+
const proposalListCommand = defineCommand({
|
|
3163
|
+
meta: { name: "list", description: "List proposal queue entries" },
|
|
3164
|
+
args: {
|
|
3165
|
+
status: {
|
|
3166
|
+
type: "string",
|
|
3167
|
+
description: "Filter by status (pending|accepted|rejected|reverted)",
|
|
3168
|
+
},
|
|
3169
|
+
ref: { type: "string", description: "Filter by asset ref (type:name)" },
|
|
3170
|
+
type: { type: "string", description: "Filter by asset type" },
|
|
3171
|
+
},
|
|
3172
|
+
run({ args }) {
|
|
3173
|
+
return runWithJsonErrors(() => {
|
|
3174
|
+
const status = parseProposalStatus(args.status);
|
|
3175
|
+
const result = akmProposalList({
|
|
3176
|
+
status,
|
|
3177
|
+
ref: args.ref,
|
|
3178
|
+
type: args.type,
|
|
3179
|
+
includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
|
|
3180
|
+
});
|
|
3181
|
+
output("proposal-list", result);
|
|
3182
|
+
});
|
|
3183
|
+
},
|
|
3184
|
+
});
|
|
3185
|
+
const proposalAcceptCommand = defineCommand({
|
|
3186
|
+
meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
|
|
3187
|
+
args: {
|
|
3188
|
+
id: {
|
|
3189
|
+
type: "positional",
|
|
3190
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
|
|
3191
|
+
required: false,
|
|
3192
|
+
},
|
|
3193
|
+
target: { type: "string", description: "Override the write target by source name" },
|
|
3194
|
+
// F-6 / #393: Batch accept by generator, diff size, or age.
|
|
3195
|
+
generator: {
|
|
3196
|
+
type: "string",
|
|
3197
|
+
description: "F-6: Bulk-accept all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
|
|
3198
|
+
},
|
|
3199
|
+
source: {
|
|
3200
|
+
type: "string",
|
|
3201
|
+
description: "DEPRECATED — use --generator. Removed in 0.9.0.",
|
|
3202
|
+
},
|
|
3203
|
+
"max-diff-lines": {
|
|
3204
|
+
type: "string",
|
|
3205
|
+
description: "F-6: When bulk-accepting, only accept proposals whose content is <= this many lines. Skips larger proposals.",
|
|
3206
|
+
},
|
|
3207
|
+
"older-than": {
|
|
3208
|
+
type: "string",
|
|
3209
|
+
description: "F-6: When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
3210
|
+
},
|
|
3211
|
+
"dry-run": {
|
|
3212
|
+
type: "boolean",
|
|
3213
|
+
description: "F-6: List proposals that would be bulk-accepted without accepting them.",
|
|
3214
|
+
default: false,
|
|
3215
|
+
},
|
|
3216
|
+
yes: {
|
|
3217
|
+
type: "boolean",
|
|
3218
|
+
alias: "y",
|
|
3219
|
+
description: "Skip confirmation prompt (required in non-interactive mode for bulk accept)",
|
|
3220
|
+
default: false,
|
|
3221
|
+
},
|
|
3222
|
+
},
|
|
3223
|
+
async run({ args }) {
|
|
3224
|
+
await runWithJsonErrors(async () => {
|
|
3225
|
+
if (args.generator === undefined && args.source !== undefined) {
|
|
3226
|
+
emitFlagDeprecation("--source", "--generator", "proposal accept");
|
|
3227
|
+
}
|
|
3228
|
+
const generator = (args.generator ?? args.source);
|
|
3229
|
+
// F-6 / #393: Bulk-accept when --generator is provided without a positional id.
|
|
3230
|
+
if (generator && !args.id) {
|
|
3231
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
3232
|
+
const confirmed = await confirmDestructive(`Bulk-accept all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
|
|
3233
|
+
if (!confirmed) {
|
|
3234
|
+
process.stderr.write("Aborted.\n");
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
const { listProposals } = await import("./core/proposals");
|
|
3238
|
+
const stashDir = resolveStashDir();
|
|
3239
|
+
const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
|
|
3240
|
+
if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
|
|
3241
|
+
throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
|
|
3242
|
+
}
|
|
3243
|
+
const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
|
|
3244
|
+
if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
|
|
3245
|
+
throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
|
|
3246
|
+
}
|
|
3247
|
+
const maxDiffLines = rawMaxDiff;
|
|
3248
|
+
const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
|
|
3249
|
+
const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
|
|
3250
|
+
if (p.source !== generator)
|
|
3251
|
+
return false;
|
|
3252
|
+
if (maxDiffLines !== undefined) {
|
|
3253
|
+
const lines = (p.payload.content ?? "").split("\n").length;
|
|
3254
|
+
if (lines > maxDiffLines)
|
|
3255
|
+
return false;
|
|
3256
|
+
}
|
|
3257
|
+
if (olderThanMs !== undefined) {
|
|
3258
|
+
const age = Date.now() - new Date(p.createdAt).getTime();
|
|
3259
|
+
if (age < olderThanMs)
|
|
3260
|
+
return false;
|
|
3261
|
+
}
|
|
3262
|
+
return true;
|
|
3263
|
+
});
|
|
3264
|
+
const results = [];
|
|
3265
|
+
for (const proposal of pending) {
|
|
3266
|
+
if (args["dry-run"]) {
|
|
3267
|
+
results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
|
|
3268
|
+
}
|
|
3269
|
+
else {
|
|
3270
|
+
const result = await akmProposalAccept({ id: proposal.id, target: args.target });
|
|
3271
|
+
results.push(result);
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
output("proposal-accept-batch", { accepted: results.length, results, dryRun: args["dry-run"] });
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
if (!args.id) {
|
|
3278
|
+
throw new UsageError("Usage: akm proposal accept <id> OR akm proposal accept --generator <generator>", "MISSING_REQUIRED_ARGUMENT");
|
|
3279
|
+
}
|
|
3280
|
+
const result = await akmProposalAccept({ id: args.id, target: args.target });
|
|
3281
|
+
output("proposal-accept", result);
|
|
3282
|
+
});
|
|
3283
|
+
},
|
|
3284
|
+
});
|
|
3285
|
+
const proposalRejectCommand = defineCommand({
|
|
3286
|
+
meta: { name: "reject", description: "Reject a proposal and record the reason" },
|
|
3287
|
+
args: {
|
|
3288
|
+
id: {
|
|
3289
|
+
type: "positional",
|
|
3290
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
|
|
3291
|
+
required: false,
|
|
3292
|
+
},
|
|
3293
|
+
reason: { type: "string", description: "Reason for rejection (required)" },
|
|
3294
|
+
// F-6 / #393: Batch reject by generator, diff size, or age.
|
|
3295
|
+
generator: {
|
|
3296
|
+
type: "string",
|
|
3297
|
+
description: "F-6: Bulk-reject all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
|
|
3298
|
+
},
|
|
3299
|
+
source: {
|
|
3300
|
+
type: "string",
|
|
3301
|
+
description: "DEPRECATED — use --generator. Removed in 0.9.0.",
|
|
3302
|
+
},
|
|
3303
|
+
"max-diff-lines": {
|
|
3304
|
+
type: "string",
|
|
3305
|
+
description: "F-6: When bulk-rejecting, only reject proposals whose content is <= this many lines. Skips larger proposals.",
|
|
3306
|
+
},
|
|
3307
|
+
"older-than": {
|
|
3308
|
+
type: "string",
|
|
3309
|
+
description: "F-6: When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
|
|
3310
|
+
},
|
|
3311
|
+
"dry-run": {
|
|
3312
|
+
type: "boolean",
|
|
3313
|
+
description: "F-6: List proposals that would be bulk-rejected without rejecting them.",
|
|
3314
|
+
default: false,
|
|
3315
|
+
},
|
|
3316
|
+
yes: {
|
|
3317
|
+
type: "boolean",
|
|
3318
|
+
alias: "y",
|
|
3319
|
+
description: "Skip confirmation prompt (required in non-interactive mode)",
|
|
3320
|
+
default: false,
|
|
3321
|
+
},
|
|
3322
|
+
},
|
|
3323
|
+
run({ args }) {
|
|
3324
|
+
return runWithJsonErrors(async () => {
|
|
3325
|
+
if (args.generator === undefined && args.source !== undefined) {
|
|
3326
|
+
emitFlagDeprecation("--source", "--generator", "proposal reject");
|
|
3327
|
+
}
|
|
3328
|
+
const generator = (args.generator ?? args.source);
|
|
3329
|
+
if (!args.reason || !String(args.reason).trim()) {
|
|
3330
|
+
throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
|
|
3331
|
+
}
|
|
3332
|
+
// F-6 / #393: Bulk-reject when --generator is provided without a positional id.
|
|
3333
|
+
if (generator && !args.id) {
|
|
3334
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
3335
|
+
const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
|
|
3336
|
+
if (!confirmed) {
|
|
3337
|
+
process.stderr.write("Aborted.\n");
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
const { listProposals } = await import("./core/proposals");
|
|
3341
|
+
const stashDir = resolveStashDir();
|
|
3342
|
+
const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
|
|
3343
|
+
if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
|
|
3344
|
+
throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
|
|
3345
|
+
}
|
|
3346
|
+
const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
|
|
3347
|
+
if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
|
|
3348
|
+
throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
|
|
3349
|
+
}
|
|
3350
|
+
const maxDiffLines = rawMaxDiff;
|
|
3351
|
+
const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
|
|
3352
|
+
const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
|
|
3353
|
+
if (p.source !== generator)
|
|
3354
|
+
return false;
|
|
3355
|
+
if (maxDiffLines !== undefined) {
|
|
3356
|
+
const lines = (p.payload.content ?? "").split("\n").length;
|
|
3357
|
+
if (lines > maxDiffLines)
|
|
3358
|
+
return false;
|
|
3359
|
+
}
|
|
3360
|
+
if (olderThanMs !== undefined) {
|
|
3361
|
+
const age = Date.now() - new Date(p.createdAt).getTime();
|
|
3362
|
+
if (age < olderThanMs)
|
|
3363
|
+
return false;
|
|
3364
|
+
}
|
|
3365
|
+
return true;
|
|
3366
|
+
});
|
|
3367
|
+
const results = [];
|
|
3368
|
+
for (const proposal of pending) {
|
|
3369
|
+
if (args["dry-run"]) {
|
|
3370
|
+
results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
|
|
3371
|
+
}
|
|
3372
|
+
else {
|
|
3373
|
+
const result = akmProposalReject({ id: proposal.id, reason: String(args.reason) });
|
|
3374
|
+
results.push(result);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
output("proposal-reject-batch", { rejected: results.length, results, dryRun: args["dry-run"] });
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
if (!args.id) {
|
|
3381
|
+
throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
|
|
2676
3382
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
3383
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
3384
|
+
const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? This cannot be undone.`, {
|
|
3385
|
+
yes: args.yes === true,
|
|
3386
|
+
});
|
|
3387
|
+
if (!confirmed) {
|
|
3388
|
+
process.stderr.write("Aborted.\n");
|
|
3389
|
+
return;
|
|
2681
3390
|
}
|
|
3391
|
+
const result = akmProposalReject({ id: args.id, reason: String(args.reason) });
|
|
3392
|
+
output("proposal-reject", result);
|
|
2682
3393
|
});
|
|
2683
3394
|
},
|
|
2684
3395
|
});
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
return undefined;
|
|
2688
|
-
const trimmed = raw.trim();
|
|
2689
|
-
if (!trimmed)
|
|
2690
|
-
return undefined;
|
|
2691
|
-
const value = Number.parseInt(trimmed, 10);
|
|
2692
|
-
if (Number.isNaN(value) || value <= 0) {
|
|
2693
|
-
throw new UsageError(`Invalid ${flag} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
2694
|
-
}
|
|
2695
|
-
return value;
|
|
2696
|
-
}
|
|
2697
|
-
const eventsCommand = defineCommand({
|
|
2698
|
-
meta: {
|
|
2699
|
-
name: "events",
|
|
2700
|
-
description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
|
|
2701
|
-
},
|
|
2702
|
-
subCommands: {
|
|
2703
|
-
list: eventsListCommand,
|
|
2704
|
-
tail: eventsTailCommand,
|
|
2705
|
-
},
|
|
2706
|
-
});
|
|
2707
|
-
// ── proposal substrate (#225) ────────────────────────────────────────────────
|
|
2708
|
-
const proposalsCommand = defineCommand({
|
|
2709
|
-
meta: { name: "proposals", description: "List proposal queue entries" },
|
|
3396
|
+
const proposalDiffCommand = defineCommand({
|
|
3397
|
+
meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
|
|
2710
3398
|
args: {
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
3399
|
+
id: {
|
|
3400
|
+
type: "positional",
|
|
3401
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
3402
|
+
required: true,
|
|
3403
|
+
},
|
|
3404
|
+
target: { type: "string", description: "Override the write target by source name" },
|
|
2714
3405
|
},
|
|
2715
3406
|
run({ args }) {
|
|
2716
3407
|
return runWithJsonErrors(() => {
|
|
2717
|
-
const
|
|
2718
|
-
|
|
2719
|
-
status,
|
|
2720
|
-
ref: args.ref,
|
|
2721
|
-
includeArchive: status === "accepted" || status === "rejected",
|
|
2722
|
-
});
|
|
2723
|
-
output("proposal-list", result);
|
|
3408
|
+
const result = akmProposalDiff({ id: args.id, target: args.target });
|
|
3409
|
+
output("proposal-diff", result);
|
|
2724
3410
|
});
|
|
2725
3411
|
},
|
|
2726
3412
|
});
|
|
2727
|
-
|
|
2728
|
-
|
|
3413
|
+
// Phase 6C (Advantage D6c): revert an accepted proposal.
|
|
3414
|
+
//
|
|
3415
|
+
// Exit codes (mapped by `runWithJsonErrors` from the typed errors thrown by
|
|
3416
|
+
// `akmProposalRevert` / `revertProposal`):
|
|
3417
|
+
// 0 — success; prior content restored.
|
|
3418
|
+
// 1 — generic error (also used by `UsageError("INVALID_FLAG_VALUE")` and
|
|
3419
|
+
// `UsageError("MISSING_REQUIRED_ARGUMENT")` when the proposal is not
|
|
3420
|
+
// accepted, or no backup is available).
|
|
3421
|
+
// 1 — `NotFoundError("FILE_NOT_FOUND")` when the proposal id does not resolve.
|
|
3422
|
+
const proposalRevertCommand = defineCommand({
|
|
3423
|
+
meta: {
|
|
3424
|
+
name: "revert",
|
|
3425
|
+
description: "Revert an accepted proposal: restore the prior asset content from the backup captured at promotion time. " +
|
|
3426
|
+
"Errors if the proposal is not accepted or has no backup (new-asset proposals leave no backup). " +
|
|
3427
|
+
"Accepts the full proposal UUID or the asset ref. UUID prefixes are not supported for archived proposals — use the full UUID.",
|
|
3428
|
+
},
|
|
2729
3429
|
args: {
|
|
2730
3430
|
id: {
|
|
2731
3431
|
type: "positional",
|
|
2732
|
-
description: "Proposal id (uuid
|
|
3432
|
+
description: "Proposal id (full uuid) or asset ref (e.g. skill:akm-dream). UUID prefixes are not supported for archived proposals — use the full UUID.",
|
|
2733
3433
|
required: true,
|
|
2734
3434
|
},
|
|
2735
3435
|
target: { type: "string", description: "Override the write target by source name" },
|
|
2736
3436
|
},
|
|
2737
3437
|
async run({ args }) {
|
|
2738
3438
|
await runWithJsonErrors(async () => {
|
|
2739
|
-
const result = await
|
|
2740
|
-
|
|
3439
|
+
const result = await akmProposalRevert({
|
|
3440
|
+
id: args.id,
|
|
3441
|
+
target: args.target,
|
|
3442
|
+
});
|
|
3443
|
+
output("proposal-revert", result);
|
|
2741
3444
|
});
|
|
2742
3445
|
},
|
|
2743
3446
|
});
|
|
2744
|
-
|
|
2745
|
-
|
|
3447
|
+
// `proposal show` (#225): show a single proposal with its validation findings.
|
|
3448
|
+
// `akmProposalShow` already backs `akm show proposal <id>` (now deprecated); this
|
|
3449
|
+
// is the canonical noun-group entry point.
|
|
3450
|
+
const proposalShowCommand = defineCommand({
|
|
3451
|
+
meta: { name: "show", description: "Show a single proposal and its validation findings" },
|
|
2746
3452
|
args: {
|
|
2747
3453
|
id: {
|
|
2748
3454
|
type: "positional",
|
|
2749
3455
|
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
2750
3456
|
required: true,
|
|
2751
3457
|
},
|
|
2752
|
-
reason: { type: "string", description: "Reason for rejection (required)" },
|
|
2753
3458
|
},
|
|
2754
3459
|
run({ args }) {
|
|
2755
3460
|
return runWithJsonErrors(() => {
|
|
2756
|
-
|
|
2757
|
-
|
|
3461
|
+
const result = akmProposalShow({ id: args.id });
|
|
3462
|
+
output("proposal-show", result);
|
|
3463
|
+
});
|
|
3464
|
+
},
|
|
3465
|
+
});
|
|
3466
|
+
const proposalDrainCommand = defineCommand({
|
|
3467
|
+
meta: {
|
|
3468
|
+
name: "drain",
|
|
3469
|
+
description: "Drain the standing pending proposal backlog using a deterministic triage policy",
|
|
3470
|
+
},
|
|
3471
|
+
args: {
|
|
3472
|
+
policy: {
|
|
3473
|
+
type: "string",
|
|
3474
|
+
description: "Built-in preset (personal-stash|conservative|manual) or path to a policy file",
|
|
3475
|
+
},
|
|
3476
|
+
"dry-run": {
|
|
3477
|
+
type: "boolean",
|
|
3478
|
+
description: "List what would be accepted/rejected/deferred without writing.",
|
|
3479
|
+
default: false,
|
|
3480
|
+
},
|
|
3481
|
+
yes: {
|
|
3482
|
+
type: "boolean",
|
|
3483
|
+
alias: "y",
|
|
3484
|
+
description: "Skip confirmation prompt (required in non-interactive mode for promotion).",
|
|
3485
|
+
default: false,
|
|
3486
|
+
},
|
|
3487
|
+
"max-accepts": {
|
|
3488
|
+
type: "string",
|
|
3489
|
+
description: "Hard per-run accept ceiling. Accepts beyond this are reported as skippedByCap.",
|
|
3490
|
+
},
|
|
3491
|
+
"max-diff-lines": {
|
|
3492
|
+
type: "string",
|
|
3493
|
+
description: "Defer (never promote) accepts whose proposed content exceeds this many lines.",
|
|
3494
|
+
},
|
|
3495
|
+
"older-than": {
|
|
3496
|
+
type: "string",
|
|
3497
|
+
description: "Only consider proposals created more than this many days ago.",
|
|
3498
|
+
},
|
|
3499
|
+
promote: {
|
|
3500
|
+
type: "boolean",
|
|
3501
|
+
description: "Promote (accept) matching proposals. Default is queue mode (stage only, no writes to assets).",
|
|
3502
|
+
default: false,
|
|
3503
|
+
},
|
|
3504
|
+
judgment: {
|
|
3505
|
+
type: "boolean",
|
|
3506
|
+
description: "Opt into the judgment tier (llm by default; agent/sdk per config) for deferred items. No-op with a logged triage_deferred summary when no runner is configured.",
|
|
3507
|
+
default: false,
|
|
3508
|
+
},
|
|
3509
|
+
profile: {
|
|
3510
|
+
type: "string",
|
|
3511
|
+
description: "Read the triage block (policy, applyMode, ceilings, judgment) from this improve profile.",
|
|
3512
|
+
},
|
|
3513
|
+
},
|
|
3514
|
+
async run({ args }) {
|
|
3515
|
+
await runWithJsonErrors(async () => {
|
|
3516
|
+
const stashDir = resolveStashDir();
|
|
3517
|
+
const cfg = loadConfig();
|
|
3518
|
+
// Phase 2: read the triage block from the named improve profile. CLI flags
|
|
3519
|
+
// always override config; config supplies defaults for any flag omitted.
|
|
3520
|
+
const triageConfig = args.profile !== undefined ? resolveImproveProfile(args.profile, cfg).processes?.triage : undefined;
|
|
3521
|
+
const policy = resolveDrainPolicy(args.policy ?? triageConfig?.policy);
|
|
3522
|
+
const dryRun = args["dry-run"] === true;
|
|
3523
|
+
const applyMode = args.promote === true ? "promote" : (triageConfig?.applyMode ?? "queue");
|
|
3524
|
+
const maxAccepts = parsePositiveIntFlag(args["max-accepts"], "--max-accepts") ??
|
|
3525
|
+
triageConfig?.maxAcceptsPerRun ??
|
|
3526
|
+
25;
|
|
3527
|
+
const maxDiffLines = parsePositiveIntFlag(args["max-diff-lines"], "--max-diff-lines") ??
|
|
3528
|
+
triageConfig?.maxDiffLines;
|
|
3529
|
+
const rawOlderThan = parsePositiveIntFlag(args["older-than"], "--older-than");
|
|
3530
|
+
const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
|
|
3531
|
+
// Promotion in promote mode is destructive (commits to git, no batch revert).
|
|
3532
|
+
if (applyMode === "promote" && !dryRun) {
|
|
3533
|
+
const { confirmDestructive } = await import("./cli/confirm.js");
|
|
3534
|
+
const confirmed = await confirmDestructive(`Drain and promote matching pending proposals under policy "${policy.name}"? Promotions commit to git and cannot be batch-reverted.`, { yes: args.yes === true });
|
|
3535
|
+
if (!confirmed) {
|
|
3536
|
+
process.stderr.write("Aborted.\n");
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
2758
3539
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
3540
|
+
// `--older-than` is applied here as a pre-filter on excludeIds: ids that
|
|
3541
|
+
// are too fresh are excluded so the engine never touches them. This reads
|
|
3542
|
+
// the pending set once here; drainProposals reads the pending set again
|
|
3543
|
+
// internally, so a future engine-level olderThan option could remove this
|
|
3544
|
+
// second read (engine API owned by another agent — not changed here).
|
|
3545
|
+
let excludeIds;
|
|
3546
|
+
if (olderThanMs !== undefined) {
|
|
3547
|
+
const { listProposals } = await import("./core/proposals");
|
|
3548
|
+
const now = Date.now();
|
|
3549
|
+
excludeIds = new Set(listProposals(stashDir, { status: "pending" })
|
|
3550
|
+
// Fail SAFE: exclude a proposal when its age cannot be computed
|
|
3551
|
+
// (NaN createdAt) OR it is too fresh. An unparseable createdAt must
|
|
3552
|
+
// never be treated as old enough to drain/promote.
|
|
3553
|
+
.filter((proposal) => {
|
|
3554
|
+
const age = now - new Date(proposal.createdAt).getTime();
|
|
3555
|
+
return Number.isNaN(age) || age < olderThanMs;
|
|
3556
|
+
})
|
|
3557
|
+
.map((proposal) => proposal.id));
|
|
3558
|
+
}
|
|
3559
|
+
// Phase 3: resolve the judgment runner when --judgment is set. Default
|
|
3560
|
+
// mode is llm; falls back to defaults.llm when the triage block sets
|
|
3561
|
+
// neither mode nor profile (mirrors resolveValidationRunner). null when
|
|
3562
|
+
// nothing is configured → the engine leaves deferred items unresolved and
|
|
3563
|
+
// emits triage_deferred.
|
|
3564
|
+
const judgment = args.judgment === true ? resolveTriageJudgmentRunner(triageConfig?.judgment, cfg) : null;
|
|
3565
|
+
const result = await drainProposals({
|
|
3566
|
+
stashDir,
|
|
3567
|
+
policy,
|
|
3568
|
+
applyMode,
|
|
3569
|
+
maxAccepts,
|
|
3570
|
+
dryRun,
|
|
3571
|
+
...(maxDiffLines !== undefined ? { maxDiffLines } : {}),
|
|
3572
|
+
...(excludeIds ? { excludeIds } : {}),
|
|
3573
|
+
judgment,
|
|
3574
|
+
});
|
|
3575
|
+
output("proposal-drain", {
|
|
3576
|
+
schemaVersion: 1,
|
|
3577
|
+
ok: true,
|
|
3578
|
+
policy: policy.name,
|
|
3579
|
+
applyMode,
|
|
3580
|
+
dryRun,
|
|
3581
|
+
promoted: result.promoted,
|
|
3582
|
+
rejected: result.rejected,
|
|
3583
|
+
deferred: result.deferred,
|
|
3584
|
+
skippedByCap: result.skippedByCap,
|
|
3585
|
+
});
|
|
2761
3586
|
});
|
|
2762
3587
|
},
|
|
2763
3588
|
});
|
|
2764
|
-
|
|
2765
|
-
|
|
3589
|
+
// ── proposal noun group (#225 / 0.8 CLI stabilization) ────────────────────────
|
|
3590
|
+
//
|
|
3591
|
+
// `akm proposal <verb>` is the canonical grammar in 0.8. The flat verbs
|
|
3592
|
+
// (`proposals`/`accept`/`reject`/`diff`/`revert`) remain as deprecated aliases
|
|
3593
|
+
// that warn to stderr and delegate to the same command bodies; they are removed
|
|
3594
|
+
// in 0.9.0. Bare `akm proposal` behaves as `proposal list` (mirrors `akm env`).
|
|
3595
|
+
const PROPOSAL_SUBCOMMAND_SET = new Set(["list", "show", "diff", "accept", "reject", "revert", "drain"]);
|
|
3596
|
+
function emitProposalVerbDeprecation(oldVerb, canonical) {
|
|
3597
|
+
if (isQuiet())
|
|
3598
|
+
return;
|
|
3599
|
+
process.stderr.write(`warning: 'akm ${oldVerb}' is deprecated and will be removed in 0.9.0. Use 'akm ${canonical}'.\n`);
|
|
3600
|
+
}
|
|
3601
|
+
const proposalCommand = defineCommand({
|
|
3602
|
+
meta: { name: "proposal", description: "Manage the proposal queue: list, show, diff, accept, reject, revert" },
|
|
2766
3603
|
args: {
|
|
2767
|
-
|
|
2768
|
-
type: "
|
|
2769
|
-
description: "
|
|
2770
|
-
required: true,
|
|
3604
|
+
status: {
|
|
3605
|
+
type: "string",
|
|
3606
|
+
description: "Filter by status (pending|accepted|rejected|reverted)",
|
|
2771
3607
|
},
|
|
2772
|
-
|
|
3608
|
+
ref: { type: "string", description: "Filter by asset ref (type:name)" },
|
|
3609
|
+
type: { type: "string", description: "Filter by asset type" },
|
|
3610
|
+
},
|
|
3611
|
+
subCommands: {
|
|
3612
|
+
list: proposalListCommand,
|
|
3613
|
+
show: proposalShowCommand,
|
|
3614
|
+
diff: proposalDiffCommand,
|
|
3615
|
+
accept: proposalAcceptCommand,
|
|
3616
|
+
reject: proposalRejectCommand,
|
|
3617
|
+
revert: proposalRevertCommand,
|
|
3618
|
+
drain: proposalDrainCommand,
|
|
2773
3619
|
},
|
|
2774
3620
|
run({ args }) {
|
|
2775
3621
|
return runWithJsonErrors(() => {
|
|
2776
|
-
|
|
2777
|
-
|
|
3622
|
+
// citty runs the group body even after a subcommand; short-circuit so the
|
|
3623
|
+
// default-to-list body only fires for bare `akm proposal [--status …]`.
|
|
3624
|
+
if (hasSubcommand(args, PROPOSAL_SUBCOMMAND_SET))
|
|
3625
|
+
return;
|
|
3626
|
+
const status = parseProposalStatus(args.status);
|
|
3627
|
+
const result = akmProposalList({
|
|
3628
|
+
status,
|
|
3629
|
+
ref: args.ref,
|
|
3630
|
+
type: args.type,
|
|
3631
|
+
includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
|
|
3632
|
+
});
|
|
3633
|
+
output("proposal-list", result);
|
|
2778
3634
|
});
|
|
2779
3635
|
},
|
|
2780
3636
|
});
|
|
3637
|
+
// Deprecated flat-verb aliases (removed 0.9.0). Each wraps the canonical command
|
|
3638
|
+
// body so bulk/guard logic is not duplicated.
|
|
3639
|
+
const proposalsCommand = defineCommand({
|
|
3640
|
+
meta: { name: "proposals", description: "DEPRECATED — use `akm proposal list`. Removed in 0.9.0." },
|
|
3641
|
+
args: proposalListCommand.args,
|
|
3642
|
+
run(ctx) {
|
|
3643
|
+
emitProposalVerbDeprecation("proposals", "proposal list");
|
|
3644
|
+
return proposalListCommand.run?.(ctx);
|
|
3645
|
+
},
|
|
3646
|
+
});
|
|
3647
|
+
const acceptCommand = defineCommand({
|
|
3648
|
+
meta: { name: "accept", description: "DEPRECATED — use `akm proposal accept`. Removed in 0.9.0." },
|
|
3649
|
+
args: proposalAcceptCommand.args,
|
|
3650
|
+
run(ctx) {
|
|
3651
|
+
emitProposalVerbDeprecation("accept", "proposal accept");
|
|
3652
|
+
return proposalAcceptCommand.run?.(ctx);
|
|
3653
|
+
},
|
|
3654
|
+
});
|
|
3655
|
+
const rejectCommand = defineCommand({
|
|
3656
|
+
meta: { name: "reject", description: "DEPRECATED — use `akm proposal reject`. Removed in 0.9.0." },
|
|
3657
|
+
args: proposalRejectCommand.args,
|
|
3658
|
+
run(ctx) {
|
|
3659
|
+
emitProposalVerbDeprecation("reject", "proposal reject");
|
|
3660
|
+
return proposalRejectCommand.run?.(ctx);
|
|
3661
|
+
},
|
|
3662
|
+
});
|
|
3663
|
+
const diffCommand = defineCommand({
|
|
3664
|
+
meta: { name: "diff", description: "DEPRECATED — use `akm proposal diff`. Removed in 0.9.0." },
|
|
3665
|
+
args: proposalDiffCommand.args,
|
|
3666
|
+
run(ctx) {
|
|
3667
|
+
emitProposalVerbDeprecation("diff", "proposal diff");
|
|
3668
|
+
return proposalDiffCommand.run?.(ctx);
|
|
3669
|
+
},
|
|
3670
|
+
});
|
|
3671
|
+
const revertCommand = defineCommand({
|
|
3672
|
+
meta: { name: "revert", description: "DEPRECATED — use `akm proposal revert`. Removed in 0.9.0." },
|
|
3673
|
+
args: proposalRevertCommand.args,
|
|
3674
|
+
run(ctx) {
|
|
3675
|
+
emitProposalVerbDeprecation("revert", "proposal revert");
|
|
3676
|
+
return proposalRevertCommand.run?.(ctx);
|
|
3677
|
+
},
|
|
3678
|
+
});
|
|
2781
3679
|
// ── distill (#228) ──────────────────────────────────────────────────────────
|
|
2782
3680
|
function parseProposalStatus(raw) {
|
|
2783
3681
|
if (raw === undefined)
|
|
@@ -2785,50 +3683,85 @@ function parseProposalStatus(raw) {
|
|
|
2785
3683
|
const trimmed = raw.trim();
|
|
2786
3684
|
if (!trimmed)
|
|
2787
3685
|
return undefined;
|
|
2788
|
-
if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected")
|
|
3686
|
+
if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected" || trimmed === "reverted") {
|
|
2789
3687
|
return trimmed;
|
|
2790
|
-
|
|
3688
|
+
}
|
|
3689
|
+
throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected, reverted.`, "INVALID_FLAG_VALUE");
|
|
2791
3690
|
}
|
|
2792
3691
|
const agentCommand = defineCommand({
|
|
2793
3692
|
meta: {
|
|
2794
3693
|
name: "agent",
|
|
2795
|
-
description: "Dispatch an agent
|
|
3694
|
+
description: "Dispatch an agent CLI (opencode, claude, …) with an optional agent asset that provides the system prompt, model, and tool policy. Use <agent-ref> to embody a stash agent, --model to override the model, and --prompt/--command/--workflow to provide the task.",
|
|
2796
3695
|
},
|
|
2797
3696
|
args: {
|
|
2798
3697
|
profile: {
|
|
2799
3698
|
type: "positional",
|
|
2800
|
-
description: "Agent profile
|
|
3699
|
+
description: "Agent profile / platform to use (opencode, claude, …)",
|
|
3700
|
+
required: false,
|
|
3701
|
+
},
|
|
3702
|
+
"agent-ref": {
|
|
3703
|
+
type: "positional",
|
|
3704
|
+
description: "Optional agent asset ref (e.g. agent:code-reviewer). Loads system prompt, model, and tool policy from the stash asset.",
|
|
2801
3705
|
required: false,
|
|
2802
3706
|
},
|
|
2803
|
-
prompt: { type: "string", description: "
|
|
2804
|
-
command: { type: "string", description: "Load
|
|
2805
|
-
workflow: {
|
|
3707
|
+
prompt: { type: "string", description: "Task prompt to pass to the agent" },
|
|
3708
|
+
command: { type: "string", description: "Load prompt from a command: asset" },
|
|
3709
|
+
workflow: { type: "string", description: "Load prompt from a workflow: asset" },
|
|
3710
|
+
model: {
|
|
2806
3711
|
type: "string",
|
|
2807
|
-
description: "
|
|
3712
|
+
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
|
|
2808
3713
|
},
|
|
2809
3714
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
2810
3715
|
},
|
|
2811
3716
|
async run({ args }) {
|
|
2812
3717
|
await runWithJsonErrors(async () => {
|
|
2813
3718
|
if (!args.profile) {
|
|
2814
|
-
throw new UsageError("Usage: akm agent <profile> [
|
|
3719
|
+
throw new UsageError("Usage: akm agent <profile> [<agent-ref>] [--prompt <text>] [--model <model>]", "MISSING_REQUIRED_ARGUMENT", "Provide the agent profile name. Available profiles are listed in profiles.agent.");
|
|
2815
3720
|
}
|
|
2816
|
-
|
|
2817
|
-
// template placeholders when a command/workflow ref is specified).
|
|
2818
|
-
const extraArgs = Array.isArray(args._) ? args._.filter((a) => a !== args.profile) : [];
|
|
2819
|
-
const timeoutRaw = args["timeout-ms"];
|
|
2820
|
-
const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
|
|
3721
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2821
3722
|
const config = loadConfig();
|
|
2822
|
-
const {
|
|
2823
|
-
|
|
3723
|
+
const { getDefaultLlmConfig } = await import("./core/config.js");
|
|
3724
|
+
// After 0.8.0 the agent block IS the loaded AkmConfig.
|
|
3725
|
+
const agentConfig = config;
|
|
3726
|
+
// Resolve agent asset ref → extract system prompt, model, and tool policy.
|
|
3727
|
+
const agentRef = getStringArg(args, "agent-ref");
|
|
3728
|
+
let systemPrompt;
|
|
3729
|
+
let assetModel;
|
|
3730
|
+
let assetTools;
|
|
3731
|
+
if (agentRef) {
|
|
3732
|
+
const { akmShowUnified } = await import("./commands/show.js");
|
|
3733
|
+
const asset = await akmShowUnified({ ref: agentRef, detail: "full" });
|
|
3734
|
+
systemPrompt = typeof asset.content === "string" ? asset.content : undefined;
|
|
3735
|
+
assetModel = typeof asset.modelHint === "string" ? asset.modelHint : undefined;
|
|
3736
|
+
assetTools = asset.toolPolicy;
|
|
3737
|
+
}
|
|
3738
|
+
// --model flag wins over the asset's modelHint.
|
|
3739
|
+
const model = getStringArg(args, "model") ?? assetModel;
|
|
3740
|
+
const promptText = getStringArg(args, "prompt");
|
|
3741
|
+
const commandRef = getStringArg(args, "command");
|
|
3742
|
+
const workflowRef = getStringArg(args, "workflow");
|
|
3743
|
+
// Only build a dispatch request when there is something to dispatch — a
|
|
3744
|
+
// prompt, an agent asset, or a model override. When none of these are
|
|
3745
|
+
// present the agent is launched interactively (no injected prompt, no
|
|
3746
|
+
// platform-specific flags beyond the profile's base args).
|
|
3747
|
+
const hasDispatchContent = !!(promptText ?? commandRef ?? workflowRef ?? systemPrompt ?? model ?? assetTools);
|
|
2824
3748
|
const result = await akmAgentDispatch({
|
|
2825
3749
|
profileName: String(args.profile),
|
|
2826
|
-
prompt:
|
|
2827
|
-
commandRef
|
|
2828
|
-
workflowRef
|
|
2829
|
-
args: extraArgs.length > 0 ? extraArgs : undefined,
|
|
3750
|
+
prompt: promptText,
|
|
3751
|
+
commandRef,
|
|
3752
|
+
workflowRef,
|
|
2830
3753
|
agentConfig,
|
|
2831
|
-
llmConfig: config
|
|
3754
|
+
llmConfig: getDefaultLlmConfig(config),
|
|
3755
|
+
...(hasDispatchContent
|
|
3756
|
+
? {
|
|
3757
|
+
dispatch: {
|
|
3758
|
+
prompt: promptText ?? "",
|
|
3759
|
+
systemPrompt,
|
|
3760
|
+
model,
|
|
3761
|
+
tools: assetTools,
|
|
3762
|
+
},
|
|
3763
|
+
}
|
|
3764
|
+
: {}),
|
|
2832
3765
|
...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
|
|
2833
3766
|
});
|
|
2834
3767
|
output("agent-result", result);
|
|
@@ -2841,159 +3774,29 @@ const agentCommand = defineCommand({
|
|
|
2841
3774
|
const lintCommand = defineCommand({
|
|
2842
3775
|
meta: {
|
|
2843
3776
|
name: "lint",
|
|
2844
|
-
description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues.",
|
|
3777
|
+
description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues. Exits 0 on success regardless of findings; use --fail-on-flagged for CI fail-on-finding behavior.",
|
|
2845
3778
|
},
|
|
2846
3779
|
args: {
|
|
2847
3780
|
fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
|
|
2848
3781
|
dir: { type: "string", description: "Override stash root directory (default: from config)" },
|
|
3782
|
+
"fail-on-flagged": {
|
|
3783
|
+
type: "boolean",
|
|
3784
|
+
description: "Exit non-zero when summary.flagged > 0 (CI-friendly). Default: exit 0 regardless of findings.",
|
|
3785
|
+
default: false,
|
|
3786
|
+
},
|
|
2849
3787
|
},
|
|
2850
3788
|
async run({ args }) {
|
|
2851
3789
|
await runWithJsonErrors(async () => {
|
|
2852
3790
|
const result = akmLint({
|
|
2853
3791
|
fix: args.fix ?? false,
|
|
2854
|
-
dir:
|
|
3792
|
+
dir: getStringArg(args, "dir"),
|
|
2855
3793
|
});
|
|
2856
3794
|
output("lint", result);
|
|
2857
|
-
if (
|
|
3795
|
+
if (args["fail-on-flagged"] && result.summary.flagged > 0)
|
|
2858
3796
|
process.exit(EXIT_GENERAL);
|
|
2859
3797
|
});
|
|
2860
3798
|
},
|
|
2861
3799
|
});
|
|
2862
|
-
const improveCommand = defineCommand({
|
|
2863
|
-
meta: {
|
|
2864
|
-
name: "improve",
|
|
2865
|
-
description: "Analyze existing AKM assets and generate improvement proposals; also consolidates memories when llm.features.memory_consolidation is enabled",
|
|
2866
|
-
},
|
|
2867
|
-
args: {
|
|
2868
|
-
scope: {
|
|
2869
|
-
type: "positional",
|
|
2870
|
-
description: "Optional asset type or asset ref to improve",
|
|
2871
|
-
required: false,
|
|
2872
|
-
},
|
|
2873
|
-
task: { type: "string", description: "Add extra guidance for this improvement pass" },
|
|
2874
|
-
"dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
|
|
2875
|
-
target: { type: "string", description: "Override the write target for accepted proposals" },
|
|
2876
|
-
"auto-accept": {
|
|
2877
|
-
type: "string",
|
|
2878
|
-
description: "Automatically accept low-risk proposals (only 'safe' is supported)",
|
|
2879
|
-
},
|
|
2880
|
-
limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
|
|
2881
|
-
"timeout-ms": {
|
|
2882
|
-
type: "string",
|
|
2883
|
-
description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
|
|
2884
|
-
},
|
|
2885
|
-
"ignore-cooldown": {
|
|
2886
|
-
type: "boolean",
|
|
2887
|
-
description: "Ignore all cooldown periods (equivalent to --reflect-cooldown-days 0 --distill-cooldown-days 0 --consolidate-cooldown-days 0)",
|
|
2888
|
-
default: false,
|
|
2889
|
-
},
|
|
2890
|
-
"reflect-cooldown-days": {
|
|
2891
|
-
type: "string",
|
|
2892
|
-
description: "Override reflect cooldown for this run only (default: 7, 0 to disable)",
|
|
2893
|
-
},
|
|
2894
|
-
"distill-cooldown-days": {
|
|
2895
|
-
type: "string",
|
|
2896
|
-
description: "Override distill cooldown for this run only (default: 30, 0 to disable)",
|
|
2897
|
-
},
|
|
2898
|
-
"consolidate-cooldown-days": {
|
|
2899
|
-
type: "string",
|
|
2900
|
-
description: "Override consolidate cooldown for this run only (default: 14, 0 to disable)",
|
|
2901
|
-
},
|
|
2902
|
-
"consolidate-recovery": {
|
|
2903
|
-
type: "string",
|
|
2904
|
-
description: "How to handle stale/incomplete consolidation journals: abort (default) or clean (remove stale journal artifacts)",
|
|
2905
|
-
},
|
|
2906
|
-
"require-feedback-signal": {
|
|
2907
|
-
type: "boolean",
|
|
2908
|
-
description: "Only process assets with recent feedback signals (disables retrieval fallback)",
|
|
2909
|
-
default: false,
|
|
2910
|
-
},
|
|
2911
|
-
"min-retrieval-count": {
|
|
2912
|
-
type: "string",
|
|
2913
|
-
description: "Minimum retrieval count for zero-feedback fallback eligibility (default: 5)",
|
|
2914
|
-
},
|
|
2915
|
-
},
|
|
2916
|
-
async run({ args }) {
|
|
2917
|
-
await runWithJsonErrors(async () => {
|
|
2918
|
-
const autoAcceptRaw = getHyphenatedArg(args, "auto-accept");
|
|
2919
|
-
if (autoAcceptRaw !== undefined && autoAcceptRaw !== "safe") {
|
|
2920
|
-
throw new UsageError("--auto-accept only supports the value 'safe'.", "INVALID_FLAG_VALUE");
|
|
2921
|
-
}
|
|
2922
|
-
const targetArg = typeof args.target === "string" && args.target.trim() ? args.target.trim() : undefined;
|
|
2923
|
-
const taskArg = typeof args.task === "string" && args.task.trim() ? args.task : undefined;
|
|
2924
|
-
const dryRun = getHyphenatedBoolean(args, "dry-run");
|
|
2925
|
-
const autoAccept = autoAcceptRaw === "safe" ? "safe" : undefined;
|
|
2926
|
-
const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
|
|
2927
|
-
const timeoutRaw = getHyphenatedArg(args, "timeout-ms");
|
|
2928
|
-
const timeoutMs = timeoutRaw !== undefined ? parseInt(timeoutRaw, 10) : undefined;
|
|
2929
|
-
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
|
2930
|
-
throw new UsageError(`Invalid --timeout-ms value: "${timeoutRaw}". Must be a positive integer.`);
|
|
2931
|
-
}
|
|
2932
|
-
const parseNonNegativeCooldownDays = (raw, flagName) => {
|
|
2933
|
-
if (raw === undefined)
|
|
2934
|
-
return undefined;
|
|
2935
|
-
if (!/^\d+$/.test(raw.trim())) {
|
|
2936
|
-
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a non-negative integer.`);
|
|
2937
|
-
}
|
|
2938
|
-
return parseInt(raw, 10);
|
|
2939
|
-
};
|
|
2940
|
-
const ignoreCooldown = getHyphenatedBoolean(args, "ignore-cooldown");
|
|
2941
|
-
const reflectCooldownRaw = getHyphenatedArg(args, "reflect-cooldown-days");
|
|
2942
|
-
const reflectCooldownDays = ignoreCooldown
|
|
2943
|
-
? 0
|
|
2944
|
-
: parseNonNegativeCooldownDays(reflectCooldownRaw, "--reflect-cooldown-days");
|
|
2945
|
-
const distillCooldownRaw = getHyphenatedArg(args, "distill-cooldown-days");
|
|
2946
|
-
const distillCooldownDays = ignoreCooldown
|
|
2947
|
-
? 0
|
|
2948
|
-
: parseNonNegativeCooldownDays(distillCooldownRaw, "--distill-cooldown-days");
|
|
2949
|
-
const consolidateCooldownRaw = getHyphenatedArg(args, "consolidate-cooldown-days");
|
|
2950
|
-
const consolidateCooldownDays = ignoreCooldown
|
|
2951
|
-
? 0
|
|
2952
|
-
: parseNonNegativeCooldownDays(consolidateCooldownRaw, "--consolidate-cooldown-days");
|
|
2953
|
-
const consolidateRecoveryRaw = getHyphenatedArg(args, "consolidate-recovery");
|
|
2954
|
-
const consolidateRecovery = consolidateRecoveryRaw === undefined
|
|
2955
|
-
? undefined
|
|
2956
|
-
: consolidateRecoveryRaw.trim().toLowerCase();
|
|
2957
|
-
if (consolidateRecovery !== undefined && consolidateRecovery !== "abort" && consolidateRecovery !== "clean") {
|
|
2958
|
-
throw new UsageError(`Invalid --consolidate-recovery value: "${consolidateRecoveryRaw}". Must be one of: abort, clean.`, "INVALID_FLAG_VALUE");
|
|
2959
|
-
}
|
|
2960
|
-
const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
|
|
2961
|
-
const minRetrievalCount = parseNonNegativeCooldownDays(minRetrievalCountRaw, "--min-retrieval-count");
|
|
2962
|
-
const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
|
|
2963
|
-
const improveLogFile = path.join(getCacheDir(), "logs", "improve", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
2964
|
-
setLogFile(improveLogFile);
|
|
2965
|
-
let improveResult;
|
|
2966
|
-
try {
|
|
2967
|
-
improveResult = await akmImprove({
|
|
2968
|
-
scope: typeof args.scope === "string" && args.scope.trim() ? args.scope : undefined,
|
|
2969
|
-
task: taskArg,
|
|
2970
|
-
dryRun,
|
|
2971
|
-
target: targetArg,
|
|
2972
|
-
autoAccept,
|
|
2973
|
-
...(limitRaw !== undefined ? { limit: limitRaw } : {}),
|
|
2974
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
2975
|
-
...(reflectCooldownDays !== undefined ? { reflectCooldownDays } : {}),
|
|
2976
|
-
...(distillCooldownDays !== undefined ? { distillCooldownDays } : {}),
|
|
2977
|
-
...(consolidateCooldownDays !== undefined ? { consolidateCooldownDays } : {}),
|
|
2978
|
-
...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
|
|
2979
|
-
...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
|
|
2980
|
-
consolidateOptions: {
|
|
2981
|
-
target: targetArg,
|
|
2982
|
-
dryRun,
|
|
2983
|
-
autoAccept,
|
|
2984
|
-
task: taskArg,
|
|
2985
|
-
...(consolidateRecovery !== undefined ? { recoveryMode: consolidateRecovery } : {}),
|
|
2986
|
-
},
|
|
2987
|
-
});
|
|
2988
|
-
}
|
|
2989
|
-
finally {
|
|
2990
|
-
clearLogFile();
|
|
2991
|
-
}
|
|
2992
|
-
output("improve", improveResult);
|
|
2993
|
-
process.exit(0);
|
|
2994
|
-
});
|
|
2995
|
-
},
|
|
2996
|
-
});
|
|
2997
3800
|
const proposeCommand = defineCommand({
|
|
2998
3801
|
meta: {
|
|
2999
3802
|
name: "propose",
|
|
@@ -3024,14 +3827,13 @@ const proposeCommand = defineCommand({
|
|
|
3024
3827
|
throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
|
|
3025
3828
|
}
|
|
3026
3829
|
const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
|
|
3027
|
-
const
|
|
3028
|
-
const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
|
|
3830
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
3029
3831
|
const result = await akmPropose({
|
|
3030
3832
|
type: String(args.type),
|
|
3031
3833
|
name: String(args.name),
|
|
3032
3834
|
task: taskText,
|
|
3033
|
-
profile:
|
|
3034
|
-
...(timeoutMs !== undefined
|
|
3835
|
+
profile: getStringArg(args, "profile"),
|
|
3836
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
3035
3837
|
});
|
|
3036
3838
|
output("propose", result);
|
|
3037
3839
|
if (result.ok === false) {
|
|
@@ -3052,7 +3854,16 @@ const TASKS_SUBCOMMAND_SET = new Set([
|
|
|
3052
3854
|
"sync",
|
|
3053
3855
|
"doctor",
|
|
3054
3856
|
]);
|
|
3055
|
-
const GRAPH_SUBCOMMAND_SET = new Set([
|
|
3857
|
+
const GRAPH_SUBCOMMAND_SET = new Set([
|
|
3858
|
+
"summary",
|
|
3859
|
+
"entities",
|
|
3860
|
+
"entity",
|
|
3861
|
+
"relations",
|
|
3862
|
+
"related",
|
|
3863
|
+
"orphans",
|
|
3864
|
+
"export",
|
|
3865
|
+
"update",
|
|
3866
|
+
]);
|
|
3056
3867
|
const tasksAddCommand = defineCommand({
|
|
3057
3868
|
meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
|
|
3058
3869
|
args: {
|
|
@@ -3063,8 +3874,14 @@ const tasksAddCommand = defineCommand({
|
|
|
3063
3874
|
type: "string",
|
|
3064
3875
|
description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
|
|
3065
3876
|
},
|
|
3066
|
-
|
|
3877
|
+
command: {
|
|
3878
|
+
type: "string",
|
|
3879
|
+
description: 'Shell command to run on the schedule (no AI agent), e.g. "akm improve --auto-accept safe". Split on whitespace; quote the whole flag value.',
|
|
3880
|
+
},
|
|
3881
|
+
profile: { type: "string", description: "Agent profile to use for prompt targets (default: defaults.agent)" },
|
|
3067
3882
|
params: { type: "string", description: "Workflow params as a JSON object" },
|
|
3883
|
+
name: { type: "string", description: "Human-readable name for the task" },
|
|
3884
|
+
"when-to-use": { type: "string", description: "Guidance on when this task runs or should be used" },
|
|
3068
3885
|
description: { type: "string", description: "Human-readable description" },
|
|
3069
3886
|
tags: { type: "string", description: "Comma-separated tags" },
|
|
3070
3887
|
disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
|
|
@@ -3077,8 +3894,11 @@ const tasksAddCommand = defineCommand({
|
|
|
3077
3894
|
schedule: args.schedule,
|
|
3078
3895
|
workflow: args.workflow,
|
|
3079
3896
|
prompt: args.prompt,
|
|
3897
|
+
command: args.command,
|
|
3080
3898
|
profile: args.profile,
|
|
3081
3899
|
params: args.params,
|
|
3900
|
+
name: args.name,
|
|
3901
|
+
when_to_use: getHyphenatedArg(args, "when-to-use"),
|
|
3082
3902
|
description: args.description,
|
|
3083
3903
|
tags: args.tags
|
|
3084
3904
|
? args.tags
|
|
@@ -3124,28 +3944,25 @@ const tasksRemoveCommand = defineCommand({
|
|
|
3124
3944
|
});
|
|
3125
3945
|
},
|
|
3126
3946
|
});
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
})
|
|
3136
|
-
|
|
3137
|
-
});
|
|
3138
|
-
const
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
});
|
|
3147
|
-
},
|
|
3148
|
-
});
|
|
3947
|
+
function makeTasksToggleCommand(enabled) {
|
|
3948
|
+
const verb = enabled ? "enable" : "disable";
|
|
3949
|
+
const description = enabled
|
|
3950
|
+
? "Enable a previously-disabled task"
|
|
3951
|
+
: "Disable a task in the OS scheduler without removing the file";
|
|
3952
|
+
return defineCommand({
|
|
3953
|
+
meta: { name: verb, description },
|
|
3954
|
+
args: { id: { type: "positional", description: "Task id", required: true } },
|
|
3955
|
+
async run({ args }) {
|
|
3956
|
+
await runWithJsonErrors(async () => {
|
|
3957
|
+
const { id } = parseTaskRef(args.id);
|
|
3958
|
+
const result = await akmTasksSetEnabled(id, enabled);
|
|
3959
|
+
output(`tasks-${verb}`, result);
|
|
3960
|
+
});
|
|
3961
|
+
},
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
const tasksEnableCommand = makeTasksToggleCommand(true);
|
|
3965
|
+
const tasksDisableCommand = makeTasksToggleCommand(false);
|
|
3149
3966
|
const tasksRunCommand = defineCommand({
|
|
3150
3967
|
meta: {
|
|
3151
3968
|
name: "run",
|
|
@@ -3203,6 +4020,7 @@ const tasksDoctorCommand = defineCommand({
|
|
|
3203
4020
|
const tasksCommand = defineCommand({
|
|
3204
4021
|
meta: {
|
|
3205
4022
|
name: "tasks",
|
|
4023
|
+
alias: "task",
|
|
3206
4024
|
description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
|
|
3207
4025
|
},
|
|
3208
4026
|
subCommands: {
|
|
@@ -3226,16 +4044,43 @@ const tasksCommand = defineCommand({
|
|
|
3226
4044
|
});
|
|
3227
4045
|
},
|
|
3228
4046
|
});
|
|
3229
|
-
const main = defineCommand({
|
|
4047
|
+
export const main = defineCommand({
|
|
3230
4048
|
meta: {
|
|
3231
4049
|
name: "akm",
|
|
3232
4050
|
version: pkgVersion,
|
|
3233
|
-
description: "Agent
|
|
4051
|
+
description: "Agent Knowledge Management — search, show, and manage assets from your stash.\n\n" +
|
|
4052
|
+
"Exit codes:\n" +
|
|
4053
|
+
" 0 success\n" +
|
|
4054
|
+
" 1 general error / not found\n" +
|
|
4055
|
+
" 2 usage error\n" +
|
|
4056
|
+
" 4 health warn (akm health only)\n" +
|
|
4057
|
+
" 78 config error",
|
|
3234
4058
|
},
|
|
3235
4059
|
args: {
|
|
3236
4060
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
|
|
3237
|
-
detail: {
|
|
3238
|
-
|
|
4061
|
+
detail: {
|
|
4062
|
+
type: "string",
|
|
4063
|
+
description: "Detail level (verbosity): brief|normal|full. Default: brief.",
|
|
4064
|
+
default: "brief",
|
|
4065
|
+
},
|
|
4066
|
+
shape: {
|
|
4067
|
+
type: "string",
|
|
4068
|
+
description: "Output projection: human|agent|summary. 'agent' trims to agent-essential fields; " +
|
|
4069
|
+
"'summary' is only valid on 'akm show'. Default: human.",
|
|
4070
|
+
},
|
|
4071
|
+
"for-agent": {
|
|
4072
|
+
type: "boolean",
|
|
4073
|
+
description: "DEPRECATED alias for '--shape agent' (removed 0.9.0).",
|
|
4074
|
+
default: false,
|
|
4075
|
+
},
|
|
4076
|
+
quiet: {
|
|
4077
|
+
type: "boolean",
|
|
4078
|
+
alias: "q",
|
|
4079
|
+
description: "Suppress non-essential stderr output (banners, spinners, progress info). " +
|
|
4080
|
+
"Safety-critical output is never suppressed: errors, destructive-action confirmation prompts, " +
|
|
4081
|
+
"and auto-migration banners always appear regardless of --quiet.",
|
|
4082
|
+
default: false,
|
|
4083
|
+
},
|
|
3239
4084
|
verbose: {
|
|
3240
4085
|
type: "boolean",
|
|
3241
4086
|
description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
|
|
@@ -3249,6 +4094,7 @@ const main = defineCommand({
|
|
|
3249
4094
|
health: healthCommand,
|
|
3250
4095
|
info: infoCommand,
|
|
3251
4096
|
graph: graphCommand,
|
|
4097
|
+
db: dbCommand,
|
|
3252
4098
|
add: addCommand,
|
|
3253
4099
|
list: listCommand,
|
|
3254
4100
|
remove: removeCommand,
|
|
@@ -3260,6 +4106,8 @@ const main = defineCommand({
|
|
|
3260
4106
|
workflow: workflowCommand,
|
|
3261
4107
|
remember: rememberCommand,
|
|
3262
4108
|
import: importKnowledgeCommand,
|
|
4109
|
+
sync: syncCommand,
|
|
4110
|
+
// Deprecated alias (removed 0.9.0) — delegates to `sync`.
|
|
3263
4111
|
save: saveCommand,
|
|
3264
4112
|
clone: cloneCommand,
|
|
3265
4113
|
registry: registryCommand,
|
|
@@ -3269,24 +4117,33 @@ const main = defineCommand({
|
|
|
3269
4117
|
feedback: feedbackCommand,
|
|
3270
4118
|
history: historyCommand,
|
|
3271
4119
|
events: eventsCommand,
|
|
4120
|
+
lessons: lessonsCommand,
|
|
3272
4121
|
agent: agentCommand,
|
|
3273
4122
|
lint: lintCommand,
|
|
3274
4123
|
improve: improveCommand,
|
|
4124
|
+
extract: extractCommand,
|
|
3275
4125
|
propose: proposeCommand,
|
|
4126
|
+
proposal: proposalCommand,
|
|
4127
|
+
// Deprecated flat verbs (removed 0.9.0) — delegate to `proposal <verb>`.
|
|
3276
4128
|
proposals: proposalsCommand,
|
|
3277
4129
|
accept: acceptCommand,
|
|
3278
4130
|
reject: rejectCommand,
|
|
3279
4131
|
diff: diffCommand,
|
|
4132
|
+
revert: revertCommand,
|
|
3280
4133
|
help: helpCommand,
|
|
3281
4134
|
hints: hintsCommand,
|
|
3282
4135
|
completions: completionsCommand,
|
|
4136
|
+
env: envCommand,
|
|
3283
4137
|
vault: vaultCommand,
|
|
4138
|
+
secret: secretCommand,
|
|
3284
4139
|
wiki: wikiCommand,
|
|
3285
4140
|
tasks: tasksCommand,
|
|
3286
4141
|
},
|
|
3287
4142
|
});
|
|
3288
|
-
const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
|
|
4143
|
+
const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset", "enable", "disable"]);
|
|
4144
|
+
const ENV_SUBCOMMAND_SET = new Set(["list", "path", "export", "run", "create", "remove"]);
|
|
3289
4145
|
const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
|
|
4146
|
+
const SECRET_SUBCOMMAND_SET = new Set(["list", "path", "run", "set", "remove"]);
|
|
3290
4147
|
const WIKI_SUBCOMMAND_SET = new Set([
|
|
3291
4148
|
"create",
|
|
3292
4149
|
"register",
|
|
@@ -3300,74 +4157,90 @@ const WIKI_SUBCOMMAND_SET = new Set([
|
|
|
3300
4157
|
"ingest",
|
|
3301
4158
|
]);
|
|
3302
4159
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
//
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
//
|
|
3310
|
-
//
|
|
3311
|
-
// `
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
? error.code
|
|
3324
|
-
: undefined;
|
|
3325
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
3326
|
-
process.exit(exitCode);
|
|
3327
|
-
}
|
|
3328
|
-
runMain(main);
|
|
3329
|
-
function classifyExitCode(error) {
|
|
3330
|
-
if (error instanceof UsageError)
|
|
3331
|
-
return EXIT_USAGE;
|
|
3332
|
-
if (error instanceof ConfigError)
|
|
3333
|
-
return EXIT_CONFIG;
|
|
3334
|
-
if (error instanceof NotFoundError)
|
|
3335
|
-
return EXIT_GENERAL;
|
|
3336
|
-
return EXIT_GENERAL;
|
|
3337
|
-
}
|
|
3338
|
-
async function runWithJsonErrors(fn) {
|
|
4160
|
+
// Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
|
|
4161
|
+
// the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
|
|
4162
|
+
// status — advisories fired but no hard failure; chosen to avoid colliding with
|
|
4163
|
+
// GENERAL (1) and USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail.
|
|
4164
|
+
const EXIT_GENERAL = EXIT_CODES.GENERAL;
|
|
4165
|
+
const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
|
|
4166
|
+
// Only run the CLI when this module is the direct entry point. When it is
|
|
4167
|
+
// imported (e.g. by the in-process test harness in tests/_helpers/cli.ts),
|
|
4168
|
+
// `import.meta.main` is false and we skip all startup side effects (argv
|
|
4169
|
+
// mutation, output-mode init, index cleanup, banner, runMain) so importers
|
|
4170
|
+
// can drive the `main` command themselves without the process exiting.
|
|
4171
|
+
if (import.meta.main) {
|
|
4172
|
+
// citty reads process.argv directly and does not accept a custom argv array,
|
|
4173
|
+
// so we must replace process.argv with the normalized version before runMain.
|
|
4174
|
+
process.argv = normalizeShowArgv(process.argv);
|
|
4175
|
+
// Resolve output mode once at startup from the (normalized) argv and persisted
|
|
4176
|
+
// config. All subsequent output() calls read from this in-memory singleton.
|
|
4177
|
+
// `initOutputMode` can throw a UsageError when --format/--detail values are
|
|
4178
|
+
// invalid; surface it through the same JSON-error path the rest of the CLI uses
|
|
4179
|
+
// rather than letting the raw exception escape with a stack trace.
|
|
3339
4180
|
try {
|
|
3340
4181
|
applyEarlyStderrFlags(process.argv);
|
|
3341
|
-
|
|
4182
|
+
initOutputMode(process.argv, loadConfig().output ?? {});
|
|
3342
4183
|
}
|
|
3343
4184
|
catch (error) {
|
|
3344
|
-
|
|
3345
|
-
const hint = extractHint(error);
|
|
3346
|
-
const exitCode = classifyExitCode(error);
|
|
3347
|
-
// Surface machine-readable error code from typed errors when present so
|
|
3348
|
-
// scripts can branch on `.code` instead of message-string matching.
|
|
3349
|
-
const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
|
|
3350
|
-
? error.code
|
|
3351
|
-
: undefined;
|
|
3352
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
3353
|
-
process.exit(exitCode);
|
|
4185
|
+
emitJsonError(error);
|
|
3354
4186
|
}
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
return error.hint();
|
|
4187
|
+
// `--shape summary` is only meaningful on `akm show`. Reject it up front for
|
|
4188
|
+
// every other command so a write command (e.g. `akm proposal accept …`)
|
|
4189
|
+
// fails fast BEFORE performing its mutation, rather than throwing at
|
|
4190
|
+
// output-shaping time after the side effect has already happened. The
|
|
4191
|
+
// shape-registry gate in shapeForCommand() remains as defense-in-depth (and
|
|
4192
|
+
// covers the in-process test harness, which skips this startup block).
|
|
4193
|
+
if (getOutputMode().shape === "summary" && process.argv[2] !== "show") {
|
|
4194
|
+
emitJsonError(new UsageError("'--shape summary' is only valid on 'akm show'.", "INVALID_SHAPE_VALUE"));
|
|
3364
4195
|
}
|
|
3365
|
-
|
|
4196
|
+
// One-time cleanup of stale 0.7.x index file at the old cache location.
|
|
4197
|
+
// 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
|
|
4198
|
+
// If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
|
|
4199
|
+
// user isn't confused by a phantom DB. Best-effort; never fatal.
|
|
4200
|
+
try {
|
|
4201
|
+
const oldIndexPath = path.join(getCacheDir(), "index.db");
|
|
4202
|
+
if (fs.existsSync(oldIndexPath)) {
|
|
4203
|
+
fs.rmSync(oldIndexPath, { force: true });
|
|
4204
|
+
fs.rmSync(`${oldIndexPath}-shm`, { force: true });
|
|
4205
|
+
fs.rmSync(`${oldIndexPath}-wal`, { force: true });
|
|
4206
|
+
warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
catch {
|
|
4210
|
+
// Non-fatal; one-time warning only.
|
|
4211
|
+
}
|
|
4212
|
+
// First-time-user breadcrumb: when run with no subcommand AND no config
|
|
4213
|
+
// exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
|
|
4214
|
+
// above citty's auto-generated usage block. Triggers only when stdin/stderr
|
|
4215
|
+
// are interactive (so JSON-output users / CI consumers see nothing extra)
|
|
4216
|
+
// and stays silent for any flag-only invocation citty would handle itself
|
|
4217
|
+
// (--help, --version).
|
|
4218
|
+
(function maybePrintFirstTimeBanner() {
|
|
4219
|
+
const argv = process.argv.slice(2);
|
|
4220
|
+
// Fire only on completely bare `akm` invocation. Any explicit flag or
|
|
4221
|
+
// subcommand means the user knows what they want.
|
|
4222
|
+
if (argv.length > 0)
|
|
4223
|
+
return;
|
|
4224
|
+
if (!process.stderr.isTTY)
|
|
4225
|
+
return;
|
|
4226
|
+
try {
|
|
4227
|
+
if (fs.existsSync(getConfigPath()))
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
catch {
|
|
4231
|
+
// If we can't resolve the config path, assume non-fresh and stay silent.
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
|
|
4235
|
+
})();
|
|
4236
|
+
runMain(main);
|
|
3366
4237
|
}
|
|
3367
4238
|
// ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
|
|
3368
4239
|
function loadHints(detail = "normal") {
|
|
3369
|
-
|
|
3370
|
-
const
|
|
4240
|
+
// `brief` → the short AGENTS.md guide; `normal`/`full` → the complete guide.
|
|
4241
|
+
const wantFull = detail !== "brief";
|
|
4242
|
+
const filename = wantFull ? "AGENTS.full.md" : "AGENTS.md";
|
|
4243
|
+
const fallback = wantFull ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
|
|
3371
4244
|
// Try reading from the docs/ directory (works in dev and when installed via npm)
|
|
3372
4245
|
try {
|
|
3373
4246
|
const docsPath = path.resolve(import.meta.dir ?? __dirname, `../docs/agents/${filename}`);
|