UNPKG

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

220 lines (219 loc) • 9.49 kB
// 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/. /** * The `dotenv` adapter — akm 0.9.0 format-family work item (#46). * * A metadata-only env/secret bundle (spec §6/§7, normative §21.2). REDACTION IS * A HARD CONTRACT keyed on the ADAPTER, never on the open `type` value (a * frontmatter `type:` cannot opt out): * - `env/*.env` → `type: env` — only the KEY NAMES are surfaced (on `hints`); * VALUES, COMMENTS, and raw CONTENT are NEVER read into the index. * - any file under `secrets/` → `type: secret` — only the FILE NAME is * surfaced; the whole file is the value and is never read. A `.env` UNDER * `secrets/` is a SECRET (the dir gate wins) — name-only, MORE redacted than * an `env/` file even though it has KEY=VALUE lines. * conceptId: env strips `.env`; secret keeps its natural path (including any * extension). Neither branch ever writes a value onto the emitted document. * * ── validate (spec §6 env/secret validation column) ── * * The dangerous-key scan (`dangerous-env-key`), reusing the akm adapter's * `dangerousEnvKeyDiagnostics` — which preserves the code-grounded narrowness: * it runs ONLY on `*.env`-suffixed files, so `secrets/<bare-name>` is never * scanned (its whole content is an opaque secret value). Reads KEY NAMES only. * * Conformance oracle (authored, DO NOT modify): fixture * `tests/fixtures/bundles/dotenv/` + goldens * `tests/fixtures/format-family-goldens/dotenv/{recognition,placement,lint,renderer}.json`. */ import fs from "node:fs"; import path from "node:path"; import { assetPathCandidatesForName, assetPathForName, typeForStashDir } from "../../asset/asset-placement.js"; import { scanEnvKeyNames, toPosix } from "../../common.js"; import { dangerousEnvKeyDiagnostics } from "./akm-lint.js"; import { hashContent } from "./shared.js"; /** A dotenv bundle is single-component; its one component is `main`. */ const COMPONENT_ID = "main"; /** The `env/` content subdir (KEY=VALUE files). */ const ENV_DIR = "env"; /** The `secrets/` content subdir (whole-file secrets). */ const SECRETS_DIR = "secrets"; /** Non-secret marker suffixes under `secrets/` (spec §6 secret row). */ const SECRET_SKIP_SUFFIXES = [".lock", ".sensitive"]; /** Classify a component-root-relative file as env / secret, or null (abstain). */ function classify(relPath) { const posix = toPosix(relPath); const segs = posix.split("/").filter((s) => s.length > 0); if (segs.length < 2) return null; const head = segs[0]; const base = segs[segs.length - 1]; if (head === ENV_DIR) { return base === ".env" || base.endsWith(".env") ? "env" : null; } if (head === SECRETS_DIR) { return SECRET_SKIP_SUFFIXES.some((s) => base.endsWith(s)) ? null : "secret"; } return null; } /** * True when `--sensitive` marked this asset, via the sibling marker file that * `akm env create --sensitive` / `akm secret create --sensitive` writes: * `env/<name>.sensitive` for `env/<name>.env`, `secrets/<name>.sensitive` for * `secrets/<name>`. * * The flag documents itself as excluding the asset from BOTH `env list` output * and the search index. Indexing filters are adapter-owned (the walk no longer * pre-filters), and the akm adapter abstains on the marker — but this adapter * only skipped files whose OWN name ended in `.sensitive`. A dotenv bundle is a * legal env/secret write target, so a marked `env/prod.env` there was still * indexed with every KEY NAME as a hint and a marked secret still indexed by * name, while `env list` / `secret list` correctly hid them. The two surfaces * disagreed about a documented promise. */ function hasSensitiveMarker(absPath, type) { const marker = type === "env" ? absPath.replace(/\.env$/i, ".sensitive") : `${absPath}.sensitive`; return marker !== absPath && fs.existsSync(marker); } function conceptIdForPath(type, relativePath) { const posix = toPosix(relativePath); if (type === "secret") return posix; const stripped = posix.replace(/\.env$/i, ""); return stripped.endsWith("/") ? `${stripped}default` : stripped; } function recognize(c, file) { const type = classify(file.relPath); if (type === null) return null; if (hasSensitiveMarker(file.absPath, type)) return null; const posix = toPosix(file.relPath); const raw = file.content(); if (type === "env") { // env: strip `.env`; surface KEY NAMES only (never values/comments/content). const conceptId = conceptIdForPath(type, posix); const name = (conceptId.split("/").pop() ?? conceptId) || "default"; const keys = scanEnvKeyNames(raw); const doc = { ref: `${c.id}//${conceptId}`, bundle: c.id, component: COMPONENT_ID, conceptId, path: file.absPath, hash: hashContent(raw), adapterId: "dotenv", type: "env", name, }; if (keys.length > 0) doc.hints = keys; return doc; } // secret: keep the natural path; surface the FILE NAME only — never keys/content. const name = posix.split("/").pop() ?? posix; return { ref: `${c.id}//${posix}`, bundle: c.id, component: COMPONENT_ID, conceptId: posix, path: file.absPath, hash: hashContent(raw), adapterId: "dotenv", type: "secret", name, }; } async function validate(_c, changes, ctx) { const diagnostics = []; for (const change of changes) { if (change.op === "delete") continue; const raw = change.after ?? (await ctx.readFile(change.path)); if (typeof raw !== "string") continue; const type = classify(change.path); if (type === null) continue; // dangerousEnvKeyDiagnostics is `.env`-suffix-narrow: a bare secret file is never scanned. diagnostics.push(...dangerousEnvKeyDiagnostics(type, toPosix(change.path), raw)); } return diagnostics; } export const dotenvAdapter = { id: "dotenv", version: "0.9.0", extensions: [".env"], recognize, validate, /** * Closed-form owner candidates (#857): `env`/`secret` are always authored * directly under their own stash subdir (`classify` requires it — no * off-canonical loose placement exists for this adapter), so there is no * loose-fallback class to enumerate here, unlike `akm-adapter`. * `assetPathCandidatesForName` expands `env`'s `.env`/`<name>.env` duality; * the sensitive-marker sibling spellings are then layered on each. */ readCandidates(c, conceptId) { const posix = toPosix(conceptId); const slash = posix.indexOf("/"); if (slash <= 0) return []; const head = posix.slice(0, slash); const rest = posix.slice(slash + 1); const type = typeForStashDir(head); if ((type !== "env" && type !== "secret") || rest.length === 0) return []; const primaries = assetPathCandidatesForName(type, path.join(c.root, head), rest); const expanded = primaries.flatMap((primary) => type === "env" ? [primary, primary.replace(/\.env$/i, ".sensitive")] : [primary, `${primary}.sensitive`, `${primary}.lock`]); return expanded.map((candidatePath) => ({ path: candidatePath, conceptId: posix })); }, /** * env places to `env/<name>.env`, secret to `secrets/<name>` (identity join, * no extension logic) — reusing the shared `assetPathForName` so the env * default-alias / already-suffixed / nested-secret edge cases match the akm * placement convention exactly. */ placeNew(c, conceptId) { const posix = toPosix(conceptId); const slash = posix.indexOf("/"); if (slash > 0) { const head = posix.slice(0, slash); const rest = posix.slice(slash + 1); const type = typeForStashDir(head); if ((type === "env" || type === "secret") && rest.length > 0) { return assetPathForName(type, path.join(c.root, head), rest); } } return path.join(c.root, posix); }, /** The dotenv bundle owns its `env/` + `secrets/` dirs. */ directoryList() { return [ENV_DIR, SECRETS_DIR]; }, /** * Install-time probe (§1.2): a root whose ONLY content dirs are `env/` and/or * `secrets/` (at least one present). The env/secrets-ONLY requirement keeps a * full akm workspace — which also carries `env/` + `secrets/` alongside many * other stash subdirs — from being mistaken for a dotenv bundle, so the probe * is registered ahead of `akm` without shadowing it. */ looksLikeRoot(root) { let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return false; } const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); if (dirs.length === 0) return false; if (!dirs.every((d) => d === ENV_DIR || d === SECRETS_DIR)) return false; return dirs.includes(ENV_DIR) || dirs.includes(SECRETS_DIR); }, };