akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
348 lines (347 loc) • 17.9 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* `akm env` command family. Extracted verbatim from src/cli.ts (WS6) so the God
* Module shrinks; the `main.subCommands.env` key and every subcommand's
* args/output shape are byte-identical. The ref-resolution helpers
* (parseEnvRef / findEnvSource / makeEnvRef / resolveEnvPath + the env-path
* traversal guard) live in src/core/env-secret-ref.ts so env + secret share one
* copy.
*
* `akm env` manages whole `.env` files under each stash's env/ directory.
* Values and comment text are NEVER written to stdout or structured output —
* only key NAMES are surfaced (comments routinely contain commented-out
* credentials). akm does not manage individual entries;
* you edit the `.env` file yourself and akm loads it. Replaced the deprecated
* `vault` type (removed in 0.9.0).
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { getParsedInvocation } from "../../cli/invocation.js";
import { getStringArg } from "../../cli/parse-args.js";
import { defineGroupCommand, defineJsonCommand, output } from "../../cli/shared.js";
import { writeFileAtomic } from "../../core/common.js";
import { listEnvsRecursive, makeEnvRef, resolveEnvPath, resolveEnvWriteTarget, sensitiveMarkerPath, withEnvSecretWrite, } from "../../core/env-secret-ref.js";
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
import { readStdin } from "../../runtime.js";
import { buildChildEnv } from "./child-env.js";
const envListCommand = defineJsonCommand({
meta: { name: "list", description: "List all env files across all bundles with their key names (no values)" },
async run() {
const { listKeys } = await import("./env.js");
output("env-list", { envs: listEnvsRecursive(listKeys) });
},
});
const envCreateCommand = defineJsonCommand({
meta: {
name: "create",
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.",
},
args: {
name: {
type: "positional",
description: "Env name (flat, e.g. prod → prod.env; use --path for a subdirectory)",
required: true,
},
path: {
type: "string",
description: "Relative subdirectory under env/ to place the env file in (e.g. 'staging'). The filename comes from the name.",
},
"from-file": { type: "string", description: "Seed the env file from an existing .env at this path" },
"from-stdin": { type: "boolean", description: "Seed the env file from stdin", default: false },
sensitive: {
type: "boolean",
description: "Exclude this env file from env list output and the search index",
default: false,
},
target: {
type: "string",
description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working bundle.",
},
},
async run({ args }) {
const { createEnv, writeEnv } = await import("./env.js");
// `create` always targets env/, never the frozen vaults/ copy. `--path` is
// the subdirectory; `--target` selects the writable destination source.
const { name, absPath, target, ref } = resolveEnvWriteTarget(args.name, args.target, {
subPath: getStringArg(args, "path"),
});
const fromFile = args["from-file"];
const fromStdin = args["from-stdin"] === true;
if (fromFile !== undefined && fromStdin) {
throw new UsageError("Pass only one of --from-file or --from-stdin.", "INVALID_FLAG_VALUE");
}
let content;
if (fromFile !== undefined || fromStdin) {
// Ingest path: never silently clobber an existing env file.
if (fs.existsSync(absPath)) {
throw new UsageError(`Env "${ref}" already exists. Remove it first (\`akm env remove\`) or edit the file directly.`, "RESOURCE_ALREADY_EXISTS");
}
if (fromFile !== undefined) {
if (!fs.existsSync(fromFile)) {
throw new NotFoundError(`Source file not found: ${fromFile}`, "FILE_NOT_FOUND");
}
content = fs.readFileSync(fromFile, "utf8");
}
else {
const buf = await readStdin();
content = buf.toString("utf8");
}
}
const written = [absPath];
const markerPath = sensitiveMarkerPath(absPath, "env");
if (args.sensitive) {
written.push(markerPath);
}
withEnvSecretWrite(target, { type: "env", name }, "Update", written, () => {
if (content !== undefined)
writeEnv(absPath, content);
else
createEnv(absPath);
if (args.sensitive) {
if (!fs.existsSync(markerPath)) {
writeFileAtomic(markerPath, "", 0o600);
}
}
});
output("env-create", { ref });
},
});
const envPathCommand = defineJsonCommand({
meta: {
name: "path",
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.",
},
args: {
ref: { type: "positional", description: "Env ref", required: true },
quiet: { type: "boolean", alias: "q", description: "Suppress the unsafe-source warning", default: false },
},
async run({ args }) {
const { name, absPath, source } = resolveEnvPath(args.ref);
if (!fs.existsSync(absPath)) {
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
}
// The raw `.env` may contain `X=$(cmd)`, which executes if `source`d.
// Warning goes to stderr (never contaminates the path on stdout) and is
// suppressed with --quiet for the legitimate `_FILE` / `--env-file` use.
if (args.quiet !== true) {
process.stderr.write(`warning: this is the raw file path. Do NOT \`source\` it (shell substitutions in the file would execute).\n` +
` To inject values run: akm env run ${args.ref} -- <command>\n`);
}
// F3/B3: this stdout write IS the payload — a bare absolute path for
// shell substitution (`$(akm env path <ref>)`, Docker `_FILE` /
// `--env-file`) — not a field inside a result envelope. `env path` is
// now declared format-exempt (src/output/format-exempt.ts, same
// classification as `env run`/`secret run`/`help`), so `--format`
// WARNS rather than doing anything to this write (src/cli.ts's startup
// `isFormatExemptCommand` check). An earlier version of this fix routed
// this through `output()` with a `{ path }` envelope so `--format`
// "worked" — but the CLI's process-wide default format is `json`, so a
// bare `akm env path <ref>` (exactly how `$(akm env path foo)` is
// written, with no explicit `--format`) started emitting
// `{"path":"..."}` instead of the raw path, breaking every existing
// shell substitution silently. Declaring the exemption is the correct
// fix: it keeps this byte-identical to history and makes the
// already-broken combination (`--format` + this command) loud instead
// of silent, per STABILITY.md's promise for exempt commands.
process.stdout.write(`${absPath}\n`);
},
});
const envExportCommand = defineJsonCommand({
meta: {
name: "export",
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>`.",
},
args: {
ref: { type: "positional", description: "Env ref", required: true },
out: { type: "string", alias: "o", description: "Destination file (required). Written at mode 0600." },
},
async run({ args }) {
const outPath = args.out;
if (!outPath) {
throw new UsageError("`akm env export` writes to a file — pass --out <path>.\n" +
" To use values directly, run `akm env run <ref> -- <command>` (or `-- $SHELL` for an interactive\n" +
" session). export never prints values to stdout, to avoid leaking them into a captured context.", "MISSING_REQUIRED_ARGUMENT");
}
const { name, absPath, source } = resolveEnvPath(args.ref);
if (!fs.existsSync(absPath)) {
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
}
const { buildShellExportScript } = await import("./env.js");
const resolvedOut = path.resolve(outPath);
writeFileAtomic(resolvedOut, buildShellExportScript(absPath), 0o600);
output("env-export", { ref: makeEnvRef(name, source), out: resolvedOut });
},
});
/**
* Shared implementation for `env run`. Injects an entire env file's values into
* the child process env — never via a shell — after scanning the injected keys
* for process-hijacking variables.
*/
async function runEnvInjected(target, opts) {
const command = getParsedInvocation().passthroughArgs();
if (command.length === 0) {
throw new UsageError("Missing command. Usage: akm env run <ref> -- <command>");
}
const { name, absPath, source } = resolveEnvPath(target);
if (!fs.existsSync(absPath)) {
// Help users who reach for the removed single-key `ref/KEY` form.
const slash = target.lastIndexOf("/");
if (slash > 0) {
const maybeKey = target.slice(slash + 1);
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(maybeKey)) {
let baseExists = false;
try {
baseExists = fs.existsSync(resolveEnvPath(target.slice(0, slash)).absPath);
}
catch {
baseExists = false;
}
if (baseExists) {
throw new UsageError(`'akm env run' injects the whole file; the single-key '<ref>/${maybeKey}' form was removed.\n` +
` For one value use a secret: \`akm secret run secrets/${maybeKey} ${maybeKey} -- <command>\`.`, "INVALID_FLAG_VALUE");
}
}
}
throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
}
// Load → filter → secret-substitute → dangerous-key policy → keys-only
// audit event. Shared with the workflow engine's per-unit env bindings —
// see env-binding.ts for the extracted core and its safety invariants.
const { resolveEnvBinding } = await import("./env-binding.js");
const { values: envValues } = resolveEnvBinding(target, {
only: opts.only,
except: opts.except,
allowDangerousEnvKeys: opts.allowDangerousEnvKeys,
});
const mergedEnv = buildChildEnv(process.env, {
clean: opts.clean === true,
inherit: opts.inherit ?? [],
});
for (const [envKey, envValue] of Object.entries(envValues)) {
mergedEnv[envKey] = envValue;
}
const result = spawnSync(command[0], command.slice(1), {
stdio: "inherit",
env: mergedEnv,
});
if (result.error) {
// Classify spawn failures (#483). Raw ErrnoException leaks a bare
// "spawn ENOENT" with no hint — wrap it so consumers get a usable
// code + hint in the standard JSON envelope.
const err = result.error;
if (err.code === "ENOENT") {
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'.`);
}
if (err.code === "EACCES") {
throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
}
throw err;
}
// R-067: was `process.exit(result.status ?? 0)`, which terminates the
// process synchronously and skips the `finally { await
// disposeDispatchResources(); }` block in src/cli.ts's `runCommand` — even
// on the success path, since this call was unconditional. Setting
// `process.exitCode` and returning lets cleanup run while still exiting
// with the child's exact status once the event loop drains, matching the
// pattern `emitJsonError` (src/cli/shared.ts) already established.
process.exitCode = result.status ?? 0;
return;
}
/** Parse a comma/space-separated key list flag into a trimmed, non-empty array. */
function parseKeyListFlag(raw) {
if (raw === undefined)
return undefined;
const keys = raw
.split(/[,\s]+/)
.map((k) => k.trim())
.filter(Boolean);
return keys.length > 0 ? keys : undefined;
}
const envRunCommand = defineJsonCommand({
meta: {
name: "run",
description:
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal `${secret:NAME}` token syntax documented for users, not interpolation
"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. Values may embed `${secret:NAME}` tokens, replaced at run time with the sibling `${secret:NAME}` value from the same bundle. Pass --clean to start the child with a minimal inherited environment instead of the full parent environment.",
},
args: {
target: { type: "positional", description: "Env ref", required: true },
only: {
type: "string",
description: "Inject ONLY these keys (comma-separated). Mutually exclusive with --except.",
},
except: { type: "string", description: "Inject all keys EXCEPT these (comma-separated)." },
clean: {
type: "boolean",
description: "Start the child with a minimal inherited environment (PATH/HOME/locale/terminal basics) instead of the full parent environment.",
default: false,
},
inherit: {
type: "string",
description: "When used with --clean, also inherit these parent env vars (comma-separated). Ignored without --clean.",
},
"allow-dangerous-env-keys": {
type: "boolean",
description: "Allow injecting a process-hijacking variable (e.g. LD_PRELOAD, GIT_SSH_COMMAND) from a third-party stash, which otherwise blocks. Use only after explicitly reviewing the env file.",
default: false,
},
},
async run({ args }) {
await runEnvInjected(args.target, {
only: parseKeyListFlag(args.only),
except: parseKeyListFlag(args.except),
clean: args.clean === true,
inherit: parseKeyListFlag(args.inherit) ?? [],
allowDangerousEnvKeys: args["allow-dangerous-env-keys"] === true,
});
},
});
const envRemoveCommand = defineJsonCommand({
meta: { name: "remove", description: "Remove an env file (and its .sensitive marker, if any)" },
args: {
ref: { type: "positional", description: "Env ref", required: true },
yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
target: {
type: "string",
description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working bundle.",
},
},
async run({ args }) {
const { name, absPath, target, ref } = resolveEnvWriteTarget(args.ref, args.target);
const { confirmDestructive } = await import("../../cli/confirm.js");
const confirmed = await confirmDestructive(`Remove env "${args.ref}"? This cannot be undone.`, {
yes: args.yes === true,
});
if (!confirmed) {
process.stderr.write("Aborted.\n");
return;
}
if (!fs.existsSync(absPath)) {
throw new NotFoundError(`Env not found: ${ref}`);
}
const { removeEnv } = await import("./env.js");
const removed = withEnvSecretWrite(target, { type: "env", name }, "Remove", [absPath, sensitiveMarkerPath(absPath, "env")], () => removeEnv(absPath));
output("env-remove", { ref, removed });
},
});
export const envCommand = defineGroupCommand({
meta: {
name: "env",
description: "Manage `.env` files — configuration values an app loads together (URLs, flags, and any credentials it needs).\n\n" +
"Values may or may not be sensitive; akm protects them all the same way — key names are visible, values never appear in structured output. For a single sensitive value used on its own (an auth token, key, or cert), use `akm secret`.",
},
subCommands: {
list: envListCommand,
path: envPathCommand,
export: envExportCommand,
run: envRunCommand,
create: envCreateCommand,
remove: envRemoveCommand,
},
// No `defaultRun`: bare `akm env` is a usage error (exit 2), the canonical
// bare-group behavior — owner ruling 12. Run `akm env list` for what the
// bare form used to print.
});