akm-cli 0.8.0-rc.7 → 0.8.0-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-05-28
10
+
11
+ ### Fixed
12
+
13
+ - **Consolidation `delete_failed` on stale index entries** — when consolidation
14
+ successfully deleted a memory file, the index DB was not re-indexed between
15
+ runs. Subsequent runs loaded the stale DB entry into their memory map, the LLM
16
+ re-proposed the deletion, and `deleteAssetFromSource` threw "not found in
17
+ source" — appearing as `delete_failed` in skipReasons. Fix: `loadMemoriesForSource`
18
+ now filters entries whose file no longer exists on disk before building chunks,
19
+ so phantom memories are never sent to the LLM. A secondary catch in the delete
20
+ handler emits `delete_already_gone` instead of `delete_failed` when the file
21
+ is confirmed absent.
22
+
9
23
  > **CI / Docker users:** the 0.8.0 storage split moved `akm.lock`, the event
10
24
  > database, and the registry cache out of `$XDG_CONFIG_HOME/akm/` into
11
25
  > `$XDG_DATA_HOME`, `$XDG_STATE_HOME`, and `$XDG_CACHE_HOME` respectively. If
@@ -65,6 +79,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
65
79
 
66
80
  ### Changed
67
81
 
82
+ - **Rebrand**: the full name "Agent Kit Manager" is now **Agent Knowledge Management** — `akm` stands for Agent Knowledge Management going forward. The binary name, npm package (`akm-cli`), and all APIs remain unchanged.
83
+
68
84
  - **Config layer rewrite** — single-source-of-truth Zod schema in
69
85
  `src/core/config-schema.ts` replaces the per-field parse switch AND
70
86
  the per-shape load-time parser. Adding a new config field is now one
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # akm -- Agent Kit Manager
1
+ # akm -- Agent Knowledge Management
2
2
 
3
- > **akm** (Agent Kit Manager) -- A package manager for AI agent skills, commands, tools, and knowledge.
3
+ > **akm** (Agent Knowledge Management) -- A package manager for AI agent skills, commands, tools, and knowledge.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/akm-cli)](https://www.npmjs.com/package/akm-cli)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/akm-cli)](https://www.npmjs.com/package/akm-cli)
package/dist/cli.js CHANGED
@@ -2051,6 +2051,258 @@ const vaultCommand = defineCommand({
2051
2051
  });
2052
2052
  },
2053
2053
  });
2054
+ // ── secret ──────────────────────────────────────────────────────────────────
2055
+ //
2056
+ // `akm secret` manages whole-file secrets under each stash's secrets/ directory.
2057
+ // Unlike vaults (.env key/value), the ENTIRE file is the secret value. The bytes
2058
+ // are NEVER written to stdout or structured output. Values reach a command only
2059
+ // via `akm secret run` (injected into a child env var) or `akm secret path`
2060
+ // (the Docker /run/secrets + `_FILE` convention).
2061
+ function parseSecretRef(ref) {
2062
+ return parseAssetRef(ref.includes(":") ? ref : `secret:${ref}`);
2063
+ }
2064
+ function makeSecretRef(name, source) {
2065
+ return source?.registryId ? `${source.registryId}//secret:${name}` : `secret:${name}`;
2066
+ }
2067
+ function resolveSecretPath(ref) {
2068
+ const parsed = parseSecretRef(ref);
2069
+ if (parsed.type !== "secret") {
2070
+ throw new UsageError(`Expected a secret ref (secret:<name>); got "${ref}".`);
2071
+ }
2072
+ // Source resolution is identical for every asset type; reuse the vault helper.
2073
+ const source = findVaultSource(parsed.origin);
2074
+ const typeRoot = path.join(source.path, "secrets");
2075
+ const absPath = resolveAssetPathFromName("secret", typeRoot, parsed.name);
2076
+ // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
2077
+ if (!isWithin(absPath, typeRoot)) {
2078
+ throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
2079
+ }
2080
+ return { name: parsed.name, absPath, source };
2081
+ }
2082
+ /** Walk `secrets/` across all stashes, returning one entry per secret file. */
2083
+ function listSecretsRecursive() {
2084
+ const result = [];
2085
+ for (const source of resolveSourceEntries(undefined, loadConfig())) {
2086
+ const secretsDir = path.join(source.path, "secrets");
2087
+ if (!fs.existsSync(secretsDir))
2088
+ continue;
2089
+ const walk = (dir) => {
2090
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2091
+ const full = path.join(dir, entry.name);
2092
+ if (entry.isDirectory()) {
2093
+ walk(full);
2094
+ continue;
2095
+ }
2096
+ if (!entry.isFile())
2097
+ continue;
2098
+ if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
2099
+ continue;
2100
+ // A sibling `<name>.sensitive` marker suppresses listing.
2101
+ if (fs.existsSync(`${full}.sensitive`))
2102
+ continue;
2103
+ const canonical = deriveCanonicalAssetName("secret", secretsDir, full);
2104
+ if (!canonical)
2105
+ continue;
2106
+ result.push({ ref: makeSecretRef(canonical, source), path: full });
2107
+ }
2108
+ };
2109
+ walk(secretsDir);
2110
+ }
2111
+ return result;
2112
+ }
2113
+ const secretListCommand = defineCommand({
2114
+ meta: {
2115
+ name: "list",
2116
+ description: "List all secrets across all stashes by name (the file contents are never shown)",
2117
+ },
2118
+ run() {
2119
+ return runWithJsonErrors(async () => {
2120
+ output("secret-list", { secrets: listSecretsRecursive() });
2121
+ });
2122
+ },
2123
+ });
2124
+ const secretSetCommand = defineCommand({
2125
+ meta: {
2126
+ name: "set",
2127
+ 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.",
2128
+ },
2129
+ args: {
2130
+ ref: { type: "positional", description: "Secret ref (e.g. secret:deploy-key or just deploy-key)", required: true },
2131
+ "from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" },
2132
+ "from-env": { type: "string", description: "Read the value from the named environment variable" },
2133
+ },
2134
+ run({ args }) {
2135
+ return runWithJsonErrors(async () => {
2136
+ const { setSecret } = await import("./commands/secret.js");
2137
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2138
+ const fromEnv = getHyphenatedArg(args, "from-env");
2139
+ const fromFile = getHyphenatedArg(args, "from-file");
2140
+ if (fromEnv !== undefined && fromFile !== undefined) {
2141
+ throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
2142
+ }
2143
+ const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
2144
+ let value;
2145
+ if (fromFile !== undefined) {
2146
+ if (!fs.existsSync(fromFile)) {
2147
+ throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
2148
+ }
2149
+ value = fs.readFileSync(fromFile);
2150
+ if (value.byteLength > MAX_SECRET_BYTES) {
2151
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2152
+ }
2153
+ }
2154
+ else if (fromEnv !== undefined) {
2155
+ const envVal = process.env[fromEnv];
2156
+ if (envVal === undefined) {
2157
+ throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
2158
+ }
2159
+ value = Buffer.from(envVal, "utf8");
2160
+ }
2161
+ else {
2162
+ if (process.stdin.isTTY) {
2163
+ process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
2164
+ }
2165
+ let totalBytes = 0;
2166
+ const chunks = [];
2167
+ for await (const chunk of Bun.stdin.stream()) {
2168
+ totalBytes += chunk.byteLength;
2169
+ if (totalBytes > MAX_SECRET_BYTES) {
2170
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2171
+ }
2172
+ chunks.push(chunk);
2173
+ }
2174
+ // Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
2175
+ // stores the token without the shell-added newline. Use --from-file for
2176
+ // byte-exact storage of multi-line material (PEM keys, certs).
2177
+ value = Buffer.from(Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""), "utf8");
2178
+ }
2179
+ setSecret(absPath, value);
2180
+ output("secret-set", { ref: makeSecretRef(name, source) });
2181
+ });
2182
+ },
2183
+ });
2184
+ const secretPathCommand = defineCommand({
2185
+ meta: {
2186
+ name: "path",
2187
+ description: "Print the absolute secret file path for the Docker `_FILE` convention, e.g. `MY_SECRET_FILE=$(akm secret path secret:deploy-key)`.",
2188
+ },
2189
+ args: {
2190
+ ref: { type: "positional", description: "Secret ref", required: true },
2191
+ },
2192
+ run({ args }) {
2193
+ return runWithJsonErrors(async () => {
2194
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2195
+ if (!fs.existsSync(absPath)) {
2196
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2197
+ }
2198
+ process.stdout.write(`${absPath}\n`);
2199
+ });
2200
+ },
2201
+ });
2202
+ const secretRunCommand = defineCommand({
2203
+ meta: {
2204
+ name: "run",
2205
+ 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.",
2206
+ },
2207
+ args: {
2208
+ ref: { type: "positional", description: "Secret ref", required: true },
2209
+ var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
2210
+ },
2211
+ run({ args }) {
2212
+ return runWithJsonErrors(async () => {
2213
+ // Validate the target env var name FIRST (before the command split) so a
2214
+ // dangerous/invalid name is rejected regardless of how the command is
2215
+ // supplied — and so the failure does not depend on argv parsing.
2216
+ const varName = args.var;
2217
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
2218
+ throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
2219
+ }
2220
+ const { isDangerousVaultKey } = await import("./commands/lint/vault-key-rules.js");
2221
+ if (isDangerousVaultKey(varName)) {
2222
+ 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");
2223
+ }
2224
+ const dashIndex = process.argv.indexOf("--");
2225
+ if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2226
+ throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
2227
+ }
2228
+ const command = process.argv.slice(dashIndex + 1);
2229
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2230
+ if (!fs.existsSync(absPath)) {
2231
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2232
+ }
2233
+ const { readValue } = await import("./commands/secret.js");
2234
+ const mergedEnv = { ...process.env };
2235
+ mergedEnv[varName] = readValue(absPath).toString("utf8");
2236
+ // Audit trail: record access by ref + var name only — never the value.
2237
+ appendEvent({
2238
+ eventType: "secret_access",
2239
+ ref: makeSecretRef(name, source),
2240
+ metadata: { var: varName },
2241
+ });
2242
+ const result = spawnSync(command[0], command.slice(1), {
2243
+ stdio: "inherit",
2244
+ env: mergedEnv,
2245
+ });
2246
+ if (result.error) {
2247
+ const err = result.error;
2248
+ if (err.code === "ENOENT") {
2249
+ 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'.`);
2250
+ }
2251
+ if (err.code === "EACCES") {
2252
+ throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2253
+ }
2254
+ throw err;
2255
+ }
2256
+ process.exit(result.status ?? 0);
2257
+ });
2258
+ },
2259
+ });
2260
+ const secretRemoveCommand = defineCommand({
2261
+ meta: { name: "remove", description: "Remove a secret (and its .sensitive marker, if any)" },
2262
+ args: {
2263
+ ref: { type: "positional", description: "Secret ref", required: true },
2264
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2265
+ },
2266
+ run({ args }) {
2267
+ return runWithJsonErrors(async () => {
2268
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2269
+ const { confirmDestructive } = await import("./cli/confirm.js");
2270
+ const confirmed = await confirmDestructive(`Remove secret "${args.ref}"? This cannot be undone.`, {
2271
+ yes: args.yes === true,
2272
+ });
2273
+ if (!confirmed) {
2274
+ process.stderr.write("Aborted.\n");
2275
+ return;
2276
+ }
2277
+ const { removeSecret } = await import("./commands/secret.js");
2278
+ if (!fs.existsSync(absPath)) {
2279
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2280
+ }
2281
+ const removed = removeSecret(absPath);
2282
+ output("secret-remove", { ref: makeSecretRef(name, source), removed });
2283
+ });
2284
+ },
2285
+ });
2286
+ const secretCommand = defineCommand({
2287
+ meta: {
2288
+ name: "secret",
2289
+ description: "Manage whole-file secrets (PEM keys, tokens, certs). Names are visible; the file contents are the value and never appear in structured output.",
2290
+ },
2291
+ subCommands: {
2292
+ list: secretListCommand,
2293
+ path: secretPathCommand,
2294
+ run: secretRunCommand,
2295
+ set: secretSetCommand,
2296
+ remove: secretRemoveCommand,
2297
+ },
2298
+ run({ args }) {
2299
+ return runWithJsonErrors(async () => {
2300
+ if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
2301
+ return;
2302
+ output("secret-list", { secrets: listSecretsRecursive() });
2303
+ });
2304
+ },
2305
+ });
2054
2306
  // ── Wiki subcommands ─────────────────────────────────────────────────────────
2055
2307
  const wikiCreateCommand = defineCommand({
2056
2308
  meta: { name: "create", description: "Scaffold a new wiki under <stashDir>/wikis/<name>/" },
@@ -3171,11 +3423,11 @@ const tasksCommand = defineCommand({
3171
3423
  });
3172
3424
  },
3173
3425
  });
3174
- const main = defineCommand({
3426
+ export const main = defineCommand({
3175
3427
  meta: {
3176
3428
  name: "akm",
3177
3429
  version: pkgVersion,
3178
- description: "Agent Kit Manager — search, show, and manage assets from your stash.",
3430
+ description: "Agent Knowledge Management — search, show, and manage assets from your stash.",
3179
3431
  },
3180
3432
  args: {
3181
3433
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
@@ -3243,12 +3495,14 @@ const main = defineCommand({
3243
3495
  hints: hintsCommand,
3244
3496
  completions: completionsCommand,
3245
3497
  vault: vaultCommand,
3498
+ secret: secretCommand,
3246
3499
  wiki: wikiCommand,
3247
3500
  tasks: tasksCommand,
3248
3501
  },
3249
3502
  });
3250
3503
  const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
3251
3504
  const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
3505
+ const SECRET_SUBCOMMAND_SET = new Set(["list", "path", "run", "set", "remove"]);
3252
3506
  const WIKI_SUBCOMMAND_SET = new Set([
3253
3507
  "create",
3254
3508
  "register",
@@ -3267,62 +3521,69 @@ const EXIT_GENERAL = 1;
3267
3521
  * fired but no hard failure). Chosen as 4 to avoid colliding with EXIT_GENERAL
3268
3522
  * (1) and USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail. */
3269
3523
  const EXIT_HEALTH_WARN = 4;
3270
- // citty reads process.argv directly and does not accept a custom argv array,
3271
- // so we must replace process.argv with the normalized version before runMain.
3272
- process.argv = normalizeShowArgv(process.argv);
3273
- // Resolve output mode once at startup from the (normalized) argv and persisted
3274
- // config. All subsequent output() calls read from this in-memory singleton.
3275
- // `initOutputMode` can throw a UsageError when --format/--detail values are
3276
- // invalid; surface it through the same JSON-error path the rest of the CLI uses
3277
- // rather than letting the raw exception escape with a stack trace.
3278
- try {
3279
- applyEarlyStderrFlags(process.argv);
3280
- initOutputMode(process.argv, loadConfig().output ?? {});
3281
- }
3282
- catch (error) {
3283
- emitJsonError(error);
3284
- }
3285
- // One-time cleanup of stale 0.7.x index file at the old cache location.
3286
- // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
3287
- // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
3288
- // user isn't confused by a phantom DB. Best-effort; never fatal.
3289
- try {
3290
- const oldIndexPath = path.join(getCacheDir(), "index.db");
3291
- if (fs.existsSync(oldIndexPath)) {
3292
- fs.rmSync(oldIndexPath, { force: true });
3293
- fs.rmSync(`${oldIndexPath}-shm`, { force: true });
3294
- fs.rmSync(`${oldIndexPath}-wal`, { force: true });
3295
- warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
3524
+ // Only run the CLI when this module is the direct entry point. When it is
3525
+ // imported (e.g. by the in-process test harness in tests/_helpers/cli.ts),
3526
+ // `import.meta.main` is false and we skip all startup side effects (argv
3527
+ // mutation, output-mode init, index cleanup, banner, runMain) so importers
3528
+ // can drive the `main` command themselves without the process exiting.
3529
+ if (import.meta.main) {
3530
+ // citty reads process.argv directly and does not accept a custom argv array,
3531
+ // so we must replace process.argv with the normalized version before runMain.
3532
+ process.argv = normalizeShowArgv(process.argv);
3533
+ // Resolve output mode once at startup from the (normalized) argv and persisted
3534
+ // config. All subsequent output() calls read from this in-memory singleton.
3535
+ // `initOutputMode` can throw a UsageError when --format/--detail values are
3536
+ // invalid; surface it through the same JSON-error path the rest of the CLI uses
3537
+ // rather than letting the raw exception escape with a stack trace.
3538
+ try {
3539
+ applyEarlyStderrFlags(process.argv);
3540
+ initOutputMode(process.argv, loadConfig().output ?? {});
3296
3541
  }
3297
- }
3298
- catch {
3299
- // Non-fatal; one-time warning only.
3300
- }
3301
- // First-time-user breadcrumb: when run with no subcommand AND no config
3302
- // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
3303
- // above citty's auto-generated usage block. Triggers only when stdin/stderr
3304
- // are interactive (so JSON-output users / CI consumers see nothing extra)
3305
- // and stays silent for any flag-only invocation citty would handle itself
3306
- // (--help, --version).
3307
- (function maybePrintFirstTimeBanner() {
3308
- const argv = process.argv.slice(2);
3309
- // Fire only on completely bare `akm` invocation. Any explicit flag or
3310
- // subcommand means the user knows what they want.
3311
- if (argv.length > 0)
3312
- return;
3313
- if (!process.stderr.isTTY)
3314
- return;
3542
+ catch (error) {
3543
+ emitJsonError(error);
3544
+ }
3545
+ // One-time cleanup of stale 0.7.x index file at the old cache location.
3546
+ // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
3547
+ // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
3548
+ // user isn't confused by a phantom DB. Best-effort; never fatal.
3315
3549
  try {
3316
- if (fs.existsSync(getConfigPath()))
3317
- return;
3550
+ const oldIndexPath = path.join(getCacheDir(), "index.db");
3551
+ if (fs.existsSync(oldIndexPath)) {
3552
+ fs.rmSync(oldIndexPath, { force: true });
3553
+ fs.rmSync(`${oldIndexPath}-shm`, { force: true });
3554
+ fs.rmSync(`${oldIndexPath}-wal`, { force: true });
3555
+ warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
3556
+ }
3318
3557
  }
3319
3558
  catch {
3320
- // If we can't resolve the config path, assume non-fresh and stay silent.
3321
- return;
3559
+ // Non-fatal; one-time warning only.
3322
3560
  }
3323
- console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
3324
- })();
3325
- runMain(main);
3561
+ // First-time-user breadcrumb: when run with no subcommand AND no config
3562
+ // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
3563
+ // above citty's auto-generated usage block. Triggers only when stdin/stderr
3564
+ // are interactive (so JSON-output users / CI consumers see nothing extra)
3565
+ // and stays silent for any flag-only invocation citty would handle itself
3566
+ // (--help, --version).
3567
+ (function maybePrintFirstTimeBanner() {
3568
+ const argv = process.argv.slice(2);
3569
+ // Fire only on completely bare `akm` invocation. Any explicit flag or
3570
+ // subcommand means the user knows what they want.
3571
+ if (argv.length > 0)
3572
+ return;
3573
+ if (!process.stderr.isTTY)
3574
+ return;
3575
+ try {
3576
+ if (fs.existsSync(getConfigPath()))
3577
+ return;
3578
+ }
3579
+ catch {
3580
+ // If we can't resolve the config path, assume non-fresh and stay silent.
3581
+ return;
3582
+ }
3583
+ console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
3584
+ })();
3585
+ runMain(main);
3586
+ }
3326
3587
  // ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
3327
3588
  function loadHints(detail = "normal") {
3328
3589
  const filename = detail === "full" ? "AGENTS.full.md" : "AGENTS.md";