akm-cli 0.8.0-rc1 → 0.8.0
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} +191 -3
- package/README.md +22 -6
- package/SECURITY.md +93 -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 +2162 -1258
- 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 +1533 -144
- 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 +204 -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 +977 -51
- package/dist/commands/help/help-accept.md +6 -3
- package/dist/commands/help/help-improve.md +36 -8
- package/dist/commands/help/help-proposals.md +7 -4
- package/dist/commands/help/help-reject.md +5 -2
- 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 +184 -0
- package/dist/commands/improve-result-file.js +167 -0
- package/dist/commands/improve.js +1725 -332
- package/dist/commands/info.js +3 -0
- 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 +233 -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 +17 -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 +662 -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 +84 -14
- 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 +114 -48
- 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 +401 -30
- 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/llm/prompts/extract-session.md +80 -0
- package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
- package/dist/output/cli-hints-full.md +60 -32
- package/dist/output/cli-hints-short.md +10 -7
- package/dist/output/cli-hints.js +5 -2
- 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 +3 -0
- package/dist/tasks/backends/schtasks.js +3 -0
- 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 +3 -0
- package/dist/wiki/wiki.js +3 -0
- package/dist/workflows/authoring.js +3 -0
- 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/install-audit.js +0 -385
- package/dist/commands/vault.js +0 -307
- 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/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,196 +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;
|
|
1925
|
+
// Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
|
|
1926
|
+
const markerPath = full.replace(/\.env$/, ".sensitive");
|
|
1927
|
+
if (fs.existsSync(markerPath))
|
|
1928
|
+
continue;
|
|
2083
1929
|
const { keys } = listKeysFn(full);
|
|
2084
|
-
result.push({ ref:
|
|
1930
|
+
result.push({ ref: makeEnvRef(canonical, source), path: full, keys });
|
|
2085
1931
|
}
|
|
2086
1932
|
};
|
|
2087
|
-
walk(
|
|
1933
|
+
walk(root);
|
|
2088
1934
|
}
|
|
2089
1935
|
return result;
|
|
2090
1936
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
if (fs.existsSync(full.absPath)) {
|
|
2094
|
-
return { ref: makeVaultRef(full.name, full.source) };
|
|
2095
|
-
}
|
|
2096
|
-
const slashIndex = target.lastIndexOf("/");
|
|
2097
|
-
if (slashIndex <= 0) {
|
|
2098
|
-
throw new NotFoundError(`Vault not found: ${target.includes(":") ? target : `vault:${target}`}`);
|
|
2099
|
-
}
|
|
2100
|
-
const refPart = target.slice(0, slashIndex);
|
|
2101
|
-
const key = target.slice(slashIndex + 1).trim();
|
|
2102
|
-
if (!key) {
|
|
2103
|
-
throw new UsageError("Expected vault run target in the form <ref> or <ref/KEY>.");
|
|
2104
|
-
}
|
|
2105
|
-
const resolved = resolveVaultPath(refPart);
|
|
2106
|
-
if (!fs.existsSync(resolved.absPath)) {
|
|
2107
|
-
throw new NotFoundError(`Vault not found: ${makeVaultRef(resolved.name, resolved.source)}`);
|
|
2108
|
-
}
|
|
2109
|
-
return { ref: makeVaultRef(resolved.name, resolved.source), key };
|
|
2110
|
-
}
|
|
2111
|
-
const vaultListCommand = defineCommand({
|
|
2112
|
-
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)" },
|
|
2113
1939
|
run() {
|
|
2114
1940
|
return runWithJsonErrors(async () => {
|
|
2115
|
-
const { listKeys } = await import("./commands/
|
|
2116
|
-
|
|
2117
|
-
output("vault-list", { vaults });
|
|
2118
|
-
});
|
|
2119
|
-
},
|
|
2120
|
-
});
|
|
2121
|
-
const vaultCreateCommand = defineCommand({
|
|
2122
|
-
meta: { name: "create", description: "Create an empty vault file (no-op if it already exists)" },
|
|
2123
|
-
args: {
|
|
2124
|
-
name: { type: "positional", description: "Vault name (e.g. prod) — file becomes <name>.env", required: true },
|
|
2125
|
-
},
|
|
2126
|
-
run({ args }) {
|
|
2127
|
-
return runWithJsonErrors(async () => {
|
|
2128
|
-
const { createVault } = await import("./commands/vault.js");
|
|
2129
|
-
const { name, absPath, source } = resolveVaultPath(args.name);
|
|
2130
|
-
createVault(absPath);
|
|
2131
|
-
output("vault-create", { ref: makeVaultRef(name, source), path: absPath });
|
|
1941
|
+
const { listKeys } = await import("./commands/env.js");
|
|
1942
|
+
output("env-list", { envs: listEnvsRecursive(listKeys) });
|
|
2132
1943
|
});
|
|
2133
1944
|
},
|
|
2134
1945
|
});
|
|
2135
|
-
const
|
|
1946
|
+
const envCreateCommand = defineCommand({
|
|
2136
1947
|
meta: {
|
|
2137
|
-
name: "
|
|
2138
|
-
description:
|
|
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.",
|
|
2139
1950
|
},
|
|
2140
1951
|
args: {
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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 },
|
|
1955
|
+
sensitive: {
|
|
1956
|
+
type: "boolean",
|
|
1957
|
+
description: "Exclude this env file from env list output and the search index",
|
|
1958
|
+
default: false,
|
|
2147
1959
|
},
|
|
2148
|
-
comment: { type: "string", description: "Optional comment written above the key line", required: false },
|
|
2149
1960
|
},
|
|
2150
1961
|
run({ args }) {
|
|
2151
1962
|
return runWithJsonErrors(async () => {
|
|
2152
|
-
const {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
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);
|
|
2160
2003
|
}
|
|
2161
2004
|
else {
|
|
2162
|
-
|
|
2163
|
-
realValue = args.value ?? "";
|
|
2005
|
+
createEnv(absPath);
|
|
2164
2006
|
}
|
|
2165
|
-
|
|
2166
|
-
|
|
2007
|
+
if (args.sensitive) {
|
|
2008
|
+
const markerPath = absPath.replace(/\.env$/, ".sensitive");
|
|
2009
|
+
if (!fs.existsSync(markerPath)) {
|
|
2010
|
+
fs.writeFileSync(markerPath, "", { mode: 0o600 });
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
output("env-create", { ref: makeEnvRef(parsed.name, source) });
|
|
2167
2014
|
});
|
|
2168
2015
|
},
|
|
2169
2016
|
});
|
|
2170
|
-
const
|
|
2171
|
-
meta: {
|
|
2017
|
+
const envPathCommand = defineCommand({
|
|
2018
|
+
meta: {
|
|
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.",
|
|
2021
|
+
},
|
|
2172
2022
|
args: {
|
|
2173
|
-
ref: { type: "positional", description: "
|
|
2174
|
-
|
|
2023
|
+
ref: { type: "positional", description: "Env ref", required: true },
|
|
2024
|
+
quiet: { type: "boolean", alias: "q", description: "Suppress the unsafe-source warning", default: false },
|
|
2175
2025
|
},
|
|
2176
2026
|
run({ args }) {
|
|
2177
2027
|
return runWithJsonErrors(async () => {
|
|
2178
|
-
const {
|
|
2179
|
-
const { name, absPath, source } = resolveVaultPath(args.ref);
|
|
2028
|
+
const { name, absPath, source } = resolveEnvPath(args.ref);
|
|
2180
2029
|
if (!fs.existsSync(absPath)) {
|
|
2181
|
-
throw new NotFoundError(`
|
|
2030
|
+
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
|
|
2031
|
+
}
|
|
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");
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
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");
|
|
2292
|
+
});
|
|
2293
|
+
},
|
|
2294
|
+
});
|
|
2295
|
+
const vaultUnsetCommand = defineCommand({
|
|
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`." },
|
|
2320
|
+
args: {
|
|
2321
|
+
name: { type: "positional", description: "Env name", required: true },
|
|
2322
|
+
sensitive: { type: "boolean", description: "Exclude from list output and the search index", default: false },
|
|
2323
|
+
},
|
|
2324
|
+
run({ args }) {
|
|
2325
|
+
return runWithJsonErrors(async () => {
|
|
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.`);
|
|
2334
|
+
}
|
|
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 });
|
|
2182
2340
|
}
|
|
2183
|
-
|
|
2184
|
-
output("vault-unset", { ref: makeVaultRef(name, source), key: args.key, removed, path: absPath });
|
|
2341
|
+
output("env-create", { ref: makeEnvRef(parsed.name, source) });
|
|
2185
2342
|
});
|
|
2186
2343
|
},
|
|
2187
2344
|
});
|
|
2188
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({
|
|
2189
2528
|
meta: {
|
|
2190
2529
|
name: "path",
|
|
2191
|
-
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)`.",
|
|
2192
2531
|
},
|
|
2193
2532
|
args: {
|
|
2194
|
-
ref: { type: "positional", description: "
|
|
2533
|
+
ref: { type: "positional", description: "Secret ref", required: true },
|
|
2195
2534
|
},
|
|
2196
2535
|
run({ args }) {
|
|
2197
2536
|
return runWithJsonErrors(async () => {
|
|
2198
|
-
const { name, absPath, source } =
|
|
2537
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2199
2538
|
if (!fs.existsSync(absPath)) {
|
|
2200
|
-
throw new NotFoundError(`
|
|
2539
|
+
throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
|
|
2201
2540
|
}
|
|
2202
2541
|
process.stdout.write(`${absPath}\n`);
|
|
2203
2542
|
});
|
|
2204
2543
|
},
|
|
2205
2544
|
});
|
|
2206
|
-
const
|
|
2545
|
+
const secretRunCommand = defineCommand({
|
|
2207
2546
|
meta: {
|
|
2208
2547
|
name: "run",
|
|
2209
|
-
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.",
|
|
2210
2549
|
},
|
|
2211
2550
|
args: {
|
|
2212
|
-
|
|
2551
|
+
ref: { type: "positional", description: "Secret ref", required: true },
|
|
2552
|
+
var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
|
|
2213
2553
|
},
|
|
2214
2554
|
run({ args }) {
|
|
2215
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
|
+
}
|
|
2216
2567
|
const dashIndex = process.argv.indexOf("--");
|
|
2217
2568
|
if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
|
|
2218
|
-
throw new UsageError("Missing command. Usage: akm
|
|
2569
|
+
throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
|
|
2219
2570
|
}
|
|
2220
2571
|
const command = process.argv.slice(dashIndex + 1);
|
|
2221
|
-
const {
|
|
2222
|
-
const { ref, key } = splitVaultRunTarget(args.target);
|
|
2223
|
-
const { name, absPath, source } = resolveVaultPath(ref);
|
|
2572
|
+
const { name, absPath, source } = resolveSecretPath(args.ref);
|
|
2224
2573
|
if (!fs.existsSync(absPath)) {
|
|
2225
|
-
throw new NotFoundError(`
|
|
2574
|
+
throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
|
|
2226
2575
|
}
|
|
2227
|
-
const
|
|
2576
|
+
const { readValue } = await import("./commands/secret.js");
|
|
2228
2577
|
const mergedEnv = { ...process.env };
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
for (const [envKey, envValue] of Object.entries(envValues)) {
|
|
2237
|
-
mergedEnv[envKey] = envValue;
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2578
|
+
mergedEnv[varName] = readValue(absPath).toString("utf8");
|
|
2579
|
+
// Audit trail: record access by ref + var name only — never the value.
|
|
2580
|
+
appendEvent({
|
|
2581
|
+
eventType: "secret_access",
|
|
2582
|
+
ref: makeSecretRef(name, source),
|
|
2583
|
+
metadata: { var: varName },
|
|
2584
|
+
});
|
|
2240
2585
|
const result = spawnSync(command[0], command.slice(1), {
|
|
2241
2586
|
stdio: "inherit",
|
|
2242
2587
|
env: mergedEnv,
|
|
2243
2588
|
});
|
|
2244
|
-
if (result.error)
|
|
2245
|
-
|
|
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
|
+
}
|
|
2246
2599
|
process.exit(result.status ?? 0);
|
|
2247
2600
|
});
|
|
2248
2601
|
},
|
|
2249
2602
|
});
|
|
2250
|
-
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({
|
|
2251
2630
|
meta: {
|
|
2252
|
-
name: "
|
|
2253
|
-
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`.",
|
|
2254
2633
|
},
|
|
2255
2634
|
subCommands: {
|
|
2256
|
-
list:
|
|
2257
|
-
path:
|
|
2258
|
-
run:
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
unset: vaultUnsetCommand,
|
|
2635
|
+
list: secretListCommand,
|
|
2636
|
+
path: secretPathCommand,
|
|
2637
|
+
run: secretRunCommand,
|
|
2638
|
+
set: secretSetCommand,
|
|
2639
|
+
remove: secretRemoveCommand,
|
|
2262
2640
|
},
|
|
2263
2641
|
run({ args }) {
|
|
2264
2642
|
return runWithJsonErrors(async () => {
|
|
2265
|
-
if (hasSubcommand(args,
|
|
2643
|
+
if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
|
|
2266
2644
|
return;
|
|
2267
|
-
|
|
2268
|
-
const { listKeys } = await import("./commands/vault.js");
|
|
2269
|
-
output("vault-list", { vaults: listVaultsRecursive(listKeys) });
|
|
2645
|
+
output("secret-list", { secrets: listSecretsRecursive() });
|
|
2270
2646
|
});
|
|
2271
2647
|
},
|
|
2272
2648
|
});
|
|
@@ -2298,11 +2674,6 @@ const wikiRegisterCommand = defineCommand({
|
|
|
2298
2674
|
description: "Mark a git-backed source as writable so changes can be pushed back",
|
|
2299
2675
|
default: false,
|
|
2300
2676
|
},
|
|
2301
|
-
trust: {
|
|
2302
|
-
type: "boolean",
|
|
2303
|
-
description: "Bypass install-audit blocking for this registration only",
|
|
2304
|
-
default: false,
|
|
2305
|
-
},
|
|
2306
2677
|
"max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
|
|
2307
2678
|
"max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
|
|
2308
2679
|
},
|
|
@@ -2313,7 +2684,6 @@ const wikiRegisterCommand = defineCommand({
|
|
|
2313
2684
|
ref: args.ref.trim(),
|
|
2314
2685
|
name: args.name,
|
|
2315
2686
|
options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
|
|
2316
|
-
trustThisInstall: args.trust,
|
|
2317
2687
|
writable: args.writable,
|
|
2318
2688
|
});
|
|
2319
2689
|
output("wiki-register", result);
|
|
@@ -2352,9 +2722,15 @@ const wikiRemoveCommand = defineCommand({
|
|
|
2352
2722
|
},
|
|
2353
2723
|
args: {
|
|
2354
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
|
+
},
|
|
2355
2731
|
force: {
|
|
2356
2732
|
type: "boolean",
|
|
2357
|
-
description: "
|
|
2733
|
+
description: "DEPRECATED — use -y/--yes. Removed in 0.9.0.",
|
|
2358
2734
|
default: false,
|
|
2359
2735
|
},
|
|
2360
2736
|
"with-sources": {
|
|
@@ -2365,8 +2741,16 @@ const wikiRemoveCommand = defineCommand({
|
|
|
2365
2741
|
},
|
|
2366
2742
|
run({ args }) {
|
|
2367
2743
|
return runWithJsonErrors(async () => {
|
|
2368
|
-
if (
|
|
2369
|
-
|
|
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;
|
|
2370
2754
|
}
|
|
2371
2755
|
const withSources = getHyphenatedBoolean(args, "with-sources");
|
|
2372
2756
|
const { removeWiki } = await import("./wiki/wiki.js");
|
|
@@ -2494,17 +2878,52 @@ const wikiLintCommand = defineCommand({
|
|
|
2494
2878
|
const wikiIngestCommand = defineCommand({
|
|
2495
2879
|
meta: {
|
|
2496
2880
|
name: "ingest",
|
|
2497
|
-
description: "
|
|
2881
|
+
description: "Dispatch an agent to execute the ingest workflow for this wiki. Uses --profile or config.defaults.agent.",
|
|
2498
2882
|
},
|
|
2499
2883
|
args: {
|
|
2500
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." },
|
|
2501
2894
|
},
|
|
2502
2895
|
run({ args }) {
|
|
2503
2896
|
return runWithJsonErrors(async () => {
|
|
2504
2897
|
const { buildIngestWorkflow } = await import("./wiki/wiki.js");
|
|
2505
2898
|
const stashDir = resolveStashDir();
|
|
2506
|
-
const
|
|
2507
|
-
|
|
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
|
+
});
|
|
2508
2927
|
});
|
|
2509
2928
|
},
|
|
2510
2929
|
});
|
|
@@ -2595,9 +3014,9 @@ const eventsTailCommand = defineCommand({
|
|
|
2595
3014
|
},
|
|
2596
3015
|
async run({ args }) {
|
|
2597
3016
|
await runWithJsonErrors(async () => {
|
|
2598
|
-
const intervalMs =
|
|
2599
|
-
const maxDurationMs =
|
|
2600
|
-
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");
|
|
2601
3020
|
const mode = getOutputMode();
|
|
2602
3021
|
// In streaming text mode we want each event to print as soon as it
|
|
2603
3022
|
// arrives. The polling loop emits via `onEvent`; the final result is
|
|
@@ -2632,121 +3051,631 @@ const eventsTailCommand = defineCommand({
|
|
|
2632
3051
|
if (!stream) {
|
|
2633
3052
|
output("events-tail", result);
|
|
2634
3053
|
}
|
|
2635
|
-
else if (mode.format === "jsonl") {
|
|
2636
|
-
// Final discriminated trailer row so jsonl consumers can resume.
|
|
2637
|
-
const trailer = {
|
|
2638
|
-
_kind: "trailer",
|
|
2639
|
-
schemaVersion: 1,
|
|
2640
|
-
nextOffset: result.nextOffset,
|
|
2641
|
-
totalCount: result.totalCount,
|
|
2642
|
-
reason: result.reason,
|
|
2643
|
-
};
|
|
2644
|
-
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");
|
|
2645
3382
|
}
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
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;
|
|
2650
3390
|
}
|
|
3391
|
+
const result = akmProposalReject({ id: args.id, reason: String(args.reason) });
|
|
3392
|
+
output("proposal-reject", result);
|
|
2651
3393
|
});
|
|
2652
3394
|
},
|
|
2653
3395
|
});
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
return undefined;
|
|
2657
|
-
const trimmed = raw.trim();
|
|
2658
|
-
if (!trimmed)
|
|
2659
|
-
return undefined;
|
|
2660
|
-
const value = Number.parseInt(trimmed, 10);
|
|
2661
|
-
if (Number.isNaN(value) || value <= 0) {
|
|
2662
|
-
throw new UsageError(`Invalid ${flag} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
2663
|
-
}
|
|
2664
|
-
return value;
|
|
2665
|
-
}
|
|
2666
|
-
const eventsCommand = defineCommand({
|
|
2667
|
-
meta: {
|
|
2668
|
-
name: "events",
|
|
2669
|
-
description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
|
|
2670
|
-
},
|
|
2671
|
-
subCommands: {
|
|
2672
|
-
list: eventsListCommand,
|
|
2673
|
-
tail: eventsTailCommand,
|
|
2674
|
-
},
|
|
2675
|
-
});
|
|
2676
|
-
// ── proposal substrate (#225) ────────────────────────────────────────────────
|
|
2677
|
-
const proposalsCommand = defineCommand({
|
|
2678
|
-
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)" },
|
|
2679
3398
|
args: {
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
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" },
|
|
2683
3405
|
},
|
|
2684
3406
|
run({ args }) {
|
|
2685
3407
|
return runWithJsonErrors(() => {
|
|
2686
|
-
const
|
|
2687
|
-
|
|
2688
|
-
status,
|
|
2689
|
-
ref: args.ref,
|
|
2690
|
-
includeArchive: status === "accepted" || status === "rejected",
|
|
2691
|
-
});
|
|
2692
|
-
output("proposal-list", result);
|
|
3408
|
+
const result = akmProposalDiff({ id: args.id, target: args.target });
|
|
3409
|
+
output("proposal-diff", result);
|
|
2693
3410
|
});
|
|
2694
3411
|
},
|
|
2695
3412
|
});
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
+
},
|
|
2698
3429
|
args: {
|
|
2699
3430
|
id: {
|
|
2700
3431
|
type: "positional",
|
|
2701
|
-
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.",
|
|
2702
3433
|
required: true,
|
|
2703
3434
|
},
|
|
2704
3435
|
target: { type: "string", description: "Override the write target by source name" },
|
|
2705
3436
|
},
|
|
2706
3437
|
async run({ args }) {
|
|
2707
3438
|
await runWithJsonErrors(async () => {
|
|
2708
|
-
const result = await
|
|
2709
|
-
|
|
3439
|
+
const result = await akmProposalRevert({
|
|
3440
|
+
id: args.id,
|
|
3441
|
+
target: args.target,
|
|
3442
|
+
});
|
|
3443
|
+
output("proposal-revert", result);
|
|
2710
3444
|
});
|
|
2711
3445
|
},
|
|
2712
3446
|
});
|
|
2713
|
-
|
|
2714
|
-
|
|
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" },
|
|
2715
3452
|
args: {
|
|
2716
3453
|
id: {
|
|
2717
3454
|
type: "positional",
|
|
2718
3455
|
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
2719
3456
|
required: true,
|
|
2720
3457
|
},
|
|
2721
|
-
reason: { type: "string", description: "Reason for rejection (required)" },
|
|
2722
3458
|
},
|
|
2723
3459
|
run({ args }) {
|
|
2724
3460
|
return runWithJsonErrors(() => {
|
|
2725
|
-
|
|
2726
|
-
|
|
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
|
+
}
|
|
2727
3539
|
}
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
+
});
|
|
2730
3586
|
});
|
|
2731
3587
|
},
|
|
2732
3588
|
});
|
|
2733
|
-
|
|
2734
|
-
|
|
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" },
|
|
2735
3603
|
args: {
|
|
2736
|
-
|
|
2737
|
-
type: "
|
|
2738
|
-
description: "
|
|
2739
|
-
required: true,
|
|
3604
|
+
status: {
|
|
3605
|
+
type: "string",
|
|
3606
|
+
description: "Filter by status (pending|accepted|rejected|reverted)",
|
|
2740
3607
|
},
|
|
2741
|
-
|
|
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,
|
|
2742
3619
|
},
|
|
2743
3620
|
run({ args }) {
|
|
2744
3621
|
return runWithJsonErrors(() => {
|
|
2745
|
-
|
|
2746
|
-
|
|
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);
|
|
2747
3634
|
});
|
|
2748
3635
|
},
|
|
2749
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
|
+
});
|
|
2750
3679
|
// ── distill (#228) ──────────────────────────────────────────────────────────
|
|
2751
3680
|
function parseProposalStatus(raw) {
|
|
2752
3681
|
if (raw === undefined)
|
|
@@ -2754,50 +3683,85 @@ function parseProposalStatus(raw) {
|
|
|
2754
3683
|
const trimmed = raw.trim();
|
|
2755
3684
|
if (!trimmed)
|
|
2756
3685
|
return undefined;
|
|
2757
|
-
if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected")
|
|
3686
|
+
if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected" || trimmed === "reverted") {
|
|
2758
3687
|
return trimmed;
|
|
2759
|
-
|
|
3688
|
+
}
|
|
3689
|
+
throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected, reverted.`, "INVALID_FLAG_VALUE");
|
|
2760
3690
|
}
|
|
2761
3691
|
const agentCommand = defineCommand({
|
|
2762
3692
|
meta: {
|
|
2763
3693
|
name: "agent",
|
|
2764
|
-
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.",
|
|
2765
3695
|
},
|
|
2766
3696
|
args: {
|
|
2767
3697
|
profile: {
|
|
2768
3698
|
type: "positional",
|
|
2769
|
-
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.",
|
|
2770
3705
|
required: false,
|
|
2771
3706
|
},
|
|
2772
|
-
prompt: { type: "string", description: "
|
|
2773
|
-
command: { type: "string", description: "Load
|
|
2774
|
-
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: {
|
|
2775
3711
|
type: "string",
|
|
2776
|
-
description: "
|
|
3712
|
+
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
|
|
2777
3713
|
},
|
|
2778
3714
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
2779
3715
|
},
|
|
2780
3716
|
async run({ args }) {
|
|
2781
3717
|
await runWithJsonErrors(async () => {
|
|
2782
3718
|
if (!args.profile) {
|
|
2783
|
-
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.");
|
|
2784
3720
|
}
|
|
2785
|
-
|
|
2786
|
-
// template placeholders when a command/workflow ref is specified).
|
|
2787
|
-
const extraArgs = Array.isArray(args._) ? args._.filter((a) => a !== args.profile) : [];
|
|
2788
|
-
const timeoutRaw = args["timeout-ms"];
|
|
2789
|
-
const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
|
|
3721
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2790
3722
|
const config = loadConfig();
|
|
2791
|
-
const {
|
|
2792
|
-
|
|
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);
|
|
2793
3748
|
const result = await akmAgentDispatch({
|
|
2794
3749
|
profileName: String(args.profile),
|
|
2795
|
-
prompt:
|
|
2796
|
-
commandRef
|
|
2797
|
-
workflowRef
|
|
2798
|
-
args: extraArgs.length > 0 ? extraArgs : undefined,
|
|
3750
|
+
prompt: promptText,
|
|
3751
|
+
commandRef,
|
|
3752
|
+
workflowRef,
|
|
2799
3753
|
agentConfig,
|
|
2800
|
-
llmConfig: config
|
|
3754
|
+
llmConfig: getDefaultLlmConfig(config),
|
|
3755
|
+
...(hasDispatchContent
|
|
3756
|
+
? {
|
|
3757
|
+
dispatch: {
|
|
3758
|
+
prompt: promptText ?? "",
|
|
3759
|
+
systemPrompt,
|
|
3760
|
+
model,
|
|
3761
|
+
tools: assetTools,
|
|
3762
|
+
},
|
|
3763
|
+
}
|
|
3764
|
+
: {}),
|
|
2801
3765
|
...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
|
|
2802
3766
|
});
|
|
2803
3767
|
output("agent-result", result);
|
|
@@ -2810,159 +3774,29 @@ const agentCommand = defineCommand({
|
|
|
2810
3774
|
const lintCommand = defineCommand({
|
|
2811
3775
|
meta: {
|
|
2812
3776
|
name: "lint",
|
|
2813
|
-
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.",
|
|
2814
3778
|
},
|
|
2815
3779
|
args: {
|
|
2816
3780
|
fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
|
|
2817
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
|
+
},
|
|
2818
3787
|
},
|
|
2819
3788
|
async run({ args }) {
|
|
2820
3789
|
await runWithJsonErrors(async () => {
|
|
2821
3790
|
const result = akmLint({
|
|
2822
3791
|
fix: args.fix ?? false,
|
|
2823
|
-
dir:
|
|
3792
|
+
dir: getStringArg(args, "dir"),
|
|
2824
3793
|
});
|
|
2825
3794
|
output("lint", result);
|
|
2826
|
-
if (
|
|
3795
|
+
if (args["fail-on-flagged"] && result.summary.flagged > 0)
|
|
2827
3796
|
process.exit(EXIT_GENERAL);
|
|
2828
3797
|
});
|
|
2829
3798
|
},
|
|
2830
3799
|
});
|
|
2831
|
-
const improveCommand = defineCommand({
|
|
2832
|
-
meta: {
|
|
2833
|
-
name: "improve",
|
|
2834
|
-
description: "Analyze existing AKM assets and generate improvement proposals; also consolidates memories when llm.features.memory_consolidation is enabled",
|
|
2835
|
-
},
|
|
2836
|
-
args: {
|
|
2837
|
-
scope: {
|
|
2838
|
-
type: "positional",
|
|
2839
|
-
description: "Optional asset type or asset ref to improve",
|
|
2840
|
-
required: false,
|
|
2841
|
-
},
|
|
2842
|
-
task: { type: "string", description: "Add extra guidance for this improvement pass" },
|
|
2843
|
-
"dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
|
|
2844
|
-
target: { type: "string", description: "Override the write target for accepted proposals" },
|
|
2845
|
-
"auto-accept": {
|
|
2846
|
-
type: "string",
|
|
2847
|
-
description: "Automatically accept low-risk proposals (only 'safe' is supported)",
|
|
2848
|
-
},
|
|
2849
|
-
limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
|
|
2850
|
-
"timeout-ms": {
|
|
2851
|
-
type: "string",
|
|
2852
|
-
description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
|
|
2853
|
-
},
|
|
2854
|
-
"ignore-cooldown": {
|
|
2855
|
-
type: "boolean",
|
|
2856
|
-
description: "Ignore all cooldown periods (equivalent to --reflect-cooldown-days 0 --distill-cooldown-days 0 --consolidate-cooldown-days 0)",
|
|
2857
|
-
default: false,
|
|
2858
|
-
},
|
|
2859
|
-
"reflect-cooldown-days": {
|
|
2860
|
-
type: "string",
|
|
2861
|
-
description: "Override reflect cooldown for this run only (default: 7, 0 to disable)",
|
|
2862
|
-
},
|
|
2863
|
-
"distill-cooldown-days": {
|
|
2864
|
-
type: "string",
|
|
2865
|
-
description: "Override distill cooldown for this run only (default: 30, 0 to disable)",
|
|
2866
|
-
},
|
|
2867
|
-
"consolidate-cooldown-days": {
|
|
2868
|
-
type: "string",
|
|
2869
|
-
description: "Override consolidate cooldown for this run only (default: 14, 0 to disable)",
|
|
2870
|
-
},
|
|
2871
|
-
"consolidate-recovery": {
|
|
2872
|
-
type: "string",
|
|
2873
|
-
description: "How to handle stale/incomplete consolidation journals: abort (default) or clean (remove stale journal artifacts)",
|
|
2874
|
-
},
|
|
2875
|
-
"require-feedback-signal": {
|
|
2876
|
-
type: "boolean",
|
|
2877
|
-
description: "Only process assets with recent feedback signals (disables retrieval fallback)",
|
|
2878
|
-
default: false,
|
|
2879
|
-
},
|
|
2880
|
-
"min-retrieval-count": {
|
|
2881
|
-
type: "string",
|
|
2882
|
-
description: "Minimum retrieval count for zero-feedback fallback eligibility (default: 5)",
|
|
2883
|
-
},
|
|
2884
|
-
},
|
|
2885
|
-
async run({ args }) {
|
|
2886
|
-
await runWithJsonErrors(async () => {
|
|
2887
|
-
const autoAcceptRaw = getHyphenatedArg(args, "auto-accept");
|
|
2888
|
-
if (autoAcceptRaw !== undefined && autoAcceptRaw !== "safe") {
|
|
2889
|
-
throw new UsageError("--auto-accept only supports the value 'safe'.", "INVALID_FLAG_VALUE");
|
|
2890
|
-
}
|
|
2891
|
-
const targetArg = typeof args.target === "string" && args.target.trim() ? args.target.trim() : undefined;
|
|
2892
|
-
const taskArg = typeof args.task === "string" && args.task.trim() ? args.task : undefined;
|
|
2893
|
-
const dryRun = getHyphenatedBoolean(args, "dry-run");
|
|
2894
|
-
const autoAccept = autoAcceptRaw === "safe" ? "safe" : undefined;
|
|
2895
|
-
const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
|
|
2896
|
-
const timeoutRaw = getHyphenatedArg(args, "timeout-ms");
|
|
2897
|
-
const timeoutMs = timeoutRaw !== undefined ? parseInt(timeoutRaw, 10) : undefined;
|
|
2898
|
-
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
|
2899
|
-
throw new UsageError(`Invalid --timeout-ms value: "${timeoutRaw}". Must be a positive integer.`);
|
|
2900
|
-
}
|
|
2901
|
-
const parseNonNegativeCooldownDays = (raw, flagName) => {
|
|
2902
|
-
if (raw === undefined)
|
|
2903
|
-
return undefined;
|
|
2904
|
-
if (!/^\d+$/.test(raw.trim())) {
|
|
2905
|
-
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a non-negative integer.`);
|
|
2906
|
-
}
|
|
2907
|
-
return parseInt(raw, 10);
|
|
2908
|
-
};
|
|
2909
|
-
const ignoreCooldown = getHyphenatedBoolean(args, "ignore-cooldown");
|
|
2910
|
-
const reflectCooldownRaw = getHyphenatedArg(args, "reflect-cooldown-days");
|
|
2911
|
-
const reflectCooldownDays = ignoreCooldown
|
|
2912
|
-
? 0
|
|
2913
|
-
: parseNonNegativeCooldownDays(reflectCooldownRaw, "--reflect-cooldown-days");
|
|
2914
|
-
const distillCooldownRaw = getHyphenatedArg(args, "distill-cooldown-days");
|
|
2915
|
-
const distillCooldownDays = ignoreCooldown
|
|
2916
|
-
? 0
|
|
2917
|
-
: parseNonNegativeCooldownDays(distillCooldownRaw, "--distill-cooldown-days");
|
|
2918
|
-
const consolidateCooldownRaw = getHyphenatedArg(args, "consolidate-cooldown-days");
|
|
2919
|
-
const consolidateCooldownDays = ignoreCooldown
|
|
2920
|
-
? 0
|
|
2921
|
-
: parseNonNegativeCooldownDays(consolidateCooldownRaw, "--consolidate-cooldown-days");
|
|
2922
|
-
const consolidateRecoveryRaw = getHyphenatedArg(args, "consolidate-recovery");
|
|
2923
|
-
const consolidateRecovery = consolidateRecoveryRaw === undefined
|
|
2924
|
-
? undefined
|
|
2925
|
-
: consolidateRecoveryRaw.trim().toLowerCase();
|
|
2926
|
-
if (consolidateRecovery !== undefined && consolidateRecovery !== "abort" && consolidateRecovery !== "clean") {
|
|
2927
|
-
throw new UsageError(`Invalid --consolidate-recovery value: "${consolidateRecoveryRaw}". Must be one of: abort, clean.`, "INVALID_FLAG_VALUE");
|
|
2928
|
-
}
|
|
2929
|
-
const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
|
|
2930
|
-
const minRetrievalCount = parseNonNegativeCooldownDays(minRetrievalCountRaw, "--min-retrieval-count");
|
|
2931
|
-
const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
|
|
2932
|
-
const improveLogFile = path.join(getCacheDir(), "logs", "improve", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
2933
|
-
setLogFile(improveLogFile);
|
|
2934
|
-
let improveResult;
|
|
2935
|
-
try {
|
|
2936
|
-
improveResult = await akmImprove({
|
|
2937
|
-
scope: typeof args.scope === "string" && args.scope.trim() ? args.scope : undefined,
|
|
2938
|
-
task: taskArg,
|
|
2939
|
-
dryRun,
|
|
2940
|
-
target: targetArg,
|
|
2941
|
-
autoAccept,
|
|
2942
|
-
...(limitRaw !== undefined ? { limit: limitRaw } : {}),
|
|
2943
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
2944
|
-
...(reflectCooldownDays !== undefined ? { reflectCooldownDays } : {}),
|
|
2945
|
-
...(distillCooldownDays !== undefined ? { distillCooldownDays } : {}),
|
|
2946
|
-
...(consolidateCooldownDays !== undefined ? { consolidateCooldownDays } : {}),
|
|
2947
|
-
...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
|
|
2948
|
-
...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
|
|
2949
|
-
consolidateOptions: {
|
|
2950
|
-
target: targetArg,
|
|
2951
|
-
dryRun,
|
|
2952
|
-
autoAccept,
|
|
2953
|
-
task: taskArg,
|
|
2954
|
-
...(consolidateRecovery !== undefined ? { recoveryMode: consolidateRecovery } : {}),
|
|
2955
|
-
},
|
|
2956
|
-
});
|
|
2957
|
-
}
|
|
2958
|
-
finally {
|
|
2959
|
-
clearLogFile();
|
|
2960
|
-
}
|
|
2961
|
-
output("improve", improveResult);
|
|
2962
|
-
process.exit(0);
|
|
2963
|
-
});
|
|
2964
|
-
},
|
|
2965
|
-
});
|
|
2966
3800
|
const proposeCommand = defineCommand({
|
|
2967
3801
|
meta: {
|
|
2968
3802
|
name: "propose",
|
|
@@ -2993,14 +3827,13 @@ const proposeCommand = defineCommand({
|
|
|
2993
3827
|
throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
|
|
2994
3828
|
}
|
|
2995
3829
|
const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
|
|
2996
|
-
const
|
|
2997
|
-
const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
|
|
3830
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2998
3831
|
const result = await akmPropose({
|
|
2999
3832
|
type: String(args.type),
|
|
3000
3833
|
name: String(args.name),
|
|
3001
3834
|
task: taskText,
|
|
3002
|
-
profile:
|
|
3003
|
-
...(timeoutMs !== undefined
|
|
3835
|
+
profile: getStringArg(args, "profile"),
|
|
3836
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
3004
3837
|
});
|
|
3005
3838
|
output("propose", result);
|
|
3006
3839
|
if (result.ok === false) {
|
|
@@ -3021,7 +3854,16 @@ const TASKS_SUBCOMMAND_SET = new Set([
|
|
|
3021
3854
|
"sync",
|
|
3022
3855
|
"doctor",
|
|
3023
3856
|
]);
|
|
3024
|
-
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
|
+
]);
|
|
3025
3867
|
const tasksAddCommand = defineCommand({
|
|
3026
3868
|
meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
|
|
3027
3869
|
args: {
|
|
@@ -3032,8 +3874,14 @@ const tasksAddCommand = defineCommand({
|
|
|
3032
3874
|
type: "string",
|
|
3033
3875
|
description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
|
|
3034
3876
|
},
|
|
3035
|
-
|
|
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)" },
|
|
3036
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" },
|
|
3037
3885
|
description: { type: "string", description: "Human-readable description" },
|
|
3038
3886
|
tags: { type: "string", description: "Comma-separated tags" },
|
|
3039
3887
|
disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
|
|
@@ -3046,8 +3894,11 @@ const tasksAddCommand = defineCommand({
|
|
|
3046
3894
|
schedule: args.schedule,
|
|
3047
3895
|
workflow: args.workflow,
|
|
3048
3896
|
prompt: args.prompt,
|
|
3897
|
+
command: args.command,
|
|
3049
3898
|
profile: args.profile,
|
|
3050
3899
|
params: args.params,
|
|
3900
|
+
name: args.name,
|
|
3901
|
+
when_to_use: getHyphenatedArg(args, "when-to-use"),
|
|
3051
3902
|
description: args.description,
|
|
3052
3903
|
tags: args.tags
|
|
3053
3904
|
? args.tags
|
|
@@ -3093,28 +3944,25 @@ const tasksRemoveCommand = defineCommand({
|
|
|
3093
3944
|
});
|
|
3094
3945
|
},
|
|
3095
3946
|
});
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
})
|
|
3105
|
-
|
|
3106
|
-
});
|
|
3107
|
-
const
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
});
|
|
3116
|
-
},
|
|
3117
|
-
});
|
|
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);
|
|
3118
3966
|
const tasksRunCommand = defineCommand({
|
|
3119
3967
|
meta: {
|
|
3120
3968
|
name: "run",
|
|
@@ -3172,6 +4020,7 @@ const tasksDoctorCommand = defineCommand({
|
|
|
3172
4020
|
const tasksCommand = defineCommand({
|
|
3173
4021
|
meta: {
|
|
3174
4022
|
name: "tasks",
|
|
4023
|
+
alias: "task",
|
|
3175
4024
|
description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
|
|
3176
4025
|
},
|
|
3177
4026
|
subCommands: {
|
|
@@ -3195,16 +4044,43 @@ const tasksCommand = defineCommand({
|
|
|
3195
4044
|
});
|
|
3196
4045
|
},
|
|
3197
4046
|
});
|
|
3198
|
-
const main = defineCommand({
|
|
4047
|
+
export const main = defineCommand({
|
|
3199
4048
|
meta: {
|
|
3200
4049
|
name: "akm",
|
|
3201
4050
|
version: pkgVersion,
|
|
3202
|
-
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",
|
|
3203
4058
|
},
|
|
3204
4059
|
args: {
|
|
3205
4060
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
|
|
3206
|
-
detail: {
|
|
3207
|
-
|
|
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
|
+
},
|
|
3208
4084
|
verbose: {
|
|
3209
4085
|
type: "boolean",
|
|
3210
4086
|
description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
|
|
@@ -3218,6 +4094,7 @@ const main = defineCommand({
|
|
|
3218
4094
|
health: healthCommand,
|
|
3219
4095
|
info: infoCommand,
|
|
3220
4096
|
graph: graphCommand,
|
|
4097
|
+
db: dbCommand,
|
|
3221
4098
|
add: addCommand,
|
|
3222
4099
|
list: listCommand,
|
|
3223
4100
|
remove: removeCommand,
|
|
@@ -3229,6 +4106,8 @@ const main = defineCommand({
|
|
|
3229
4106
|
workflow: workflowCommand,
|
|
3230
4107
|
remember: rememberCommand,
|
|
3231
4108
|
import: importKnowledgeCommand,
|
|
4109
|
+
sync: syncCommand,
|
|
4110
|
+
// Deprecated alias (removed 0.9.0) — delegates to `sync`.
|
|
3232
4111
|
save: saveCommand,
|
|
3233
4112
|
clone: cloneCommand,
|
|
3234
4113
|
registry: registryCommand,
|
|
@@ -3238,24 +4117,33 @@ const main = defineCommand({
|
|
|
3238
4117
|
feedback: feedbackCommand,
|
|
3239
4118
|
history: historyCommand,
|
|
3240
4119
|
events: eventsCommand,
|
|
4120
|
+
lessons: lessonsCommand,
|
|
3241
4121
|
agent: agentCommand,
|
|
3242
4122
|
lint: lintCommand,
|
|
3243
4123
|
improve: improveCommand,
|
|
4124
|
+
extract: extractCommand,
|
|
3244
4125
|
propose: proposeCommand,
|
|
4126
|
+
proposal: proposalCommand,
|
|
4127
|
+
// Deprecated flat verbs (removed 0.9.0) — delegate to `proposal <verb>`.
|
|
3245
4128
|
proposals: proposalsCommand,
|
|
3246
4129
|
accept: acceptCommand,
|
|
3247
4130
|
reject: rejectCommand,
|
|
3248
4131
|
diff: diffCommand,
|
|
4132
|
+
revert: revertCommand,
|
|
3249
4133
|
help: helpCommand,
|
|
3250
4134
|
hints: hintsCommand,
|
|
3251
4135
|
completions: completionsCommand,
|
|
4136
|
+
env: envCommand,
|
|
3252
4137
|
vault: vaultCommand,
|
|
4138
|
+
secret: secretCommand,
|
|
3253
4139
|
wiki: wikiCommand,
|
|
3254
4140
|
tasks: tasksCommand,
|
|
3255
4141
|
},
|
|
3256
4142
|
});
|
|
3257
|
-
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"]);
|
|
3258
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"]);
|
|
3259
4147
|
const WIKI_SUBCOMMAND_SET = new Set([
|
|
3260
4148
|
"create",
|
|
3261
4149
|
"register",
|
|
@@ -3269,74 +4157,90 @@ const WIKI_SUBCOMMAND_SET = new Set([
|
|
|
3269
4157
|
"ingest",
|
|
3270
4158
|
]);
|
|
3271
4159
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
//
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
// `
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
? error.code
|
|
3293
|
-
: undefined;
|
|
3294
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
3295
|
-
process.exit(exitCode);
|
|
3296
|
-
}
|
|
3297
|
-
runMain(main);
|
|
3298
|
-
function classifyExitCode(error) {
|
|
3299
|
-
if (error instanceof UsageError)
|
|
3300
|
-
return EXIT_USAGE;
|
|
3301
|
-
if (error instanceof ConfigError)
|
|
3302
|
-
return EXIT_CONFIG;
|
|
3303
|
-
if (error instanceof NotFoundError)
|
|
3304
|
-
return EXIT_GENERAL;
|
|
3305
|
-
return EXIT_GENERAL;
|
|
3306
|
-
}
|
|
3307
|
-
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.
|
|
3308
4180
|
try {
|
|
3309
4181
|
applyEarlyStderrFlags(process.argv);
|
|
3310
|
-
|
|
4182
|
+
initOutputMode(process.argv, loadConfig().output ?? {});
|
|
3311
4183
|
}
|
|
3312
4184
|
catch (error) {
|
|
3313
|
-
|
|
3314
|
-
const hint = extractHint(error);
|
|
3315
|
-
const exitCode = classifyExitCode(error);
|
|
3316
|
-
// Surface machine-readable error code from typed errors when present so
|
|
3317
|
-
// scripts can branch on `.code` instead of message-string matching.
|
|
3318
|
-
const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
|
|
3319
|
-
? error.code
|
|
3320
|
-
: undefined;
|
|
3321
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
3322
|
-
process.exit(exitCode);
|
|
4185
|
+
emitJsonError(error);
|
|
3323
4186
|
}
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
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"));
|
|
3333
4195
|
}
|
|
3334
|
-
|
|
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);
|
|
3335
4237
|
}
|
|
3336
4238
|
// ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
|
|
3337
4239
|
function loadHints(detail = "normal") {
|
|
3338
|
-
|
|
3339
|
-
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;
|
|
3340
4244
|
// Try reading from the docs/ directory (works in dev and when installed via npm)
|
|
3341
4245
|
try {
|
|
3342
4246
|
const docsPath = path.resolve(import.meta.dir ?? __dirname, `../docs/agents/${filename}`);
|