akm-cli 0.4.0 → 0.5.0-rc1

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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Scaffolded content for a fresh `wikis/<name>/` directory.
3
+ *
4
+ * Inlined as TypeScript constants so they ship with the published bundle
5
+ * (the build step is `tsc` — non-TS files are not copied to dist).
6
+ *
7
+ * The scaffold is deliberately minimal: akm does not prescribe conventions
8
+ * beyond the three-layer layout. `schema.md` is the per-wiki rulebook the
9
+ * agent reads first; authors customise it freely.
10
+ */
11
+ export function buildSchemaMd(wikiName) {
12
+ return `---
13
+ description: Rules that govern this wiki. Read before ingesting, searching, or editing pages.
14
+ wikiRole: schema
15
+ ---
16
+
17
+ # ${wikiName} wiki schema
18
+
19
+ This wiki follows the three-layer pattern:
20
+
21
+ - \`raw/\` — immutable ingested sources (never edit)
22
+ - \`<page>.md\` and \`<topic>/<page>.md\` — agent-authored pages
23
+ - \`schema.md\` (this file), \`index.md\`, \`log.md\` — wiki-level metadata
24
+
25
+ ## Page frontmatter
26
+
27
+ Every page should carry frontmatter so akm can index and link it:
28
+
29
+ \`\`\`yaml
30
+ ---
31
+ description: one-sentence summary used in search and lint
32
+ pageKind: entity | concept | question | note | <your-custom-kind>
33
+ xrefs:
34
+ - wiki:${wikiName}/other-page
35
+ sources:
36
+ - raw/<slug>.md
37
+ ---
38
+ \`\`\`
39
+
40
+ \`pageKind\` accepts any non-empty string. Add new categories freely; they
41
+ will surface in \`index.md\` as new sections after the next \`akm index\` run.
42
+
43
+ ## Three operations
44
+
45
+ ### Ingest
46
+
47
+ 1. Copy the new source into \`raw/\` with \`akm wiki stash ${wikiName} <path>\`.
48
+ 2. Find related pages: \`akm wiki search ${wikiName} "<terms>"\`.
49
+ 3. For each related page: append a section, note a contradiction, or create a
50
+ new page. Update xrefs on both sides.
51
+ 4. Cite the raw source in each touched page's \`sources:\` frontmatter.
52
+ 5. Append one entry to \`log.md\` describing what was assimilated.
53
+
54
+ ### Query
55
+
56
+ 1. \`akm wiki search ${wikiName} "<question>"\` — find candidate pages.
57
+ 2. \`akm show wiki:${wikiName}/<page>\` — read the top hits.
58
+ 3. Compose the answer from the wiki; cite raw sources only when the wiki
59
+ points at them.
60
+
61
+ ### Lint
62
+
63
+ 1. \`akm wiki lint ${wikiName}\` — deterministic structural checks.
64
+ 2. Resolve each finding: link orphans, fix broken xrefs, add descriptions,
65
+ cite uncited raws, refresh the index.
66
+
67
+ ## Hard rules
68
+
69
+ - \`raw/\` is immutable. Never edit ingested sources.
70
+ - Cross-references must point at pages that actually exist.
71
+ - Prefer appending to an existing page over duplicating one.
72
+ - Cite the raw source id (e.g. \`raw/2026-04-foo.md\`) when copying claims.
73
+ `;
74
+ }
75
+ export function buildIndexMd(wikiName) {
76
+ return `---
77
+ description: Catalog of pages in the ${wikiName} wiki. Regenerated by \`akm index\`.
78
+ wikiRole: index
79
+ ---
80
+
81
+ # ${wikiName} — index
82
+
83
+ _This file is regenerated on every \`akm index\` run. Manual edits are
84
+ preserved until the next regeneration, then replaced._
85
+
86
+ _(no pages yet — create one with your editor, or ingest a source with \`akm
87
+ wiki stash ${wikiName} <path>\`.)_
88
+ `;
89
+ }
90
+ export function buildLogMd(wikiName) {
91
+ return `---
92
+ description: Append-only log for the ${wikiName} wiki. Newest entries at the top.
93
+ wikiRole: log
94
+ ---
95
+
96
+ # ${wikiName} — log
97
+
98
+ _Each entry: ISO date, operation, brief summary._
99
+ `;
100
+ }
package/dist/vault.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Vault asset type — secret storage backed by `.env` files.
3
+ *
4
+ * Invariant: vault values must never be written to stdout, returned through
5
+ * the indexer, the `akm show` renderer, or any structured output channel.
6
+ * The supported load paths are:
7
+ *
8
+ * - `eval "$(akm vault load vault:<name>)"` — `vault load` parses the vault
9
+ * with dotenv (no shell expansion, no code execution), writes a safely
10
+ * single-quote-escaped `export KEY='value'` script to a mode-0600 temp
11
+ * file, and emits `. <tmp>; rm -f <tmp>` on stdout. Values reach bash
12
+ * only via the temp file, never via akm's stdout.
13
+ * - `injectIntoEnv(vaultPath, target)` — programmatic API for modules that
14
+ * need values in a process environment.
15
+ *
16
+ * Value parsing is delegated to the `dotenv` package — we deliberately do not
17
+ * implement our own quoting/escaping rules for security-sensitive content.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import dotenv from "dotenv";
22
+ /** Matches a KEY=value assignment line, capturing only the key. */
23
+ const ASSIGN_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
24
+ /** Scan lines and return KEY names in file order, without duplicates. */
25
+ function scanKeys(text) {
26
+ const keys = [];
27
+ const seen = new Set();
28
+ for (const line of text.split(/\r?\n/)) {
29
+ const m = line.match(ASSIGN_RE);
30
+ if (!m)
31
+ continue;
32
+ const key = m[1];
33
+ if (seen.has(key))
34
+ continue;
35
+ seen.add(key);
36
+ keys.push(key);
37
+ }
38
+ return keys;
39
+ }
40
+ /**
41
+ * Scan lines and return start-of-line `#` comments (with the leading `#` and
42
+ * any leading whitespace stripped). Inline/trailing `#` after an assignment is
43
+ * never extracted.
44
+ */
45
+ function scanComments(text) {
46
+ const comments = [];
47
+ for (const line of text.split(/\r?\n/)) {
48
+ const trimmed = line.trimStart();
49
+ if (trimmed.startsWith("#")) {
50
+ comments.push(trimmed.slice(1).trimStart());
51
+ }
52
+ }
53
+ return comments;
54
+ }
55
+ /**
56
+ * Read and return ONLY non-secret metadata (keys + start-of-line comments).
57
+ *
58
+ * The function reads the whole file into memory (same as any dotenv parser)
59
+ * but deliberately does not parse values — the LHS-only regex scanners above
60
+ * ensure no value content is retained or returned. The guarantee is that
61
+ * values never leave this function.
62
+ */
63
+ export function listKeys(vaultPath) {
64
+ if (!fs.existsSync(vaultPath))
65
+ return { keys: [], comments: [] };
66
+ const text = fs.readFileSync(vaultPath, "utf8");
67
+ return { keys: scanKeys(text), comments: scanComments(text) };
68
+ }
69
+ /**
70
+ * Read all KEY=value pairs from a vault file. Intended for programmatic
71
+ * callers that need to inject values into a process environment. Callers
72
+ * MUST NOT write the returned values to stdout or any logged output.
73
+ *
74
+ * Value parsing (quoting, escapes, multi-line, etc.) is delegated to dotenv.
75
+ */
76
+ export function loadEnv(vaultPath) {
77
+ if (!fs.existsSync(vaultPath))
78
+ return {};
79
+ const buf = fs.readFileSync(vaultPath);
80
+ return dotenv.parse(buf);
81
+ }
82
+ /**
83
+ * Load a vault and assign its values into `target` (defaults to `process.env`).
84
+ * Returns the list of keys that were set so the caller can log/observe without
85
+ * touching values.
86
+ *
87
+ * Existing keys in `target` are overwritten — callers who want to preserve
88
+ * pre-existing environment variables should filter before calling.
89
+ */
90
+ export function injectIntoEnv(vaultPath, target = process.env) {
91
+ const env = loadEnv(vaultPath);
92
+ for (const [key, value] of Object.entries(env)) {
93
+ target[key] = value;
94
+ }
95
+ return Object.keys(env);
96
+ }
97
+ /**
98
+ * Serialise a vault's values as a POSIX shell script of `export KEY='value'`
99
+ * lines, with single-quote escaping (`'\''`). Every line is an assignment of
100
+ * a literal string — there is no expansion, command substitution, or
101
+ * non-assignment content, so sourcing the output is safe regardless of what
102
+ * the vault file contains.
103
+ *
104
+ * Intended for use by `akm vault load`, which writes this to a mode-0600
105
+ * temp file and emits only the path (never values) on stdout.
106
+ */
107
+ export function buildShellExportScript(vaultPath) {
108
+ const env = loadEnv(vaultPath);
109
+ const lines = [];
110
+ for (const [key, value] of Object.entries(env)) {
111
+ // Defence in depth: dotenv already validates key shape, but reject any
112
+ // key we wouldn't be able to export safely.
113
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
114
+ continue;
115
+ const escaped = value.replace(/'/g, "'\\''");
116
+ lines.push(`export ${key}='${escaped}'`);
117
+ }
118
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
119
+ }
120
+ /**
121
+ * Set a key in the vault file, preserving line order and comments. Creates
122
+ * the file (and parent directory) if it does not exist.
123
+ *
124
+ * `quoteValue` picks the safest representation that dotenv round-trips:
125
+ * single-quoted when the value has no `'`, double-quoted when it has `'` but
126
+ * no `"` and no literal `\n`/`\r` escape sequences, and unquoted only for
127
+ * values that contain no characters requiring escaping (see quoteValue for
128
+ * the full rule set). Values containing newlines or both quote types are
129
+ * rejected outright. Round-trip safety is enforced by the test suite.
130
+ *
131
+ * When `comment` is provided it is written as a `# <comment>` line
132
+ * immediately before the `KEY=value` line:
133
+ * - New key: the comment line is inserted just before the appended key.
134
+ * - Existing key: if the preceding line is already a comment it is replaced
135
+ * with the new comment; otherwise a new comment line is inserted.
136
+ * When `comment` is absent the surrounding comment lines are left unchanged.
137
+ */
138
+ export function setKey(vaultPath, key, value, comment) {
139
+ validateKeyName(key);
140
+ ensureParentDir(vaultPath);
141
+ const existing = fs.existsSync(vaultPath) ? fs.readFileSync(vaultPath, "utf8") : "";
142
+ const lines = existing.length > 0 ? existing.split(/\r?\n/) : [];
143
+ const formatted = `${key}=${quoteValue(value)}`;
144
+ let replaced = false;
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const m = lines[i].match(ASSIGN_RE);
147
+ if (m && m[1] === key) {
148
+ lines[i] = formatted;
149
+ replaced = true;
150
+ if (comment !== undefined) {
151
+ const commentLine = `# ${comment}`;
152
+ const prevIsComment = i > 0 && lines[i - 1].trimStart().startsWith("#");
153
+ if (prevIsComment) {
154
+ lines[i - 1] = commentLine;
155
+ }
156
+ else {
157
+ lines.splice(i, 0, commentLine);
158
+ }
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ if (!replaced) {
164
+ if (comment !== undefined) {
165
+ const commentLine = `# ${comment}`;
166
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
167
+ lines[lines.length - 1] = commentLine;
168
+ lines.push(formatted);
169
+ lines.push("");
170
+ }
171
+ else {
172
+ lines.push(commentLine);
173
+ lines.push(formatted);
174
+ }
175
+ }
176
+ else if (lines.length > 0 && lines[lines.length - 1] === "") {
177
+ lines[lines.length - 1] = formatted;
178
+ lines.push("");
179
+ }
180
+ else {
181
+ lines.push(formatted);
182
+ }
183
+ }
184
+ let out = lines.join("\n");
185
+ if (!out.endsWith("\n"))
186
+ out += "\n";
187
+ writeFileAtomic(vaultPath, out);
188
+ }
189
+ /** Remove a key from the vault file. Returns true if the key was present. */
190
+ export function unsetKey(vaultPath, key) {
191
+ if (!fs.existsSync(vaultPath))
192
+ return false;
193
+ const text = fs.readFileSync(vaultPath, "utf8");
194
+ const lines = text.split(/\r?\n/);
195
+ const kept = [];
196
+ let removed = false;
197
+ for (const line of lines) {
198
+ const m = line.match(ASSIGN_RE);
199
+ if (m && m[1] === key) {
200
+ removed = true;
201
+ continue;
202
+ }
203
+ kept.push(line);
204
+ }
205
+ if (!removed)
206
+ return false;
207
+ let out = kept.join("\n");
208
+ if (out.length > 0 && !out.endsWith("\n"))
209
+ out += "\n";
210
+ writeFileAtomic(vaultPath, out);
211
+ return true;
212
+ }
213
+ /** Create an empty vault file (does nothing if it already exists). */
214
+ export function createVault(vaultPath) {
215
+ ensureParentDir(vaultPath);
216
+ if (fs.existsSync(vaultPath))
217
+ return;
218
+ writeFileAtomic(vaultPath, "");
219
+ }
220
+ /**
221
+ * Characters that are safe in an UNquoted dotenv value AND are not
222
+ * metacharacters in POSIX shells. Anything outside this set forces quoting,
223
+ * which is defense-in-depth for any caller that might ever `source` the
224
+ * vault file directly instead of going through `akm vault load`.
225
+ */
226
+ const UNQUOTED_SAFE_RE = /^[A-Za-z0-9_.:/@%+,-]+$/;
227
+ /**
228
+ * Quote a value for safe storage in a .env file that round-trips through
229
+ * `dotenv.parse` AND is safe if the file is ever `source`d by a POSIX shell.
230
+ *
231
+ * Strategy:
232
+ * - empty → empty
233
+ * - all-safe chars (alnum + `_.:/@%+,-`) → unquoted
234
+ * - no `'` → single-quote (dotenv and shell both treat single-quoted
235
+ * content literally: no expansion, no escapes)
236
+ * - no `"` and no literal `\n`/`\r` escape sequence → double-quote
237
+ * (dotenv unescapes `\n`/`\r` on read, so we
238
+ * can't double-quote a value that contains
239
+ * those literal sequences)
240
+ * - newlines or both quote types → reject
241
+ *
242
+ * dotenv intentionally does NOT support `\"` inside double-quoted values, so
243
+ * we never produce that pattern.
244
+ */
245
+ function quoteValue(value) {
246
+ if (value.length === 0)
247
+ return "";
248
+ if (/[\n\r]/.test(value)) {
249
+ throw new Error("Vault values cannot contain literal newlines.");
250
+ }
251
+ if (UNQUOTED_SAFE_RE.test(value))
252
+ return value;
253
+ if (!value.includes("'"))
254
+ return `'${value}'`;
255
+ if (!value.includes('"') && !/\\[nr]/.test(value))
256
+ return `"${value}"`;
257
+ throw new Error("Vault value contains both single and double quote characters; not supported.");
258
+ }
259
+ function validateKeyName(key) {
260
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
261
+ throw new Error(`Invalid vault key name: "${key}". Must match [A-Za-z_][A-Za-z0-9_]*`);
262
+ }
263
+ }
264
+ function ensureParentDir(filePath) {
265
+ const dir = path.dirname(filePath);
266
+ if (!fs.existsSync(dir))
267
+ fs.mkdirSync(dir, { recursive: true });
268
+ }
269
+ function writeFileAtomic(filePath, content) {
270
+ const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
271
+ try {
272
+ fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
273
+ fs.renameSync(tmp, filePath);
274
+ try {
275
+ fs.chmodSync(filePath, 0o600);
276
+ }
277
+ catch {
278
+ /* best-effort on platforms without chmod */
279
+ }
280
+ }
281
+ catch (err) {
282
+ try {
283
+ fs.unlinkSync(tmp);
284
+ }
285
+ catch {
286
+ /* ignore cleanup failure */
287
+ }
288
+ throw err;
289
+ }
290
+ }