akm-cli 0.0.0 → 0.0.17

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/llm.js ADDED
@@ -0,0 +1,87 @@
1
+ import { fetchWithTimeout } from "./common";
2
+ async function chatCompletion(config, messages) {
3
+ const headers = { "Content-Type": "application/json" };
4
+ if (config.apiKey) {
5
+ headers.Authorization = `Bearer ${config.apiKey}`;
6
+ }
7
+ const response = await fetchWithTimeout(config.endpoint, {
8
+ method: "POST",
9
+ headers,
10
+ body: JSON.stringify({
11
+ model: config.model,
12
+ messages,
13
+ temperature: config.temperature ?? 0.3,
14
+ max_tokens: config.maxTokens ?? 512,
15
+ }),
16
+ });
17
+ if (!response.ok) {
18
+ const body = await response.text().catch(() => "");
19
+ throw new Error(`LLM request failed (${response.status}): ${body}`);
20
+ }
21
+ const json = (await response.json());
22
+ return json.choices?.[0]?.message?.content?.trim() ?? "";
23
+ }
24
+ // ── Metadata Enhancement ────────────────────────────────────────────────────
25
+ const SYSTEM_PROMPT = `You are a metadata generator for a developer asset registry. Given a script/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`;
26
+ /**
27
+ * Use an LLM to enhance a stash entry's metadata: improve description,
28
+ * generate searchHints, and suggest tags.
29
+ */
30
+ export async function enhanceMetadata(config, entry, fileContent) {
31
+ const contextParts = [`Name: ${entry.name}`, `Type: ${entry.type}`];
32
+ if (entry.description)
33
+ contextParts.push(`Current description: ${entry.description}`);
34
+ if (entry.tags?.length)
35
+ contextParts.push(`Current tags: ${entry.tags.join(", ")}`);
36
+ if (fileContent) {
37
+ // Limit content to first 2000 chars to stay within token limits
38
+ const truncated = fileContent.length > 2000 ? `${fileContent.slice(0, 2000)}\n... (truncated)` : fileContent;
39
+ contextParts.push(`File content:\n${truncated}`);
40
+ }
41
+ const userPrompt = `${contextParts.join("\n")}
42
+
43
+ Generate improved metadata for this ${entry.type}. Return JSON with these fields:
44
+ - "description": a clear, concise one-sentence description of what this does
45
+ - "searchHints": an array of 3-6 natural language task phrases an agent might use to find this (e.g. "deploy a docker container", "run database migrations")
46
+ - "tags": an array of 3-8 relevant keyword tags
47
+
48
+ Return ONLY the JSON object, no explanation.`;
49
+ const raw = await chatCompletion(config, [
50
+ { role: "system", content: SYSTEM_PROMPT },
51
+ { role: "user", content: userPrompt },
52
+ ]);
53
+ try {
54
+ // Strip markdown code fences if present
55
+ const cleaned = raw.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
56
+ const parsed = JSON.parse(cleaned);
57
+ const result = {};
58
+ if (typeof parsed.description === "string" && parsed.description) {
59
+ result.description = parsed.description;
60
+ }
61
+ if (Array.isArray(parsed.searchHints)) {
62
+ result.searchHints = parsed.searchHints
63
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
64
+ .slice(0, 8);
65
+ }
66
+ if (Array.isArray(parsed.tags)) {
67
+ result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
68
+ }
69
+ return result;
70
+ }
71
+ catch {
72
+ // LLM returned unparseable output, return empty
73
+ return {};
74
+ }
75
+ }
76
+ /**
77
+ * Check if the LLM endpoint is reachable.
78
+ */
79
+ export async function isLlmAvailable(config) {
80
+ try {
81
+ const result = await chatCompletion(config, [{ role: "user", content: "Respond with just the word: ok" }]);
82
+ return result.length > 0;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getConfigDir } from "./config";
4
+ // ── Paths ───────────────────────────────────────────────────────────────────
5
+ function getLockfilePath() {
6
+ return path.join(getConfigDir(), "stash.lock");
7
+ }
8
+ // ── Read / Write ────────────────────────────────────────────────────────────
9
+ export function readLockfile() {
10
+ const lockfilePath = getLockfilePath();
11
+ try {
12
+ const raw = JSON.parse(fs.readFileSync(lockfilePath, "utf8"));
13
+ if (!Array.isArray(raw))
14
+ return [];
15
+ return raw.filter(isValidLockfileEntry);
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ }
21
+ export function writeLockfile(entries) {
22
+ const lockfilePath = getLockfilePath();
23
+ const dir = path.dirname(lockfilePath);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ const tmpPath = `${lockfilePath}.tmp.${process.pid}`;
26
+ try {
27
+ fs.writeFileSync(tmpPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
28
+ fs.renameSync(tmpPath, lockfilePath);
29
+ }
30
+ catch (err) {
31
+ try {
32
+ fs.unlinkSync(tmpPath);
33
+ }
34
+ catch {
35
+ /* ignore cleanup failure */
36
+ }
37
+ throw err;
38
+ }
39
+ }
40
+ export function upsertLockEntry(entry) {
41
+ const entries = readLockfile();
42
+ const withoutExisting = entries.filter((e) => e.id !== entry.id);
43
+ writeLockfile([...withoutExisting, entry]);
44
+ }
45
+ export function removeLockEntry(id) {
46
+ const entries = readLockfile();
47
+ writeLockfile(entries.filter((e) => e.id !== id));
48
+ }
49
+ // ── Helpers ─────────────────────────────────────────────────────────────────
50
+ function isValidLockfileEntry(value) {
51
+ if (typeof value !== "object" || value === null || Array.isArray(value))
52
+ return false;
53
+ const obj = value;
54
+ return (typeof obj.id === "string" &&
55
+ obj.id !== "" &&
56
+ typeof obj.source === "string" &&
57
+ ["npm", "github", "git", "local"].includes(obj.source) &&
58
+ typeof obj.ref === "string" &&
59
+ obj.ref !== "");
60
+ }
@@ -0,0 +1,77 @@
1
+ import { parseFrontmatter } from "./frontmatter";
2
+ // ── Parsing ─────────────────────────────────────────────────────────────────
3
+ export function parseMarkdownToc(content) {
4
+ const lines = content.split(/\r?\n/);
5
+ const headings = [];
6
+ const parsed = parseFrontmatter(content);
7
+ const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0;
8
+ for (let i = start; i < lines.length; i++) {
9
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
10
+ if (match) {
11
+ headings.push({
12
+ level: match[1].length,
13
+ text: match[2].replace(/\s+#+\s*$/, "").trim(),
14
+ line: i + 1,
15
+ });
16
+ }
17
+ }
18
+ return { headings, totalLines: lines.length };
19
+ }
20
+ // ── Extraction ──────────────────────────────────────────────────────────────
21
+ export function extractSection(content, heading) {
22
+ const lines = content.split(/\r?\n/);
23
+ const target = heading.toLowerCase();
24
+ let startIdx = -1;
25
+ let startLevel = 0;
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
28
+ if (!match)
29
+ continue;
30
+ const text = match[2].replace(/\s+#+\s*$/, "").trim();
31
+ if (text.toLowerCase() === target && startIdx === -1) {
32
+ startIdx = i;
33
+ startLevel = match[1].length;
34
+ }
35
+ else if (startIdx !== -1 && match[1].length <= startLevel) {
36
+ return {
37
+ content: lines.slice(startIdx, i).join("\n"),
38
+ startLine: startIdx + 1,
39
+ endLine: i,
40
+ };
41
+ }
42
+ }
43
+ if (startIdx === -1)
44
+ return null;
45
+ return {
46
+ content: lines.slice(startIdx).join("\n"),
47
+ startLine: startIdx + 1,
48
+ endLine: lines.length,
49
+ };
50
+ }
51
+ export function extractLineRange(content, start, end) {
52
+ const lines = content.split(/\r?\n/);
53
+ if (end < start)
54
+ return "";
55
+ const s = Math.max(1, Math.min(start, lines.length));
56
+ const e = Math.min(end, lines.length);
57
+ return lines.slice(s - 1, e).join("\n");
58
+ }
59
+ export function extractFrontmatterOnly(content) {
60
+ const parsed = parseFrontmatter(content);
61
+ return parsed.frontmatter;
62
+ }
63
+ // ── Formatting ──────────────────────────────────────────────────────────────
64
+ export function formatToc(toc) {
65
+ if (toc.headings.length === 0) {
66
+ return `(no headings found — ${toc.totalLines} lines total)`;
67
+ }
68
+ const lineWidth = String(toc.totalLines).length;
69
+ const parts = toc.headings.map((h) => {
70
+ const lineNum = `L${String(h.line).padStart(lineWidth)}`;
71
+ const indent = " ".repeat(h.level - 1);
72
+ const prefix = "#".repeat(h.level);
73
+ return `${lineNum} ${indent}${prefix} ${h.text}`;
74
+ });
75
+ parts.push(`\n${toc.totalLines} lines total`);
76
+ return parts.join("\n");
77
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Built-in asset matchers for the akm file classification system.
3
+ *
4
+ * Four matchers are registered at module load time, each at a different
5
+ * specificity level. Extension and content determine type; directories are
6
+ * optional specificity boosts, not requirements.
7
+ *
8
+ * - `extensionMatcher` (3) -- classifies any file by extension alone.
9
+ * Ensures every known file type is discoverable regardless of directory.
10
+ * - `directoryMatcher` (10) -- boosts specificity when the first ancestor
11
+ * directory matches a known type name (e.g. `scripts/`, `agents/`).
12
+ * - `parentDirHintMatcher` (15) -- boosts specificity based on the
13
+ * immediate parent directory name.
14
+ * - `smartMdMatcher` (20 / 18 / 8 / 5) -- inspects markdown frontmatter
15
+ * and body content for agent/command signals; falls back to "knowledge"
16
+ * at specificity 5 when no signals are found. Command signals (`agent`
17
+ * frontmatter, `$ARGUMENTS`/`$1`-`$3` placeholders) return 18.
18
+ */
19
+ import { SCRIPT_EXTENSIONS } from "./asset-spec";
20
+ import { registerMatcher } from "./file-context";
21
+ // ── extensionMatcher (specificity: 3) ────────────────────────────────────────
22
+ /**
23
+ * Base-level matcher that classifies files purely by extension.
24
+ *
25
+ * This is the foundation of the classification system: every file with a
26
+ * known extension gets a type, regardless of what directory it lives in.
27
+ * Higher-specificity matchers (directory, content) can override this.
28
+ *
29
+ * .md files are NOT handled here -- smartMdMatcher provides richer
30
+ * classification for markdown via frontmatter inspection.
31
+ */
32
+ export function extensionMatcher(ctx) {
33
+ // SKILL.md is a skill regardless of location — high specificity beats
34
+ // smartMdMatcher's knowledge fallback and all directory-based matchers.
35
+ if (ctx.fileName === "SKILL.md") {
36
+ return { type: "skill", specificity: 25, renderer: "skill-md" };
37
+ }
38
+ // Known script extensions (excluding .md, handled by smartMdMatcher)
39
+ if (SCRIPT_EXTENSIONS.has(ctx.ext)) {
40
+ return { type: "script", specificity: 3, renderer: "script-source" };
41
+ }
42
+ return null;
43
+ }
44
+ // ── directoryMatcher (specificity: 10) ──────────────────────────────────────
45
+ /**
46
+ * Directory-based matcher that boosts specificity when the first ancestor
47
+ * directory segment from the stash root matches a known type name.
48
+ */
49
+ export function directoryMatcher(ctx) {
50
+ const topDir = ctx.ancestorDirs[0];
51
+ if (!topDir)
52
+ return null;
53
+ const ext = ctx.ext;
54
+ if (topDir === "scripts" && SCRIPT_EXTENSIONS.has(ext)) {
55
+ return { type: "script", specificity: 10, renderer: "script-source" };
56
+ }
57
+ if (topDir === "skills" && ctx.fileName === "SKILL.md") {
58
+ return { type: "skill", specificity: 10, renderer: "skill-md" };
59
+ }
60
+ if (topDir === "commands" && ext === ".md") {
61
+ return { type: "command", specificity: 10, renderer: "command-md" };
62
+ }
63
+ if (topDir === "agents" && ext === ".md") {
64
+ return { type: "agent", specificity: 10, renderer: "agent-md" };
65
+ }
66
+ if (topDir === "knowledge" && ext === ".md") {
67
+ return { type: "knowledge", specificity: 10, renderer: "knowledge-md" };
68
+ }
69
+ return null;
70
+ }
71
+ // ── parentDirHintMatcher (specificity: 15) ──────────────────────────────────
72
+ /**
73
+ * Uses the immediate parent directory name as a hint. More specific than
74
+ * the ancestor-based directory matcher because the file might be nested
75
+ * several levels deep, yet its immediate parent can still carry strong
76
+ * naming conventions (e.g. `my-project/agents/planning.md`).
77
+ */
78
+ export function parentDirHintMatcher(ctx) {
79
+ const { parentDir, ext, fileName } = ctx;
80
+ if (parentDir === "scripts" && SCRIPT_EXTENSIONS.has(ext)) {
81
+ return { type: "script", specificity: 15, renderer: "script-source" };
82
+ }
83
+ if (parentDir === "skills" && fileName === "SKILL.md") {
84
+ return { type: "skill", specificity: 15, renderer: "skill-md" };
85
+ }
86
+ if (parentDir === "agents" && ext === ".md") {
87
+ return { type: "agent", specificity: 15, renderer: "agent-md" };
88
+ }
89
+ if (parentDir === "commands" && ext === ".md") {
90
+ return { type: "command", specificity: 15, renderer: "command-md" };
91
+ }
92
+ if (parentDir === "knowledge" && ext === ".md") {
93
+ return { type: "knowledge", specificity: 15, renderer: "knowledge-md" };
94
+ }
95
+ return null;
96
+ }
97
+ // ── smartMdMatcher (specificity: 20 / 18 / 8 / 5) ──────────────────────────
98
+ /** Pattern that matches OpenCode command placeholders in markdown body. */
99
+ const COMMAND_PLACEHOLDER_RE = /\$ARGUMENTS|\$[123]\b/;
100
+ /**
101
+ * Content-based matcher for `.md` files. Inspects frontmatter keys and body
102
+ * content to classify markdown as agent, command, or knowledge.
103
+ *
104
+ * Specificity levels:
105
+ * 20 -- agent-exclusive signals (`tools`, `toolPolicy`)
106
+ * 18 -- command content signals (`agent` frontmatter, `$ARGUMENTS`/`$1`-`$3`)
107
+ * 8 -- weak agent signal (`model` alone)
108
+ * 5 -- knowledge fallback (any unclassified `.md`)
109
+ *
110
+ * Command signals at 18 override directory hints (10/15) because the content
111
+ * unambiguously identifies a command template. Agent-exclusive signals at 20
112
+ * still win over command signals when both are present.
113
+ */
114
+ export function smartMdMatcher(ctx) {
115
+ if (ctx.ext !== ".md")
116
+ return null;
117
+ const fm = ctx.frontmatter();
118
+ if (fm) {
119
+ // Agent-exclusive indicators: toolPolicy or tools
120
+ // These return high specificity (20) to override everything else.
121
+ if ("toolPolicy" in fm || "tools" in fm) {
122
+ return { type: "agent", specificity: 20, renderer: "agent-md" };
123
+ }
124
+ // Command signal: `agent` frontmatter key names a dispatch target.
125
+ // This is an OpenCode convention specific to commands.
126
+ if ("agent" in fm) {
127
+ return { type: "command", specificity: 18, renderer: "command-md" };
128
+ }
129
+ }
130
+ // Command signal: body contains $ARGUMENTS or $1/$2/$3 placeholders.
131
+ // These are definitively command template patterns (OpenCode convention).
132
+ const body = ctx.content();
133
+ if (COMMAND_PLACEHOLDER_RE.test(body)) {
134
+ return { type: "command", specificity: 18, renderer: "command-md" };
135
+ }
136
+ if (fm) {
137
+ // model alone is a weaker agent signal (specificity 8) -- it can appear
138
+ // on commands too (OpenCode convention). Directory hints (10/15) win
139
+ // when the file lives in commands/, but model still classifies an .md
140
+ // as agent when no directory hint is present.
141
+ if ("model" in fm) {
142
+ return { type: "agent", specificity: 8, renderer: "agent-md" };
143
+ }
144
+ }
145
+ // Weak fallback: any .md file is assumed to be knowledge
146
+ return { type: "knowledge", specificity: 5, renderer: "knowledge-md" };
147
+ }
148
+ // ── Registration ────────────────────────────────────────────────────────────
149
+ /** All built-in matchers in registration order (later wins ties). */
150
+ const builtinMatchers = [extensionMatcher, directoryMatcher, parentDirHintMatcher, smartMdMatcher];
151
+ /**
152
+ * Register all built-in matchers with the file-context registry.
153
+ * Called once from the CLI entry point (or ensureBuiltinsRegistered).
154
+ */
155
+ export function registerBuiltinMatchers() {
156
+ for (const matcher of builtinMatchers) {
157
+ registerMatcher(matcher);
158
+ }
159
+ }