akm-cli 0.1.3 → 0.2.1

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/metadata.js CHANGED
@@ -133,6 +133,30 @@ export function validateStashEntry(entry) {
133
133
  result.cwd = e.cwd.trim();
134
134
  if (typeof e.fileSize === "number" && Number.isFinite(e.fileSize) && e.fileSize >= 0)
135
135
  result.fileSize = e.fileSize;
136
+ if (Array.isArray(e.parameters)) {
137
+ const validated = e.parameters
138
+ .filter((p) => {
139
+ if (typeof p !== "object" || p === null)
140
+ return false;
141
+ const rec = p;
142
+ return typeof rec.name === "string" && rec.name.trim().length > 0;
143
+ })
144
+ .map((p) => {
145
+ const rec = p;
146
+ const param = { name: rec.name.trim() };
147
+ if (typeof rec.type === "string" && rec.type.trim())
148
+ param.type = rec.type.trim();
149
+ if (typeof rec.description === "string" && rec.description.trim())
150
+ param.description = rec.description.trim();
151
+ if (typeof rec.required === "boolean")
152
+ param.required = rec.required;
153
+ if (typeof rec.default === "string" && rec.default.trim().length > 0)
154
+ param.default = rec.default;
155
+ return param;
156
+ });
157
+ if (validated.length > 0)
158
+ result.parameters = validated;
159
+ }
136
160
  return result;
137
161
  }
138
162
  function normalizeNonEmptyStringList(value) {
@@ -148,6 +172,107 @@ function normalizeNonEmptyStringList(value) {
148
172
  .filter((item) => item.length > 0);
149
173
  return filtered.length > 0 ? filtered : undefined;
150
174
  }
175
+ // ── Parameter Extraction ─────────────────────────────────────────────────────
176
+ /**
177
+ * Extract structured parameters from a command template containing
178
+ * `$ARGUMENTS`, `$1`-`$9`, or `{{named}}` placeholders.
179
+ */
180
+ export function extractCommandParameters(template) {
181
+ const params = [];
182
+ if (/\$ARGUMENTS\b/.test(template)) {
183
+ params.push({ name: "ARGUMENTS" });
184
+ }
185
+ for (const match of template.matchAll(/\$([1-9])(?!\d)/g)) {
186
+ const name = `$${match[1]}`;
187
+ if (!params.some((p) => p.name === name)) {
188
+ params.push({ name });
189
+ }
190
+ }
191
+ for (const match of template.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g)) {
192
+ const name = match[1];
193
+ if (!params.some((p) => p.name === name)) {
194
+ params.push({ name });
195
+ }
196
+ }
197
+ return params.length > 0 ? params : undefined;
198
+ }
199
+ /**
200
+ * Extract `@param` JSDoc tags from a script file's leading comment block.
201
+ *
202
+ * Supports both JSDoc-style (`/** ... * /`) and hash-style (`# @param ...`)
203
+ * comments. Optionally captures `{type}` annotations.
204
+ */
205
+ export function extractScriptParameters(filePath, content) {
206
+ if (content === undefined) {
207
+ try {
208
+ content = fs.readFileSync(filePath, "utf8");
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ const lines = content.split(/\r?\n/).slice(0, 50);
215
+ const params = [];
216
+ // Match @param lines in any comment style:
217
+ // JSDoc: * @param {string} name - description
218
+ // JSDoc: * @param name - description
219
+ // Hash: # @param name - description
220
+ const paramRegex = /^[\s/*#;-]*@param\s+(?:\{([^}]+)\}\s+)?(\w+)(?:\s+-\s+(.+))?/;
221
+ for (const line of lines) {
222
+ const match = line.match(paramRegex);
223
+ if (match) {
224
+ const param = { name: match[2] };
225
+ if (match[1])
226
+ param.type = match[1].trim();
227
+ if (match[3])
228
+ param.description = match[3].trim();
229
+ params.push(param);
230
+ }
231
+ }
232
+ return params.length > 0 ? params : undefined;
233
+ }
234
+ /**
235
+ * Extract parameters from frontmatter `params:` key.
236
+ *
237
+ * The frontmatter parser produces a nested object for `params:` like:
238
+ * ```
239
+ * { region: "AWS region to deploy to", instance_type: "EC2 instance type" }
240
+ * ```
241
+ */
242
+ export function extractFrontmatterParameters(fmData) {
243
+ const paramsRaw = fmData.params;
244
+ if (typeof paramsRaw !== "object" || paramsRaw === null || Array.isArray(paramsRaw))
245
+ return undefined;
246
+ const paramsObj = paramsRaw;
247
+ const params = [];
248
+ for (const [key, value] of Object.entries(paramsObj)) {
249
+ const param = { name: key };
250
+ if (typeof value === "string" && value.trim()) {
251
+ param.description = value.trim();
252
+ }
253
+ params.push(param);
254
+ }
255
+ return params.length > 0 ? params : undefined;
256
+ }
257
+ /**
258
+ * Merge two parameter lists, deduplicating by name.
259
+ * Parameters from `additional` are appended only if their name is not already present.
260
+ */
261
+ function mergeParameters(existing, additional) {
262
+ if (!additional || additional.length === 0)
263
+ return existing;
264
+ if (!existing || existing.length === 0)
265
+ return additional;
266
+ const names = new Set(existing.map((p) => p.name));
267
+ const merged = [...existing];
268
+ for (const param of additional) {
269
+ if (!names.has(param.name)) {
270
+ merged.push(param);
271
+ names.add(param.name);
272
+ }
273
+ }
274
+ return merged;
275
+ }
151
276
  // ── Metadata Generation ─────────────────────────────────────────────────────
152
277
  export async function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
153
278
  const entries = [];
@@ -179,12 +304,31 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
179
304
  }
180
305
  // Priority 2: Frontmatter (for .md files -- overrides package.json description)
181
306
  if (ext === ".md") {
182
- const fm = extractFrontmatterDescription(file);
307
+ const content = fs.readFileSync(file, "utf8");
308
+ const parsed = parseFrontmatter(content);
309
+ const fm = toStringOrUndefined(parsed.data.description);
183
310
  if (fm) {
184
311
  entry.description = fm;
185
312
  entry.source = "frontmatter";
186
313
  entry.confidence = 0.9;
187
314
  }
315
+ // Extract parameters from frontmatter params: key
316
+ const fmParams = extractFrontmatterParameters(parsed.data);
317
+ if (fmParams)
318
+ entry.parameters = fmParams;
319
+ // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
320
+ if (entry.type === "command") {
321
+ const cmdParams = extractCommandParameters(parsed.content);
322
+ if (cmdParams) {
323
+ entry.parameters = mergeParameters(entry.parameters, cmdParams);
324
+ }
325
+ }
326
+ }
327
+ // Extract @param from script files
328
+ if (ext !== ".md") {
329
+ const scriptParams = extractScriptParameters(file);
330
+ if (scriptParams)
331
+ entry.parameters = scriptParams;
188
332
  }
189
333
  // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
190
334
  const fileCtx = buildFileContext(typeRoot, file);
@@ -262,12 +406,31 @@ export async function generateMetadataFlat(stashRoot, files) {
262
406
  }
263
407
  // Frontmatter
264
408
  if (ext === ".md") {
265
- const fm = extractFrontmatterDescription(file);
409
+ const content = ctx.content();
410
+ const parsed = parseFrontmatter(content);
411
+ const fm = toStringOrUndefined(parsed.data.description);
266
412
  if (fm) {
267
413
  entry.description = fm;
268
414
  entry.source = "frontmatter";
269
415
  entry.confidence = 0.9;
270
416
  }
417
+ // Extract parameters from frontmatter params: key
418
+ const fmParams = extractFrontmatterParameters(parsed.data);
419
+ if (fmParams)
420
+ entry.parameters = fmParams;
421
+ // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
422
+ if (entry.type === "command") {
423
+ const cmdParams = extractCommandParameters(parsed.content);
424
+ if (cmdParams) {
425
+ entry.parameters = mergeParameters(entry.parameters, cmdParams);
426
+ }
427
+ }
428
+ }
429
+ // Extract @param from script files
430
+ if (ext !== ".md") {
431
+ const scriptParams = extractScriptParameters(file, ctx.content());
432
+ if (scriptParams)
433
+ entry.parameters = scriptParams;
271
434
  }
272
435
  // Renderer metadata extraction
273
436
  const renderer = await getRenderer(match.renderer);
@@ -68,14 +68,17 @@ class SkillsShProvider {
68
68
  const registryName = this.config.name ?? "skills.sh";
69
69
  const baseUrl = this.config.url.replace(/\/+$/, "");
70
70
  return entries.map((entry) => {
71
- const owner = entry.source.split("/")[0] ?? "";
71
+ const segments = entry.source.split("/");
72
+ const owner = segments[0] ?? "";
73
+ const repo = segments[1] ?? "";
74
+ const ownerRepo = owner && repo ? `${owner}/${repo}` : entry.source;
72
75
  const score = Math.round((entry.installs / maxInstalls) * 1000) / 1000;
73
76
  return {
74
77
  source: "github",
75
78
  id: `skills-sh:${entry.id}`,
76
79
  title: entry.name,
77
- ref: entry.source,
78
- installRef: `github:${entry.source}`,
80
+ ref: ownerRepo,
81
+ installRef: `github:${ownerRepo}`,
79
82
  homepage: `${baseUrl}/${entry.id}`,
80
83
  score,
81
84
  metadata: {
@@ -91,15 +94,21 @@ class SkillsShProvider {
91
94
  return undefined;
92
95
  const registryName = this.config.name ?? "skills.sh";
93
96
  const maxInstalls = Math.max(...entries.map((e) => e.installs), 1);
94
- const hits = entries.map((entry) => ({
95
- type: "registry-asset",
96
- assetType: "skill",
97
- assetName: entry.name,
98
- kit: { id: `skills-sh:${entry.id}`, name: entry.name },
99
- registryName,
100
- action: `akm add ${entry.source}`,
101
- score: Math.round((entry.installs / maxInstalls) * 1000) / 1000,
102
- }));
97
+ const hits = entries.map((entry) => {
98
+ const segments = entry.source.split("/");
99
+ const owner = segments[0] ?? "";
100
+ const repo = segments[1] ?? "";
101
+ const ownerRepo = owner && repo ? `${owner}/${repo}` : entry.source;
102
+ return {
103
+ type: "registry-asset",
104
+ assetType: "skill",
105
+ assetName: entry.name,
106
+ kit: { id: `skills-sh:${entry.id}`, name: entry.name },
107
+ registryName,
108
+ action: `akm add github:${ownerRepo}`,
109
+ score: Math.round((entry.installs / maxInstalls) * 1000) / 1000,
110
+ };
111
+ });
103
112
  return hits.length > 0 ? hits : undefined;
104
113
  }
105
114
  // ── Per-query cache ─────────────────────────────────────────────────────
@@ -251,6 +251,7 @@ function parseAssetEntry(raw) {
251
251
  name,
252
252
  description: asString(obj.description),
253
253
  tags: asStringArray(obj.tags),
254
+ estimatedTokens: typeof obj.estimatedTokens === "number" ? obj.estimatedTokens : undefined,
254
255
  };
255
256
  }
256
257
  // ── Asset-level scoring ─────────────────────────────────────────────────────
@@ -272,6 +273,7 @@ function scoreAssets(kits, query, limit) {
272
273
  assetType: asset.type,
273
274
  assetName: asset.name,
274
275
  description: asset.description,
276
+ estimatedTokens: asset.estimatedTokens,
275
277
  kit: { id: kit.id, name: kit.name },
276
278
  registryName,
277
279
  action: `akm add ${installRef}`,
@@ -335,7 +337,7 @@ function buildInstallRef(source, ref) {
335
337
  case "git":
336
338
  return `git+${ref}`;
337
339
  case "local":
338
- return ref;
340
+ return `file:${ref}`;
339
341
  default:
340
342
  return `github:${ref}`;
341
343
  }
@@ -184,6 +184,7 @@ async function inspectArchive(url, headers) {
184
184
  name: entry.name,
185
185
  ...(entry.description ? { description: entry.description } : {}),
186
186
  ...(entry.tags && entry.tags.length > 0 ? { tags: entry.tags } : {}),
187
+ ...(typeof entry.fileSize === "number" ? { estimatedTokens: Math.round(entry.fileSize / 4) } : {}),
187
188
  }));
188
189
  return {
189
190
  description: asString(packageMetadata.description),
@@ -241,10 +242,20 @@ async function enumerateAssets(stashRoot) {
241
242
  continue;
242
243
  stash = generated;
243
244
  }
244
- entries.push(...stash.entries);
245
+ entries.push(...stash.entries.map((entry) => attachFileSize(dirPath, entry)));
245
246
  }
246
247
  return entries.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));
247
248
  }
249
+ function attachFileSize(dirPath, entry) {
250
+ if (typeof entry.fileSize === "number" || !entry.filename)
251
+ return entry;
252
+ try {
253
+ return { ...entry, fileSize: fs.statSync(path.join(dirPath, entry.filename)).size };
254
+ }
255
+ catch {
256
+ return entry;
257
+ }
258
+ }
248
259
  function applyIncludeConfigForInspection(stashRoot, tempDir, searchRoot) {
249
260
  const includeConfig = findNearestIncludeConfig(stashRoot, searchRoot);
250
261
  if (!includeConfig)
@@ -87,16 +87,19 @@ function detectRegistrySearchId(ref) {
87
87
  if (!/^[a-z][a-z0-9-]*$/.test(prefix))
88
88
  return undefined;
89
89
  const rest = ref.slice(colonIdx + 1);
90
- return [
90
+ // Try to extract a plausible owner/repo from the rest (e.g. "org/repo/skill" → "org/repo")
91
+ const segments = rest.split("/").filter(Boolean);
92
+ const suggestedRef = segments.length >= 2 ? `github:${segments[0]}/${segments[1]}` : undefined;
93
+ const lines = [
91
94
  `"${ref}" looks like a registry search result ID, not an installable ref.`,
92
95
  `The "${prefix}:" prefix is a registry identifier and cannot be passed to \`akm add\`.`,
93
96
  "",
94
- "Use the installRef or ref field from the search result instead. For example:",
95
- ` akm registry search "${rest}" --format json`,
96
- "Then install using the installRef value from the result:",
97
- " akm add github:owner/repo",
98
- " akm add npm:package-name",
99
- ].join("\n");
97
+ ];
98
+ if (suggestedRef) {
99
+ lines.push(`Try installing the source repository directly:`, ` akm add ${suggestedRef}`, "");
100
+ }
101
+ lines.push("Or search for the installable ref:", ` akm search "${segments.length > 2 ? segments[segments.length - 1] : rest}" --source registry`, "Then install using the installRef value from the result:", " akm add github:owner/repo", " akm add npm:package-name");
102
+ return lines.join("\n");
100
103
  }
101
104
  export async function resolveRegistryArtifact(parsed) {
102
105
  if (parsed.source === "npm") {
package/dist/renderers.js CHANGED
@@ -399,4 +399,4 @@ export function registerBuiltinRenderers() {
399
399
  }
400
400
  }
401
401
  // ── Named exports for testing ────────────────────────────────────────────────
402
- export { skillMdRenderer, commandMdRenderer, agentMdRenderer, knowledgeMdRenderer, memoryMdRenderer, scriptSourceRenderer, INTERPRETER_MAP, SETUP_SIGNALS, };
402
+ export { agentMdRenderer, commandMdRenderer, INTERPRETER_MAP, knowledgeMdRenderer, memoryMdRenderer, SETUP_SIGNALS, scriptSourceRenderer, skillMdRenderer, };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Per-field search text extraction for FTS5 indexing.
3
+ *
4
+ * Extracted from indexer.ts to break the circular dependency:
5
+ * db.ts -> indexer.ts -> db.ts
6
+ *
7
+ * This module imports only from metadata.ts (for the StashEntry type),
8
+ * so it can be safely imported by both db.ts and indexer.ts.
9
+ */
10
+ /**
11
+ * Return per-field search text for multi-column FTS5 indexing.
12
+ *
13
+ * Fields:
14
+ * - name: entry name with hyphens/underscores replaced by spaces
15
+ * - description: entry description
16
+ * - tags: tags + aliases joined
17
+ * - hints: searchHints + examples + usage + intent fields
18
+ * - content: TOC headings (lowest-weight catch-all)
19
+ */
20
+ export function buildSearchFields(entry) {
21
+ const name = entry.name.replace(/[-_]/g, " ").toLowerCase();
22
+ const description = (entry.description ?? "").toLowerCase();
23
+ const tagParts = [];
24
+ if (entry.tags)
25
+ tagParts.push(entry.tags.join(" "));
26
+ if (entry.aliases)
27
+ tagParts.push(entry.aliases.join(" "));
28
+ const tags = tagParts.join(" ").toLowerCase();
29
+ const hintParts = [];
30
+ if (entry.searchHints)
31
+ hintParts.push(entry.searchHints.join(" "));
32
+ if (entry.examples)
33
+ hintParts.push(entry.examples.join(" "));
34
+ if (entry.usage)
35
+ hintParts.push(entry.usage.join(" "));
36
+ if (entry.intent) {
37
+ if (entry.intent.when)
38
+ hintParts.push(entry.intent.when);
39
+ if (entry.intent.input)
40
+ hintParts.push(entry.intent.input);
41
+ if (entry.intent.output)
42
+ hintParts.push(entry.intent.output);
43
+ }
44
+ const hints = hintParts.join(" ").toLowerCase();
45
+ const contentParts = [];
46
+ if (entry.toc) {
47
+ contentParts.push(entry.toc.map((h) => h.text).join(" "));
48
+ }
49
+ if (entry.parameters) {
50
+ for (const param of entry.parameters) {
51
+ contentParts.push(param.name);
52
+ if (param.description)
53
+ contentParts.push(param.description);
54
+ }
55
+ }
56
+ const content = contentParts.join(" ").toLowerCase();
57
+ return { name, description, tags, hints, content };
58
+ }
59
+ /**
60
+ * Build a single concatenated search text string for an entry.
61
+ * Used for the `search_text` column in the entries table (backward compat)
62
+ * and for generating embedding text.
63
+ */
64
+ export function buildSearchText(entry) {
65
+ const fields = buildSearchFields(entry);
66
+ return [fields.name, fields.description, fields.tags, fields.hints, fields.content]
67
+ .filter((s) => s.length > 0)
68
+ .join(" ");
69
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { resolveStashDir } from "./common";
4
4
  import { loadConfig } from "./config";
5
+ import { ensureGitMirror, getCachePaths, parseGitRepoUrl } from "./stash-providers/git";
5
6
  import { warn } from "./warn";
6
7
  // ── Resolution ──────────────────────────────────────────────────────────────
7
8
  /**
@@ -36,6 +37,23 @@ export function resolveStashSources(overrideStashDir, existingConfig) {
36
37
  addSource(entry.path, entry.name);
37
38
  }
38
39
  }
40
+ // Git stash entries: resolve cache directory so the indexer can walk it.
41
+ // "context-hub", "github", and "git" provider types are handled.
42
+ for (const entry of config.stashes ?? []) {
43
+ if (GIT_STASH_TYPES.has(entry.type) && entry.url && entry.enabled !== false) {
44
+ try {
45
+ const repo = parseGitRepoUrl(entry.url);
46
+ const cachePaths = getCachePaths(repo.canonicalUrl);
47
+ // The content/ subdirectory inside the extracted repo is the actual
48
+ // stash root containing DOC.md / SKILL.md files that the walker indexes.
49
+ const contentDir = path.join(cachePaths.repoDir, "content");
50
+ addSource(contentDir, entry.name);
51
+ }
52
+ catch (err) {
53
+ warn(`Warning: failed to resolve git stash cache for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
54
+ }
55
+ }
56
+ }
39
57
  // Installed kits (registry and local)
40
58
  for (const entry of config.installed ?? []) {
41
59
  addSource(entry.stashRoot, entry.id);
@@ -132,3 +150,27 @@ function isValidDirectory(dir) {
132
150
  return false;
133
151
  }
134
152
  }
153
+ // ── Git stash cache integration ──────────────────────────────────────────────
154
+ const GIT_STASH_TYPES = new Set(["context-hub", "github", "git"]);
155
+ /**
156
+ * Ensure all git stash mirrors are refreshed so their cache directories
157
+ * exist on disk. Must be called (async) before `resolveStashSources()` so
158
+ * the content directories pass the `isValidDirectory()` check.
159
+ */
160
+ export async function ensureGitCaches(config) {
161
+ const cfg = config ?? loadConfig();
162
+ for (const entry of cfg.stashes ?? []) {
163
+ if (!GIT_STASH_TYPES.has(entry.type) || !entry.url || entry.enabled === false)
164
+ continue;
165
+ try {
166
+ const repo = parseGitRepoUrl(entry.url);
167
+ const cachePaths = getCachePaths(repo.canonicalUrl);
168
+ await ensureGitMirror(repo, cachePaths, { requireRepoDir: true });
169
+ }
170
+ catch (err) {
171
+ warn(`Warning: failed to refresh git mirror for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
172
+ }
173
+ }
174
+ }
175
+ /** @deprecated Use ensureGitCaches instead. */
176
+ export const ensureContextHubCaches = ensureGitCaches;