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.
@@ -0,0 +1,408 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { deriveCanonicalAssetName, isRelevantAssetFile, TYPE_DIRS } from "./asset-spec";
4
+ import { isAssetType } from "./common";
5
+ import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "./file-context";
6
+ import { parseFrontmatter, toStringOrUndefined } from "./frontmatter";
7
+ import { warn } from "./warn";
8
+ // ── Load / Write ────────────────────────────────────────────────────────────
9
+ const STASH_FILENAME = ".stash.json";
10
+ export function stashFilePath(dirPath) {
11
+ return path.join(dirPath, STASH_FILENAME);
12
+ }
13
+ export function loadStashFile(dirPath) {
14
+ const filePath = stashFilePath(dirPath);
15
+ if (!fs.existsSync(filePath))
16
+ return null;
17
+ try {
18
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
19
+ if (!raw || !Array.isArray(raw.entries))
20
+ return null;
21
+ const entries = [];
22
+ for (const e of raw.entries) {
23
+ const validated = validateStashEntry(e);
24
+ if (validated) {
25
+ entries.push(validated);
26
+ }
27
+ else {
28
+ const name = typeof e === "object" && e !== null && typeof e.name === "string"
29
+ ? e.name
30
+ : "(unknown)";
31
+ warn(`Warning: Skipping invalid entry "${name}" in ${filePath}`);
32
+ }
33
+ }
34
+ return entries.length > 0 ? { entries } : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function writeStashFile(dirPath, stash) {
41
+ const filePath = stashFilePath(dirPath);
42
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
43
+ try {
44
+ fs.writeFileSync(tmpPath, `${JSON.stringify(stash, null, 2)}\n`, "utf8");
45
+ fs.renameSync(tmpPath, filePath);
46
+ }
47
+ catch (err) {
48
+ try {
49
+ fs.unlinkSync(tmpPath);
50
+ }
51
+ catch {
52
+ /* ignore cleanup failure */
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+ export function validateStashEntry(entry) {
58
+ if (typeof entry !== "object" || entry === null)
59
+ return null;
60
+ const e = entry;
61
+ if (typeof e.name !== "string" || !e.name)
62
+ return null;
63
+ if (typeof e.type !== "string" || !isAssetType(e.type))
64
+ return null;
65
+ const result = {
66
+ name: e.name,
67
+ type: e.type,
68
+ };
69
+ if (typeof e.description === "string" && e.description)
70
+ result.description = e.description;
71
+ if (Array.isArray(e.tags))
72
+ result.tags = e.tags.filter((t) => typeof t === "string");
73
+ if (Array.isArray(e.examples))
74
+ result.examples = e.examples.filter((x) => typeof x === "string");
75
+ if (Array.isArray(e.searchHints)) {
76
+ const filtered = e.searchHints.filter((s) => typeof s === "string" && s.trim().length > 0);
77
+ if (filtered.length > 0)
78
+ result.searchHints = filtered;
79
+ }
80
+ if (typeof e.intent === "object" && e.intent !== null) {
81
+ const intent = e.intent;
82
+ result.intent = {};
83
+ if (typeof intent.when === "string")
84
+ result.intent.when = intent.when;
85
+ if (typeof intent.input === "string")
86
+ result.intent.input = intent.input;
87
+ if (typeof intent.output === "string")
88
+ result.intent.output = intent.output;
89
+ }
90
+ if (typeof e.filename === "string" && e.filename)
91
+ result.filename = e.filename;
92
+ if (e.quality === "generated" || e.quality === "curated")
93
+ result.quality = e.quality;
94
+ if (typeof e.confidence === "number" && Number.isFinite(e.confidence))
95
+ result.confidence = Math.max(0, Math.min(1, e.confidence));
96
+ if (typeof e.source === "string" &&
97
+ ["package", "frontmatter", "comments", "filename", "manual", "llm"].includes(e.source)) {
98
+ result.source = e.source;
99
+ }
100
+ if (Array.isArray(e.aliases)) {
101
+ const filtered = e.aliases.filter((a) => typeof a === "string" && a.trim().length > 0);
102
+ if (filtered.length > 0)
103
+ result.aliases = normalizeTerms(filtered);
104
+ }
105
+ if (Array.isArray(e.toc)) {
106
+ const validated = e.toc.filter((h) => {
107
+ if (typeof h !== "object" || h === null)
108
+ return false;
109
+ const rec = h;
110
+ return typeof rec.level === "number" && typeof rec.text === "string" && typeof rec.line === "number";
111
+ });
112
+ if (validated.length > 0)
113
+ result.toc = validated;
114
+ }
115
+ const usage = normalizeNonEmptyStringList(e.usage);
116
+ if (usage)
117
+ result.usage = usage;
118
+ if (typeof e.run === "string" && e.run.trim())
119
+ result.run = e.run.trim();
120
+ if (typeof e.setup === "string" && e.setup.trim())
121
+ result.setup = e.setup.trim();
122
+ if (typeof e.cwd === "string" && e.cwd.trim())
123
+ result.cwd = e.cwd.trim();
124
+ if (typeof e.fileSize === "number" && Number.isFinite(e.fileSize) && e.fileSize >= 0)
125
+ result.fileSize = e.fileSize;
126
+ return result;
127
+ }
128
+ function normalizeNonEmptyStringList(value) {
129
+ if (typeof value === "string") {
130
+ const trimmed = value.trim();
131
+ return trimmed ? [trimmed] : undefined;
132
+ }
133
+ if (!Array.isArray(value))
134
+ return undefined;
135
+ const filtered = value
136
+ .filter((item) => typeof item === "string")
137
+ .map((item) => item.trim())
138
+ .filter((item) => item.length > 0);
139
+ return filtered.length > 0 ? filtered : undefined;
140
+ }
141
+ // ── Metadata Generation ─────────────────────────────────────────────────────
142
+ export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
143
+ const entries = [];
144
+ const pkgMeta = extractPackageMetadata(dirPath);
145
+ for (const file of files) {
146
+ const ext = path.extname(file).toLowerCase();
147
+ const baseName = path.basename(file, ext);
148
+ const fileName = path.basename(file);
149
+ // Skip non-relevant files
150
+ if (!isRelevantAssetFile(assetType, fileName))
151
+ continue;
152
+ const canonicalName = deriveCanonicalAssetName(assetType, typeRoot, file) ?? baseName;
153
+ const entry = {
154
+ name: canonicalName,
155
+ type: assetType,
156
+ quality: "generated",
157
+ confidence: 0.55,
158
+ source: "filename",
159
+ };
160
+ // Priority 1: Package.json metadata
161
+ if (pkgMeta) {
162
+ if (pkgMeta.description && !entry.description) {
163
+ entry.description = pkgMeta.description;
164
+ entry.source = "package";
165
+ entry.confidence = 0.8;
166
+ }
167
+ if (pkgMeta.keywords && pkgMeta.keywords.length > 0)
168
+ entry.tags = normalizeTerms(pkgMeta.keywords);
169
+ }
170
+ // Priority 2: Frontmatter (for .md files -- overrides package.json description)
171
+ if (ext === ".md") {
172
+ const fm = extractFrontmatterDescription(file);
173
+ if (fm) {
174
+ entry.description = fm;
175
+ entry.source = "frontmatter";
176
+ entry.confidence = 0.9;
177
+ }
178
+ }
179
+ // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
180
+ const fileCtx = buildFileContext(typeRoot, file);
181
+ const match = runMatchers(fileCtx);
182
+ if (match) {
183
+ const renderer = getRenderer(match.renderer);
184
+ if (renderer?.extractMetadata) {
185
+ const renderCtx = buildRenderContext(fileCtx, match, [typeRoot]);
186
+ renderer.extractMetadata(entry, renderCtx);
187
+ }
188
+ }
189
+ // Priority 4: Filename heuristics (fallback)
190
+ if (!entry.description) {
191
+ entry.description = fileNameToDescription(baseName);
192
+ entry.source = "filename";
193
+ entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
194
+ }
195
+ if (!entry.tags || entry.tags.length === 0) {
196
+ entry.tags = extractTagsFromPath(file, dirPath);
197
+ }
198
+ entry.tags = normalizeTerms(entry.tags ?? []);
199
+ entry.aliases = buildAliases(canonicalName, entry.tags);
200
+ // Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
201
+ // Heuristic search hints are too noisy to be useful for search quality
202
+ entry.filename = path.basename(file);
203
+ entries.push(entry);
204
+ }
205
+ return { entries };
206
+ }
207
+ /** Set of all known type directory names (e.g. "scripts", "skills", "agents") */
208
+ const TYPE_DIR_NAMES = new Set(Object.values(TYPE_DIRS));
209
+ /**
210
+ * Generate metadata for files using the matcher system instead of a fixed asset type.
211
+ *
212
+ * This is the flat-walk counterpart of `generateMetadata`. It classifies each
213
+ * file via `runMatchers()` and uses the matched type for canonical naming.
214
+ * Files that no matcher claims are silently skipped.
215
+ */
216
+ export function generateMetadataFlat(stashRoot, files) {
217
+ const entries = [];
218
+ const pkgMetaCache = new Map();
219
+ for (const file of files) {
220
+ const ctx = buildFileContext(stashRoot, file);
221
+ const match = runMatchers(ctx);
222
+ if (!match)
223
+ continue;
224
+ const assetType = match.type;
225
+ if (!isAssetType(assetType))
226
+ continue;
227
+ // If the file lives under a known type directory, use that as the root
228
+ // for canonical naming so names don't include the type prefix.
229
+ // e.g. scripts/deploy.sh → "deploy.sh" not "scripts/deploy.sh"
230
+ const firstAncestor = ctx.ancestorDirs[0];
231
+ const effectiveRoot = firstAncestor && TYPE_DIR_NAMES.has(firstAncestor) ? path.join(stashRoot, firstAncestor) : stashRoot;
232
+ const ext = path.extname(file).toLowerCase();
233
+ const baseName = path.basename(file, ext);
234
+ const canonicalName = deriveCanonicalAssetName(assetType, effectiveRoot, file) ?? baseName;
235
+ const entry = {
236
+ name: canonicalName,
237
+ type: assetType,
238
+ quality: "generated",
239
+ confidence: 0.55,
240
+ source: "filename",
241
+ };
242
+ // Package.json metadata
243
+ const dirPath = path.dirname(file);
244
+ if (!pkgMetaCache.has(dirPath)) {
245
+ pkgMetaCache.set(dirPath, extractPackageMetadata(dirPath));
246
+ }
247
+ const pkgMeta = pkgMetaCache.get(dirPath);
248
+ if (pkgMeta) {
249
+ if (pkgMeta.description && !entry.description) {
250
+ entry.description = pkgMeta.description;
251
+ entry.source = "package";
252
+ entry.confidence = 0.8;
253
+ }
254
+ if (pkgMeta.keywords?.length)
255
+ entry.tags = normalizeTerms(pkgMeta.keywords);
256
+ }
257
+ // Frontmatter
258
+ if (ext === ".md") {
259
+ const fm = extractFrontmatterDescription(file);
260
+ if (fm) {
261
+ entry.description = fm;
262
+ entry.source = "frontmatter";
263
+ entry.confidence = 0.9;
264
+ }
265
+ }
266
+ // Renderer metadata extraction
267
+ const renderer = getRenderer(match.renderer);
268
+ if (renderer?.extractMetadata) {
269
+ const renderCtx = buildRenderContext(ctx, match, [stashRoot]);
270
+ renderer.extractMetadata(entry, renderCtx);
271
+ }
272
+ // Filename heuristics fallback
273
+ if (!entry.description) {
274
+ entry.description = fileNameToDescription(baseName);
275
+ entry.source = "filename";
276
+ entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55);
277
+ }
278
+ if (!entry.tags || entry.tags.length === 0) {
279
+ entry.tags = extractTagsFromPath(file, dirPath);
280
+ }
281
+ entry.tags = normalizeTerms(entry.tags ?? []);
282
+ entry.aliases = buildAliases(canonicalName, entry.tags);
283
+ entry.filename = path.basename(file);
284
+ entries.push(entry);
285
+ }
286
+ return { entries };
287
+ }
288
+ function normalizeTerms(values) {
289
+ const normalized = new Set();
290
+ for (const value of values) {
291
+ const cleaned = value.toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
292
+ if (!cleaned)
293
+ continue;
294
+ normalized.add(cleaned);
295
+ if (cleaned.endsWith("s") && cleaned.length > 3) {
296
+ normalized.add(cleaned.slice(0, -1));
297
+ }
298
+ }
299
+ return Array.from(normalized);
300
+ }
301
+ function buildAliases(name, tags) {
302
+ const aliases = new Set();
303
+ const spaced = name.replace(/[-_]+/g, " ").trim().toLowerCase();
304
+ if (spaced && spaced !== name.toLowerCase())
305
+ aliases.add(spaced);
306
+ if (tags.length > 1)
307
+ aliases.add(tags.join(" "));
308
+ return Array.from(aliases);
309
+ }
310
+ export function extractDescriptionFromComments(filePath) {
311
+ let content;
312
+ try {
313
+ content = fs.readFileSync(filePath, "utf8");
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ const lines = content.split(/\r?\n/).slice(0, 50);
319
+ // Try JSDoc-style block comment: /** ... */
320
+ const blockStart = lines.findIndex((l) => /^\s*\/\*\*/.test(l));
321
+ if (blockStart >= 0) {
322
+ const desc = [];
323
+ for (let i = blockStart; i < lines.length; i++) {
324
+ const line = lines[i];
325
+ if (i > blockStart && /\*\//.test(line))
326
+ break;
327
+ const cleaned = line
328
+ .replace(/^\s*\/?\*\*?\s?/, "")
329
+ .replace(/\*\/\s*$/, "")
330
+ .trim();
331
+ if (cleaned)
332
+ desc.push(cleaned);
333
+ }
334
+ if (desc.length > 0)
335
+ return desc.join(" ");
336
+ }
337
+ // Try hash comments at start of file (skip shebang)
338
+ let start = 0;
339
+ if (lines[0]?.startsWith("#!"))
340
+ start = 1;
341
+ const hashLines = [];
342
+ for (let i = start; i < lines.length; i++) {
343
+ const line = lines[i].trim();
344
+ if (line.startsWith("#") && !line.startsWith("#!")) {
345
+ hashLines.push(line.replace(/^#+\s*/, "").trim());
346
+ }
347
+ else if (line === "") {
348
+ }
349
+ else {
350
+ break;
351
+ }
352
+ }
353
+ if (hashLines.length > 0)
354
+ return hashLines.join(" ");
355
+ return null;
356
+ }
357
+ export function extractFrontmatterDescription(filePath) {
358
+ let content;
359
+ try {
360
+ content = fs.readFileSync(filePath, "utf8");
361
+ }
362
+ catch {
363
+ return null;
364
+ }
365
+ const parsed = parseFrontmatter(content);
366
+ return toStringOrUndefined(parsed.data.description) ?? null;
367
+ }
368
+ export function extractPackageMetadata(dirPath) {
369
+ const pkgPath = path.join(dirPath, "package.json");
370
+ if (!fs.existsSync(pkgPath))
371
+ return null;
372
+ try {
373
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
374
+ const result = {};
375
+ if (typeof pkg.name === "string")
376
+ result.name = pkg.name;
377
+ if (typeof pkg.description === "string")
378
+ result.description = pkg.description;
379
+ if (Array.isArray(pkg.keywords)) {
380
+ result.keywords = pkg.keywords.filter((k) => typeof k === "string");
381
+ }
382
+ return Object.keys(result).length > 0 ? result : null;
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ }
388
+ export function fileNameToDescription(fileName) {
389
+ return fileName
390
+ .replace(/[-_]+/g, " ")
391
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
392
+ .toLowerCase()
393
+ .trim();
394
+ }
395
+ export function extractTagsFromPath(filePath, rootDir) {
396
+ const rel = path.relative(rootDir, filePath);
397
+ const parts = rel.split(path.sep);
398
+ const tags = new Set();
399
+ for (const part of parts) {
400
+ const name = part.replace(path.extname(part), "");
401
+ for (const token of name.split(/[-_./\\]+/)) {
402
+ const clean = token.toLowerCase().trim();
403
+ if (clean && clean.length > 1)
404
+ tags.add(clean);
405
+ }
406
+ }
407
+ return Array.from(tags);
408
+ }
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+ import { parseRegistryRef } from "./registry-resolve";
3
+ /**
4
+ * Given an origin string (from an AssetRef) and the full list of stash
5
+ * sources, return the subset of sources to search.
6
+ *
7
+ * Resolution order:
8
+ * 1. undefined → all sources
9
+ * 2. "local" → primary stash only (first entry)
10
+ * 3. exact match → source whose registryId matches verbatim
11
+ * 4. parsed match → parse origin as a registry ref, match by parsed ID
12
+ * 5. path match → source whose resolved path matches the origin
13
+ * 6. empty → indicates a remote/uninstalled origin (caller decides)
14
+ */
15
+ export function resolveSourcesForOrigin(origin, allSources) {
16
+ if (!origin)
17
+ return allSources;
18
+ // "local" means the primary stash (first entry)
19
+ if (origin === "local") {
20
+ return allSources.length > 0 ? [allSources[0]] : [];
21
+ }
22
+ // Exact registryId match (e.g. origin is "npm:@scope/pkg")
23
+ const byExactId = allSources.filter((s) => s.registryId !== undefined && s.registryId === origin);
24
+ if (byExactId.length > 0)
25
+ return byExactId;
26
+ // Parse origin as a registry ref and match by parsed ID.
27
+ // Allows shorthand: "owner/repo" matches "github:owner/repo",
28
+ // "@scope/pkg" matches "npm:@scope/pkg".
29
+ try {
30
+ const parsed = parseRegistryRef(origin);
31
+ const byParsedId = allSources.filter((s) => s.registryId !== undefined && s.registryId === parsed.id);
32
+ if (byParsedId.length > 0)
33
+ return byParsedId;
34
+ }
35
+ catch {
36
+ // Not a valid registry ref — continue to path matching
37
+ }
38
+ // Match by resolved path (any source, including installed)
39
+ const resolvedOrigin = path.resolve(origin);
40
+ const byPath = allSources.filter((s) => path.resolve(s.path) === resolvedOrigin);
41
+ if (byPath.length > 0)
42
+ return byPath;
43
+ // No match — origin may be remote/uninstalled
44
+ return [];
45
+ }
46
+ /**
47
+ * Check whether an origin refers to something that could be fetched remotely
48
+ * (i.e. it looks like a registry ref but isn't installed locally).
49
+ */
50
+ export function isRemoteOrigin(origin, allSources) {
51
+ if (origin === "local")
52
+ return false;
53
+ return resolveSourcesForOrigin(origin, allSources).length === 0;
54
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Centralized path resolution for all akm directories.
3
+ *
4
+ * Provides platform-aware paths for config, cache, and stash directories,
5
+ * following XDG Base Directory conventions on Unix and standard locations
6
+ * on Windows.
7
+ */
8
+ import path from "node:path";
9
+ import { ConfigError } from "./errors";
10
+ const IS_WINDOWS = process.platform === "win32";
11
+ // ── Config directory ─────────────────────────────────────────────────────────
12
+ export function getConfigDir(env = process.env, platform = process.platform) {
13
+ const override = env.AKM_CONFIG_DIR?.trim();
14
+ if (override)
15
+ return override;
16
+ if (platform === "win32") {
17
+ const appData = env.APPDATA?.trim();
18
+ if (appData)
19
+ return path.join(appData, "akm");
20
+ const userProfile = env.USERPROFILE?.trim();
21
+ if (!userProfile) {
22
+ throw new ConfigError("Unable to determine config directory. Set APPDATA or USERPROFILE.");
23
+ }
24
+ return path.join(userProfile, "AppData", "Roaming", "akm");
25
+ }
26
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
27
+ if (xdgConfigHome)
28
+ return path.join(xdgConfigHome, "akm");
29
+ const home = env.HOME?.trim();
30
+ if (!home) {
31
+ throw new ConfigError("Unable to determine config directory. Set XDG_CONFIG_HOME or HOME.");
32
+ }
33
+ return path.join(home, ".config", "akm");
34
+ }
35
+ export function getConfigPath() {
36
+ return path.join(getConfigDir(), "config.json");
37
+ }
38
+ // ── Cache directory ──────────────────────────────────────────────────────────
39
+ export function getCacheDir() {
40
+ const override = process.env.AKM_CACHE_DIR?.trim();
41
+ if (override)
42
+ return override;
43
+ if (IS_WINDOWS) {
44
+ const localAppData = process.env.LOCALAPPDATA?.trim();
45
+ if (localAppData)
46
+ return path.join(localAppData, "akm");
47
+ const userProfile = process.env.USERPROFILE?.trim();
48
+ if (userProfile)
49
+ return path.join(userProfile, "AppData", "Local", "akm");
50
+ const appData = process.env.APPDATA?.trim();
51
+ if (!appData) {
52
+ throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.");
53
+ }
54
+ return path.join(appData, "..", "Local", "akm");
55
+ }
56
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
57
+ if (xdgCacheHome)
58
+ return path.join(xdgCacheHome, "akm");
59
+ const home = process.env.HOME?.trim();
60
+ if (!home)
61
+ return path.join("/tmp", "akm-cache");
62
+ return path.join(home, ".cache", "akm");
63
+ }
64
+ export function getDbPath() {
65
+ return path.join(getCacheDir(), "index.db");
66
+ }
67
+ export function getRegistryCacheDir() {
68
+ return path.join(getCacheDir(), "registry");
69
+ }
70
+ export function getRegistryIndexCacheDir() {
71
+ return path.join(getCacheDir(), "registry-index");
72
+ }
73
+ export function getBinDir() {
74
+ return path.join(getCacheDir(), "bin");
75
+ }
76
+ // ── Default stash directory ──────────────────────────────────────────────────
77
+ export function getDefaultStashDir() {
78
+ const override = process.env.AKM_STASH_DIR?.trim();
79
+ if (override)
80
+ return override;
81
+ if (IS_WINDOWS) {
82
+ const userProfile = process.env.USERPROFILE?.trim();
83
+ if (userProfile)
84
+ return path.join(userProfile, "Documents", "akm");
85
+ return path.join("C:\\", "akm");
86
+ }
87
+ const home = process.env.HOME?.trim();
88
+ if (!home) {
89
+ throw new ConfigError("Unable to determine default stash directory. Set HOME.");
90
+ }
91
+ return path.join(home, "akm");
92
+ }