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.
- package/dist/asset-registry.js +7 -0
- package/dist/asset-spec.js +35 -0
- package/dist/cli.js +1383 -25
- package/dist/completions.js +2 -2
- package/dist/config-cli.js +41 -0
- package/dist/config.js +62 -0
- package/dist/embedder.js +26 -4
- package/dist/file-context.js +2 -1
- package/dist/github.js +20 -1
- package/dist/indexer.js +55 -5
- package/dist/init.js +11 -0
- package/dist/install-audit.js +53 -8
- package/dist/installed-kits.js +2 -0
- package/dist/llm.js +64 -23
- package/dist/matchers.js +56 -4
- package/dist/metadata.js +68 -4
- package/dist/paths.js +3 -0
- package/dist/registry-install.js +36 -7
- package/dist/registry-resolve.js +25 -0
- package/dist/renderers.js +182 -2
- package/dist/search-fields.js +4 -0
- package/dist/search-source.js +12 -8
- package/dist/setup.js +158 -33
- package/dist/stash-add.js +84 -11
- package/dist/stash-providers/git.js +182 -44
- package/dist/stash-show.js +56 -1
- package/dist/stash-source-manage.js +14 -4
- package/dist/templates/wiki-templates.js +100 -0
- package/dist/vault.js +290 -0
- package/dist/wiki.js +886 -0
- package/dist/workflow-authoring.js +131 -0
- package/dist/workflow-cli.js +44 -0
- package/dist/workflow-db.js +55 -0
- package/dist/workflow-markdown.js +251 -0
- package/dist/workflow-runs.js +364 -0
- package/package.json +2 -1
- package/LICENSE +0 -374
|
@@ -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
|
+
}
|