akm-cli 0.1.3 → 0.2.0
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 +48 -0
- package/dist/asset-spec.js +11 -32
- package/dist/cli.js +161 -57
- package/dist/completions.js +4 -2
- package/dist/config.js +34 -6
- package/dist/db.js +178 -22
- package/dist/embedder.js +94 -13
- package/dist/file-context.js +3 -0
- package/dist/indexer.js +88 -37
- package/dist/info.js +92 -0
- package/dist/local-search.js +190 -90
- package/dist/manifest.js +172 -0
- package/dist/metadata.js +165 -2
- package/dist/providers/skills-sh.js +21 -12
- package/dist/providers/static-index.js +3 -1
- package/dist/registry-build-index.js +12 -1
- package/dist/registry-resolve.js +10 -7
- package/dist/search-fields.js +69 -0
- package/dist/search-source.js +42 -0
- package/dist/stash-clone.js +3 -1
- package/dist/stash-provider-factory.js +0 -2
- package/dist/stash-providers/filesystem.js +4 -5
- package/dist/stash-providers/git.js +140 -0
- package/dist/stash-providers/index.js +1 -1
- package/dist/stash-providers/openviking.js +36 -25
- package/dist/stash-providers/provider-utils.js +11 -0
- package/dist/stash-search.js +106 -90
- package/dist/stash-show.js +125 -9
- package/dist/usage-events.js +73 -0
- package/dist/version.js +20 -0
- package/dist/walker.js +1 -2
- package/package.json +3 -2
- package/dist/stash-providers/context-hub.js +0 -390
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
|
|
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
|
|
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
|
|
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:
|
|
78
|
-
installRef: `github:${
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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)
|
package/dist/registry-resolve.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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") {
|
|
@@ -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
|
+
}
|
package/dist/search-source.js
CHANGED
|
@@ -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;
|
package/dist/stash-clone.js
CHANGED
|
@@ -98,7 +98,9 @@ export async function akmClone(options) {
|
|
|
98
98
|
}
|
|
99
99
|
else {
|
|
100
100
|
const resolvedSource = path.resolve(sourcePath);
|
|
101
|
-
const
|
|
101
|
+
const sourceExt = path.extname(sourcePath);
|
|
102
|
+
const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName;
|
|
103
|
+
const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
|
|
102
104
|
if (resolvedSource === resolvedDest) {
|
|
103
105
|
throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
|
|
104
106
|
}
|
|
@@ -26,8 +26,6 @@ export function resolveStashProviders(config) {
|
|
|
26
26
|
for (const entry of config.stashes ?? []) {
|
|
27
27
|
if (entry.enabled === false)
|
|
28
28
|
continue;
|
|
29
|
-
if (entry.type === "filesystem")
|
|
30
|
-
continue;
|
|
31
29
|
const factory = registry.resolve(entry.type);
|
|
32
30
|
if (factory) {
|
|
33
31
|
providers.push(factory(entry));
|
|
@@ -8,21 +8,20 @@ class FilesystemStashProvider {
|
|
|
8
8
|
type = "filesystem";
|
|
9
9
|
name;
|
|
10
10
|
stashDir;
|
|
11
|
-
config;
|
|
12
11
|
constructor(entry) {
|
|
13
|
-
this.config = loadConfig();
|
|
14
12
|
this.stashDir = entry.path ?? resolveStashDir();
|
|
15
13
|
this.name = entry.name ?? this.stashDir;
|
|
16
14
|
}
|
|
17
15
|
async search(options) {
|
|
18
|
-
const
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const sources = resolveStashSources(this.stashDir, config);
|
|
19
18
|
const result = await searchLocal({
|
|
20
19
|
query: options.query.toLowerCase(),
|
|
21
20
|
searchType: options.type ?? "any",
|
|
22
21
|
limit: options.limit,
|
|
23
22
|
stashDir: this.stashDir,
|
|
24
23
|
sources,
|
|
25
|
-
config
|
|
24
|
+
config,
|
|
26
25
|
});
|
|
27
26
|
return {
|
|
28
27
|
hits: result.hits,
|
|
@@ -35,7 +34,7 @@ class FilesystemStashProvider {
|
|
|
35
34
|
return showLocal({ ref, view });
|
|
36
35
|
}
|
|
37
36
|
canShow(ref) {
|
|
38
|
-
return !ref.
|
|
37
|
+
return !ref.includes("://");
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
40
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fetchWithRetry } from "../common";
|
|
5
|
+
import { ConfigError } from "../errors";
|
|
6
|
+
import { getRegistryIndexCacheDir } from "../paths";
|
|
7
|
+
import { extractTarGzSecure } from "../registry-install";
|
|
8
|
+
import { registerStashProvider } from "../stash-provider-factory";
|
|
9
|
+
import { isExpired, sanitizeString } from "./provider-utils";
|
|
10
|
+
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
11
|
+
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
12
|
+
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
13
|
+
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
class GitStashProvider {
|
|
15
|
+
type = "git";
|
|
16
|
+
name;
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.name = config.name ?? "git";
|
|
19
|
+
}
|
|
20
|
+
/** Content is indexed through the standard FTS5 pipeline. */
|
|
21
|
+
async search(_options) {
|
|
22
|
+
return { hits: [] };
|
|
23
|
+
}
|
|
24
|
+
/** Content is local files, shown via showLocal. */
|
|
25
|
+
async show(_ref, _view) {
|
|
26
|
+
throw new Error("Git provider content is shown via local index");
|
|
27
|
+
}
|
|
28
|
+
/** Content is local; no remote show needed. */
|
|
29
|
+
canShow(_ref) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ── Self-register ───────────────────────────────────────────────────────────
|
|
34
|
+
registerStashProvider("git", (config) => new GitStashProvider(config));
|
|
35
|
+
registerStashProvider("context-hub", (config) => new GitStashProvider(config));
|
|
36
|
+
registerStashProvider("github", (config) => new GitStashProvider(config));
|
|
37
|
+
// ── Cache management ────────────────────────────────────────────────────────
|
|
38
|
+
function getCachePaths(repoUrl) {
|
|
39
|
+
const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
|
|
40
|
+
const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
|
|
41
|
+
return {
|
|
42
|
+
rootDir,
|
|
43
|
+
archivePath: path.join(rootDir, "repo.tar.gz"),
|
|
44
|
+
repoDir: path.join(rootDir, "repo"),
|
|
45
|
+
indexPath: path.join(rootDir, "index.json"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function ensureGitMirror(repo, cachePaths, options) {
|
|
49
|
+
const requireRepoDir = options?.requireRepoDir === true;
|
|
50
|
+
// Check if cache is fresh
|
|
51
|
+
let mtime = 0;
|
|
52
|
+
try {
|
|
53
|
+
mtime = fs.statSync(cachePaths.indexPath).mtimeMs;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* no cached index */
|
|
57
|
+
}
|
|
58
|
+
if (mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(cachePaths.rootDir, { recursive: true });
|
|
63
|
+
await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
|
|
64
|
+
extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
|
|
65
|
+
// Touch index file to track freshness
|
|
66
|
+
fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (mtime && !isExpired(mtime, CACHE_STALE_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function hasExtractedRepo(repoDir) {
|
|
76
|
+
try {
|
|
77
|
+
return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function downloadArchive(url, destination) {
|
|
84
|
+
const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Failed to download archive (${response.status}) from ${url}`);
|
|
87
|
+
}
|
|
88
|
+
const BunRuntime = globalThis.Bun;
|
|
89
|
+
if (BunRuntime?.write) {
|
|
90
|
+
await BunRuntime.write(destination, response);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
+
fs.writeFileSync(destination, Buffer.from(arrayBuffer));
|
|
95
|
+
}
|
|
96
|
+
function buildTarballUrl(repo) {
|
|
97
|
+
return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
|
|
98
|
+
}
|
|
99
|
+
function parseGitRepoUrl(rawUrl) {
|
|
100
|
+
if (!rawUrl) {
|
|
101
|
+
throw new ConfigError("Git provider requires a GitHub repository URL");
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(rawUrl);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
|
|
109
|
+
}
|
|
110
|
+
if (parsed.protocol !== "https:") {
|
|
111
|
+
throw new ConfigError(`Git provider URL must use https://, got "${parsed.protocol}"`);
|
|
112
|
+
}
|
|
113
|
+
if (parsed.hostname !== "github.com") {
|
|
114
|
+
throw new ConfigError(`Git provider only supports github.com URLs, got "${parsed.hostname}"`);
|
|
115
|
+
}
|
|
116
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
117
|
+
if (segments.length < 2) {
|
|
118
|
+
throw new ConfigError(`Git provider URL must point to a GitHub repository, got "${rawUrl}"`);
|
|
119
|
+
}
|
|
120
|
+
const owner = sanitizeString(segments[0]);
|
|
121
|
+
const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
|
|
122
|
+
let ref = "main";
|
|
123
|
+
if (segments[2] === "tree" && segments.length >= 4) {
|
|
124
|
+
ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
|
|
125
|
+
}
|
|
126
|
+
if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
127
|
+
throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
|
|
128
|
+
}
|
|
129
|
+
if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
|
130
|
+
throw new ConfigError(`Unsupported branch/ref in URL: "${rawUrl}"`);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
owner,
|
|
134
|
+
repo,
|
|
135
|
+
ref,
|
|
136
|
+
canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
140
|
+
export { GitStashProvider, ensureGitMirror, getCachePaths, parseGitRepoUrl };
|