akm-cli 0.1.2 → 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/README.md +4 -1
- package/dist/asset-registry.js +48 -0
- package/dist/asset-spec.js +11 -32
- package/dist/cli.js +174 -57
- package/dist/completions.js +4 -2
- package/dist/config.js +34 -6
- package/dist/db.js +178 -22
- package/dist/detect.js +120 -0
- 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/setup.js +506 -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 +4 -2
- package/dist/stash-providers/context-hub.js +0 -389
package/dist/stash-show.js
CHANGED
|
@@ -1,27 +1,86 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { loadConfig } from "./config";
|
|
3
|
+
import { closeDatabase, openDatabase } from "./db";
|
|
2
4
|
import { NotFoundError, UsageError } from "./errors";
|
|
3
5
|
import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "./file-context";
|
|
6
|
+
import { parseFrontmatter, toStringOrUndefined } from "./frontmatter";
|
|
7
|
+
import { loadStashFile } from "./metadata";
|
|
4
8
|
import { resolveSourcesForOrigin } from "./origin-resolve";
|
|
5
9
|
import { buildEditHint, findSourceForPath, isEditable, resolveStashSources } from "./search-source";
|
|
6
10
|
import { resolveStashProviders } from "./stash-provider-factory";
|
|
7
11
|
import { parseAssetRef } from "./stash-ref";
|
|
8
12
|
import { resolveAssetPath } from "./stash-resolve";
|
|
13
|
+
import { insertUsageEvent } from "./usage-events";
|
|
9
14
|
// Eagerly import stash providers to trigger self-registration
|
|
10
15
|
import "./stash-providers/index";
|
|
11
16
|
/**
|
|
12
|
-
* Unified show:
|
|
13
|
-
*
|
|
17
|
+
* Unified show: tries local FTS5 index first, then remote providers.
|
|
18
|
+
*
|
|
19
|
+
* When `detail` is `"summary"`, the response omits content/template/prompt and
|
|
20
|
+
* returns only compact metadata (name, type, description, tags, parameters).
|
|
14
21
|
*/
|
|
15
22
|
export async function akmShowUnified(input) {
|
|
16
23
|
const ref = input.ref.trim();
|
|
17
|
-
// Try
|
|
24
|
+
// 1. Try local filesystem first (FTS5 index lookup)
|
|
25
|
+
let localError;
|
|
26
|
+
try {
|
|
27
|
+
const result = await showLocal(input);
|
|
28
|
+
logShowEvent(ref);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
// Only fall through to remote providers on NotFoundError
|
|
33
|
+
if (!(err instanceof NotFoundError))
|
|
34
|
+
throw err;
|
|
35
|
+
localError = err;
|
|
36
|
+
}
|
|
37
|
+
// 2. Try remote providers (e.g. OpenViking)
|
|
18
38
|
const config = loadConfig();
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
const providers = resolveStashProviders(config).filter((p) => p.type !== "filesystem" && p.canShow(ref));
|
|
40
|
+
for (const provider of providers) {
|
|
41
|
+
try {
|
|
42
|
+
const response = await provider.show(ref, input.view);
|
|
43
|
+
logShowEvent(ref);
|
|
44
|
+
if (input.detail === "summary") {
|
|
45
|
+
return buildSummaryResponse(response);
|
|
46
|
+
}
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (!(err instanceof NotFoundError))
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Nothing found anywhere — rethrow the original local error with its specific message
|
|
55
|
+
throw localError;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fire-and-forget: log a show event to the usage_events table.
|
|
59
|
+
* Never blocks the caller; errors are silently ignored.
|
|
60
|
+
*/
|
|
61
|
+
function logShowEvent(ref, existingDb) {
|
|
62
|
+
try {
|
|
63
|
+
const db = existingDb ?? openDatabase();
|
|
64
|
+
try {
|
|
65
|
+
const parsed = parseAssetRef(ref);
|
|
66
|
+
const safeName = parsed.name.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
67
|
+
const row = db
|
|
68
|
+
.prepare("SELECT id FROM entries WHERE entry_key LIKE ? ESCAPE '\\' AND entry_type = ? LIMIT 1")
|
|
69
|
+
.get(`%:${parsed.type}:${safeName}`, parsed.type);
|
|
70
|
+
insertUsageEvent(db, {
|
|
71
|
+
event_type: "show",
|
|
72
|
+
entry_ref: ref,
|
|
73
|
+
entry_id: row?.id,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (!existingDb)
|
|
78
|
+
closeDatabase(db);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* fire-and-forget */
|
|
22
83
|
}
|
|
23
|
-
// Default: local filesystem show
|
|
24
|
-
return showLocal(input);
|
|
25
84
|
}
|
|
26
85
|
/** @internal Use akmShowUnified() for all external callers. */
|
|
27
86
|
export async function showLocal(input) {
|
|
@@ -71,10 +130,67 @@ export async function showLocal(input) {
|
|
|
71
130
|
const renderCtx = buildRenderContext(fileCtx, match, allStashDirs);
|
|
72
131
|
const response = renderer.buildShowResponse(renderCtx);
|
|
73
132
|
const editable = isEditable(assetPath, config);
|
|
74
|
-
|
|
133
|
+
const fullResponse = {
|
|
75
134
|
...response,
|
|
76
135
|
origin: source?.registryId ?? null,
|
|
77
136
|
editable,
|
|
78
137
|
...(!editable ? { editHint: buildEditHint(assetPath, parsed.type, parsed.name, source?.registryId) } : {}),
|
|
79
138
|
};
|
|
139
|
+
if (input.detail === "summary") {
|
|
140
|
+
return buildSummaryResponse(fullResponse, assetPath);
|
|
141
|
+
}
|
|
142
|
+
return fullResponse;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Build a compact summary response from a full ShowResponse.
|
|
146
|
+
*
|
|
147
|
+
* Strips content/template/prompt and returns only metadata fields:
|
|
148
|
+
* type, name, path, description, tags, parameters, action.
|
|
149
|
+
* Enriches description and tags from frontmatter or .stash.json when available.
|
|
150
|
+
*
|
|
151
|
+
* Enrichment via frontmatter and .stash.json is only performed when `assetPath`
|
|
152
|
+
* is supplied (local assets). Remote provider responses (e.g. OpenViking) rely
|
|
153
|
+
* on the provider having already populated description and tags.
|
|
154
|
+
*
|
|
155
|
+
* The resulting JSON should be under 200 tokens.
|
|
156
|
+
*/
|
|
157
|
+
function buildSummaryResponse(full, assetPath) {
|
|
158
|
+
// Try to enrich metadata from .stash.json if description or tags are missing
|
|
159
|
+
let description = full.description;
|
|
160
|
+
let tags = full.tags;
|
|
161
|
+
if (assetPath) {
|
|
162
|
+
// Try frontmatter extraction from content fields
|
|
163
|
+
const textContent = full.content ?? full.template ?? full.prompt;
|
|
164
|
+
if (textContent && !description) {
|
|
165
|
+
const parsed = parseFrontmatter(textContent);
|
|
166
|
+
description = toStringOrUndefined(parsed.data.description);
|
|
167
|
+
}
|
|
168
|
+
// Try .stash.json for richer metadata (tags especially)
|
|
169
|
+
const dir = path.dirname(assetPath);
|
|
170
|
+
const stashFile = loadStashFile(dir);
|
|
171
|
+
if (stashFile) {
|
|
172
|
+
const fileName = path.basename(assetPath);
|
|
173
|
+
const entry = stashFile.entries.find((e) => e.filename === fileName);
|
|
174
|
+
if (entry) {
|
|
175
|
+
if (!description && entry.description) {
|
|
176
|
+
description = entry.description;
|
|
177
|
+
}
|
|
178
|
+
if (!tags && entry.tags) {
|
|
179
|
+
tags = entry.tags;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const summary = {
|
|
185
|
+
type: full.type,
|
|
186
|
+
name: full.name,
|
|
187
|
+
path: full.path,
|
|
188
|
+
...(description ? { description } : {}),
|
|
189
|
+
...(tags && tags.length > 0 ? { tags } : {}),
|
|
190
|
+
...(full.parameters ? { parameters: full.parameters } : {}),
|
|
191
|
+
...(full.action ? { action: full.action } : {}),
|
|
192
|
+
...(full.run ? { run: full.run } : {}),
|
|
193
|
+
...(full.origin !== undefined ? { origin: full.origin } : {}),
|
|
194
|
+
};
|
|
195
|
+
return summary;
|
|
80
196
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage event helpers for telemetry and utility-based re-ranking.
|
|
3
|
+
*
|
|
4
|
+
* Schema (created by ensureUsageEventsSchema):
|
|
5
|
+
* id, event_type, query, entry_id (nullable), entry_ref, signal, metadata, created_at
|
|
6
|
+
*/
|
|
7
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
8
|
+
export function ensureUsageEventsSchema(db) {
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
event_type TEXT NOT NULL,
|
|
13
|
+
query TEXT,
|
|
14
|
+
entry_id INTEGER,
|
|
15
|
+
entry_ref TEXT,
|
|
16
|
+
signal TEXT,
|
|
17
|
+
metadata TEXT,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_entry ON usage_events(entry_id);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_type ON usage_events(event_type);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_ref ON usage_events(entry_ref);
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
// ── Insert ───────────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Insert a usage event into the database. Fire-and-forget: errors are
|
|
28
|
+
* silently caught so callers are never blocked or disrupted.
|
|
29
|
+
*/
|
|
30
|
+
export function insertUsageEvent(db, event) {
|
|
31
|
+
try {
|
|
32
|
+
db.prepare(`INSERT INTO usage_events (event_type, query, entry_id, entry_ref, signal, metadata)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(event.event_type, event.query ?? null, event.entry_id ?? null, event.entry_ref ?? null, event.signal ?? null, event.metadata ?? null);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* fire-and-forget: silently ignore errors */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ── Query ────────────────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Retrieve usage events, optionally filtered by event_type and/or entry_ref.
|
|
42
|
+
*/
|
|
43
|
+
export function getUsageEvents(db, filters) {
|
|
44
|
+
const conditions = [];
|
|
45
|
+
const params = [];
|
|
46
|
+
if (filters?.event_type) {
|
|
47
|
+
conditions.push("event_type = ?");
|
|
48
|
+
params.push(filters.event_type);
|
|
49
|
+
}
|
|
50
|
+
if (filters?.entry_ref) {
|
|
51
|
+
conditions.push("entry_ref = ?");
|
|
52
|
+
params.push(filters.entry_ref);
|
|
53
|
+
}
|
|
54
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
55
|
+
const sql = `SELECT id, event_type, query, entry_id, entry_ref, signal, metadata, created_at
|
|
56
|
+
FROM usage_events ${where}
|
|
57
|
+
ORDER BY id ASC`;
|
|
58
|
+
return db.prepare(sql).all(...params);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Delete usage events older than the given number of days.
|
|
62
|
+
*/
|
|
63
|
+
export function purgeOldUsageEvents(db, retentionDays) {
|
|
64
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
68
|
+
db.prepare("DELETE FROM usage_events WHERE created_at < ?").run(cutoff);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* Table may not exist yet */
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Version: prefer compile-time define, then package.json, then fallback
|
|
4
|
+
export const pkgVersion = (() => {
|
|
5
|
+
// Injected at compile time via `bun build --define`
|
|
6
|
+
if (typeof AKM_VERSION !== "undefined")
|
|
7
|
+
return AKM_VERSION;
|
|
8
|
+
try {
|
|
9
|
+
const pkgPath = path.resolve(import.meta.dir ?? __dirname, "../package.json");
|
|
10
|
+
if (fs.existsSync(pkgPath)) {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
12
|
+
if (typeof pkg.version === "string")
|
|
13
|
+
return pkg.version;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// swallow — running as compiled binary without package.json
|
|
18
|
+
}
|
|
19
|
+
return "0.0.0-dev";
|
|
20
|
+
})();
|
package/dist/walker.js
CHANGED
|
@@ -9,6 +9,7 @@ import fs from "node:fs";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { isRelevantAssetFile } from "./asset-spec";
|
|
11
11
|
import { buildFileContext } from "./file-context";
|
|
12
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "bin", ".cache"]);
|
|
12
13
|
/**
|
|
13
14
|
* Walk a type root directory and return files grouped by their parent directory.
|
|
14
15
|
*
|
|
@@ -82,7 +83,6 @@ function walkStashGit(stashRoot) {
|
|
|
82
83
|
// result.success is false if the process exited non-zero OR git was not found
|
|
83
84
|
if (!result.success)
|
|
84
85
|
return null;
|
|
85
|
-
const SKIP_DIRS = new Set([".git", "node_modules", "bin", ".cache"]);
|
|
86
86
|
const SKIP_FILES = new Set([".stash.json", ".gitignore", ".gitattributes"]);
|
|
87
87
|
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? "");
|
|
88
88
|
const files = stdout
|
|
@@ -139,7 +139,6 @@ function isInsideGitRepo(dir) {
|
|
|
139
139
|
/** Manual walk for non-git directories. */
|
|
140
140
|
function walkStashManual(stashRoot) {
|
|
141
141
|
const results = [];
|
|
142
|
-
const SKIP_DIRS = new Set([".git", "node_modules", "bin", ".cache"]);
|
|
143
142
|
const stack = [stashRoot];
|
|
144
143
|
while (stack.length > 0) {
|
|
145
144
|
const current = stack.pop();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool to search, open, and run extension assets from an akm stash directory.",
|
|
6
6
|
"keywords": [
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
"bun": ">=1.0.0"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"
|
|
61
|
+
"@clack/prompts": "^1.1.0",
|
|
62
|
+
"citty": "^0.2.1",
|
|
63
|
+
"yaml": "^2.8.2"
|
|
62
64
|
}
|
|
63
65
|
}
|
|
@@ -1,389 +0,0 @@
|
|
|
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, NotFoundError, UsageError } from "../errors";
|
|
6
|
-
import { parseFrontmatter, toStringOrUndefined } from "../frontmatter";
|
|
7
|
-
import { extractFrontmatterOnly, extractLineRange, extractSection, formatToc, parseMarkdownToc } from "../markdown";
|
|
8
|
-
import { getRegistryIndexCacheDir } from "../paths";
|
|
9
|
-
import { extractTarGzSecure } from "../registry-install";
|
|
10
|
-
import { registerStashProvider } from "../stash-provider-factory";
|
|
11
|
-
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
12
|
-
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
13
|
-
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
14
|
-
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
|
-
const CONTEXT_HUB_REF_PREFIX = "context-hub://";
|
|
16
|
-
class ContextHubStashProvider {
|
|
17
|
-
type = "context-hub";
|
|
18
|
-
name;
|
|
19
|
-
repo;
|
|
20
|
-
constructor(config) {
|
|
21
|
-
this.repo = parseContextHubRepoUrl(config.url ?? "");
|
|
22
|
-
this.name = config.name ?? `${this.repo.owner}/${this.repo.repo}`;
|
|
23
|
-
}
|
|
24
|
-
async search(options) {
|
|
25
|
-
try {
|
|
26
|
-
const entries = await this.loadEntries();
|
|
27
|
-
const filtered = entries
|
|
28
|
-
.filter((entry) => matchesType(entry, options.type))
|
|
29
|
-
.map((entry) => ({ entry, score: scoreEntry(entry, options.query) }))
|
|
30
|
-
.filter(({ score }) => options.query.trim() === "" || score > 0)
|
|
31
|
-
.sort((a, b) => b.score - a.score || a.entry.sortName.localeCompare(b.entry.sortName))
|
|
32
|
-
.slice(0, options.limit);
|
|
33
|
-
return {
|
|
34
|
-
hits: filtered.map(({ entry, score }) => entryToHit(entry, score)),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
-
return { hits: [], warnings: [`Stash ${this.name}: ${message}`] };
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
async show(ref, view) {
|
|
43
|
-
const filePath = parseContextHubRef(ref);
|
|
44
|
-
const repoDir = await this.loadRepoDir();
|
|
45
|
-
const resolved = resolveCachedFilePath(repoDir, filePath);
|
|
46
|
-
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
47
|
-
throw new NotFoundError(`Context Hub asset not found: ${filePath}`);
|
|
48
|
-
}
|
|
49
|
-
const raw = fs.readFileSync(resolved, "utf8");
|
|
50
|
-
const parsed = parseFrontmatter(raw);
|
|
51
|
-
const relFromContent = path.posix.normalize(path.relative(path.join(repoDir, "content"), resolved).replace(/\\/g, "/"));
|
|
52
|
-
const author = sanitizeString(relFromContent.split("/")[0] ?? "") || "unknown";
|
|
53
|
-
const name = sanitizeString(toStringOrUndefined(parsed.data.name) ?? path.basename(path.dirname(resolved)));
|
|
54
|
-
const description = sanitizeString(toStringOrUndefined(parsed.data.description), 1000);
|
|
55
|
-
const assetType = path.basename(resolved) === "SKILL.md" ? "skill" : "knowledge";
|
|
56
|
-
const content = renderContentForView(raw, view);
|
|
57
|
-
return {
|
|
58
|
-
type: assetType,
|
|
59
|
-
name: `${author}/${name}`,
|
|
60
|
-
path: ref,
|
|
61
|
-
content,
|
|
62
|
-
description,
|
|
63
|
-
editable: false,
|
|
64
|
-
origin: this.type,
|
|
65
|
-
action: `Context Hub content from ${this.repo.canonicalUrl}`,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
canShow(ref) {
|
|
69
|
-
return ref.trim().startsWith(CONTEXT_HUB_REF_PREFIX);
|
|
70
|
-
}
|
|
71
|
-
async loadEntries() {
|
|
72
|
-
const cachePaths = getCachePaths(this.repo.canonicalUrl);
|
|
73
|
-
const index = await ensureContextHubMirror(this.repo, cachePaths);
|
|
74
|
-
return index.entries;
|
|
75
|
-
}
|
|
76
|
-
async loadRepoDir() {
|
|
77
|
-
const cachePaths = getCachePaths(this.repo.canonicalUrl);
|
|
78
|
-
await ensureContextHubMirror(this.repo, cachePaths, { requireRepoDir: true });
|
|
79
|
-
return cachePaths.repoDir;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
registerStashProvider("context-hub", (config) => new ContextHubStashProvider(config));
|
|
83
|
-
function getCachePaths(repoUrl) {
|
|
84
|
-
const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
|
|
85
|
-
const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
|
|
86
|
-
return {
|
|
87
|
-
rootDir,
|
|
88
|
-
archivePath: path.join(rootDir, "repo.tar.gz"),
|
|
89
|
-
repoDir: path.join(rootDir, "repo"),
|
|
90
|
-
indexPath: path.join(rootDir, "index.json"),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
async function ensureContextHubMirror(repo, cachePaths, options) {
|
|
94
|
-
const requireRepoDir = options?.requireRepoDir === true;
|
|
95
|
-
const cached = readCachedIndex(cachePaths.indexPath);
|
|
96
|
-
if (cached && !isExpired(cached.mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
97
|
-
return { entries: cached.entries };
|
|
98
|
-
}
|
|
99
|
-
try {
|
|
100
|
-
fs.mkdirSync(cachePaths.rootDir, { recursive: true });
|
|
101
|
-
await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
|
|
102
|
-
extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
|
|
103
|
-
const entries = buildContextHubIndex(cachePaths.repoDir);
|
|
104
|
-
writeCachedIndex(cachePaths.indexPath, entries);
|
|
105
|
-
return { entries };
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
if (cached &&
|
|
109
|
-
!isExpired(cached.mtime, CACHE_STALE_MS) &&
|
|
110
|
-
(!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
111
|
-
return { entries: cached.entries };
|
|
112
|
-
}
|
|
113
|
-
throw err;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
function hasExtractedRepo(repoDir) {
|
|
117
|
-
try {
|
|
118
|
-
return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function readCachedIndex(indexPath) {
|
|
125
|
-
try {
|
|
126
|
-
const stat = fs.statSync(indexPath);
|
|
127
|
-
const raw = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
128
|
-
if (!Array.isArray(raw))
|
|
129
|
-
return null;
|
|
130
|
-
const entries = raw.filter(isContextHubEntry);
|
|
131
|
-
return { entries, mtime: stat.mtimeMs };
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
function writeCachedIndex(indexPath, entries) {
|
|
138
|
-
const dir = path.dirname(indexPath);
|
|
139
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
-
const tmpPath = `${indexPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
141
|
-
fs.writeFileSync(tmpPath, JSON.stringify(entries), { encoding: "utf8", mode: 0o600 });
|
|
142
|
-
fs.renameSync(tmpPath, indexPath);
|
|
143
|
-
}
|
|
144
|
-
async function downloadArchive(url, destination) {
|
|
145
|
-
const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
|
|
146
|
-
if (!response.ok) {
|
|
147
|
-
throw new Error(`Failed to download Context Hub archive (${response.status}) from ${url}`);
|
|
148
|
-
}
|
|
149
|
-
const BunRuntime = globalThis.Bun;
|
|
150
|
-
if (BunRuntime?.write) {
|
|
151
|
-
await BunRuntime.write(destination, response);
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
155
|
-
fs.writeFileSync(destination, Buffer.from(arrayBuffer));
|
|
156
|
-
}
|
|
157
|
-
function buildContextHubIndex(repoDir) {
|
|
158
|
-
const contentDir = path.join(repoDir, "content");
|
|
159
|
-
if (!fs.existsSync(contentDir) || !fs.statSync(contentDir).isDirectory()) {
|
|
160
|
-
throw new Error(`Context Hub repo at ${repoDir} is missing a content/ directory`);
|
|
161
|
-
}
|
|
162
|
-
const files = findEntryFiles(contentDir);
|
|
163
|
-
const entries = [];
|
|
164
|
-
for (const filePath of files) {
|
|
165
|
-
const entry = buildEntry(repoDir, contentDir, filePath);
|
|
166
|
-
if (entry)
|
|
167
|
-
entries.push(entry);
|
|
168
|
-
}
|
|
169
|
-
return entries;
|
|
170
|
-
}
|
|
171
|
-
function findEntryFiles(dir) {
|
|
172
|
-
const results = [];
|
|
173
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
174
|
-
const full = path.join(dir, entry.name);
|
|
175
|
-
if (entry.isDirectory()) {
|
|
176
|
-
results.push(...findEntryFiles(full));
|
|
177
|
-
}
|
|
178
|
-
else if (entry.name === "DOC.md" || entry.name === "SKILL.md") {
|
|
179
|
-
results.push(full);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return results;
|
|
183
|
-
}
|
|
184
|
-
function buildEntry(repoDir, contentDir, fullPath) {
|
|
185
|
-
const raw = fs.readFileSync(fullPath, "utf8");
|
|
186
|
-
const parsed = parseFrontmatter(raw);
|
|
187
|
-
const relPath = path.posix.normalize(path.relative(repoDir, fullPath).replace(/\\/g, "/"));
|
|
188
|
-
const relFromContent = path.posix.normalize(path.relative(contentDir, fullPath).replace(/\\/g, "/"));
|
|
189
|
-
const segments = relFromContent.split("/");
|
|
190
|
-
const author = sanitizeString(segments[0] ?? "");
|
|
191
|
-
if (!author)
|
|
192
|
-
return null;
|
|
193
|
-
const name = sanitizeString(toStringOrUndefined(parsed.data.name) ?? path.basename(path.dirname(fullPath)));
|
|
194
|
-
if (!name)
|
|
195
|
-
return null;
|
|
196
|
-
const metadata = (parsed.data.metadata ?? {});
|
|
197
|
-
const tags = parseCsv(metadata.tags);
|
|
198
|
-
const language = sanitizeString(toStringOrUndefined(metadata.languages));
|
|
199
|
-
const version = sanitizeString(toStringOrUndefined(metadata.versions));
|
|
200
|
-
const id = `${author}/${name}`;
|
|
201
|
-
const assetType = path.basename(fullPath) === "SKILL.md" ? "skill" : "knowledge";
|
|
202
|
-
return {
|
|
203
|
-
id,
|
|
204
|
-
ref: makeContextHubRef(relPath),
|
|
205
|
-
assetType,
|
|
206
|
-
filePath: relPath,
|
|
207
|
-
description: sanitizeString(toStringOrUndefined(parsed.data.description), 1000),
|
|
208
|
-
tags,
|
|
209
|
-
language: language || undefined,
|
|
210
|
-
version: version || undefined,
|
|
211
|
-
sortName: `${id}:${language ?? ""}:${version ?? ""}`,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
function scoreEntry(entry, query) {
|
|
215
|
-
const trimmed = query.trim().toLowerCase();
|
|
216
|
-
if (!trimmed)
|
|
217
|
-
return 1;
|
|
218
|
-
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
219
|
-
if (tokens.length === 0)
|
|
220
|
-
return 1;
|
|
221
|
-
const haystacks = [
|
|
222
|
-
{ text: entry.id.toLowerCase(), weight: 4 },
|
|
223
|
-
{ text: entry.description?.toLowerCase() ?? "", weight: 2 },
|
|
224
|
-
{ text: (entry.tags ?? []).join(" ").toLowerCase(), weight: 2 },
|
|
225
|
-
{ text: entry.language?.toLowerCase() ?? "", weight: 1 },
|
|
226
|
-
{ text: entry.version?.toLowerCase() ?? "", weight: 1 },
|
|
227
|
-
];
|
|
228
|
-
let matched = 0;
|
|
229
|
-
let score = 0;
|
|
230
|
-
for (const token of tokens) {
|
|
231
|
-
let tokenScore = 0;
|
|
232
|
-
for (const { text, weight } of haystacks) {
|
|
233
|
-
if (!text)
|
|
234
|
-
continue;
|
|
235
|
-
if (text === token)
|
|
236
|
-
tokenScore = Math.max(tokenScore, weight * 2);
|
|
237
|
-
else if (text.includes(token))
|
|
238
|
-
tokenScore = Math.max(tokenScore, weight);
|
|
239
|
-
}
|
|
240
|
-
if (tokenScore > 0) {
|
|
241
|
-
matched++;
|
|
242
|
-
score += tokenScore;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if (matched === 0)
|
|
246
|
-
return 0;
|
|
247
|
-
const coverage = matched / tokens.length;
|
|
248
|
-
return Math.round((score * coverage + (entry.id.toLowerCase() === trimmed ? 5 : 0)) * 1000) / 1000;
|
|
249
|
-
}
|
|
250
|
-
function matchesType(entry, requested) {
|
|
251
|
-
if (!requested || requested === "any")
|
|
252
|
-
return true;
|
|
253
|
-
return entry.assetType === requested;
|
|
254
|
-
}
|
|
255
|
-
function entryToHit(entry, score) {
|
|
256
|
-
const details = [entry.language, entry.version].filter(Boolean).join(" • ");
|
|
257
|
-
const description = [entry.description, details].filter(Boolean).join(" — ") || undefined;
|
|
258
|
-
return {
|
|
259
|
-
type: entry.assetType,
|
|
260
|
-
name: entry.id,
|
|
261
|
-
path: entry.ref,
|
|
262
|
-
ref: entry.ref,
|
|
263
|
-
origin: "context-hub",
|
|
264
|
-
editable: false,
|
|
265
|
-
description,
|
|
266
|
-
tags: entry.tags,
|
|
267
|
-
action: `akm show ${entry.ref}`,
|
|
268
|
-
score,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
function renderContentForView(content, view) {
|
|
272
|
-
if (!view || view.mode === "full")
|
|
273
|
-
return content;
|
|
274
|
-
switch (view.mode) {
|
|
275
|
-
case "toc":
|
|
276
|
-
return formatToc(parseMarkdownToc(content));
|
|
277
|
-
case "frontmatter":
|
|
278
|
-
return extractFrontmatterOnly(content) ?? "(no frontmatter)";
|
|
279
|
-
case "section": {
|
|
280
|
-
const section = extractSection(content, view.heading);
|
|
281
|
-
if (!section) {
|
|
282
|
-
throw new UsageError(`Section not found: ${view.heading}`);
|
|
283
|
-
}
|
|
284
|
-
return section.content;
|
|
285
|
-
}
|
|
286
|
-
case "lines":
|
|
287
|
-
return extractLineRange(content, view.start, view.end);
|
|
288
|
-
default:
|
|
289
|
-
return content;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
function resolveCachedFilePath(repoDir, filePath) {
|
|
293
|
-
const normalized = path.posix.normalize(filePath.replace(/\\/g, "/"));
|
|
294
|
-
if (!normalized.startsWith("content/")) {
|
|
295
|
-
throw new UsageError(`Invalid Context Hub ref: ${filePath}`);
|
|
296
|
-
}
|
|
297
|
-
const resolved = path.resolve(repoDir, normalized);
|
|
298
|
-
const root = path.resolve(repoDir);
|
|
299
|
-
if (!resolved.startsWith(root + path.sep)) {
|
|
300
|
-
throw new UsageError(`Invalid Context Hub ref: ${filePath}`);
|
|
301
|
-
}
|
|
302
|
-
return resolved;
|
|
303
|
-
}
|
|
304
|
-
function buildTarballUrl(repo) {
|
|
305
|
-
return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
|
|
306
|
-
}
|
|
307
|
-
function parseContextHubRepoUrl(rawUrl) {
|
|
308
|
-
if (!rawUrl) {
|
|
309
|
-
throw new ConfigError("Context Hub provider requires a GitHub repository URL");
|
|
310
|
-
}
|
|
311
|
-
let parsed;
|
|
312
|
-
try {
|
|
313
|
-
parsed = new URL(rawUrl);
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
throw new ConfigError(`Context Hub URL is not valid: "${rawUrl}"`);
|
|
317
|
-
}
|
|
318
|
-
if (parsed.protocol !== "https:") {
|
|
319
|
-
throw new ConfigError(`Context Hub URL must use https://, got "${parsed.protocol}"`);
|
|
320
|
-
}
|
|
321
|
-
if (parsed.hostname !== "github.com") {
|
|
322
|
-
throw new ConfigError(`Context Hub provider only supports github.com URLs, got "${parsed.hostname}"`);
|
|
323
|
-
}
|
|
324
|
-
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
325
|
-
if (segments.length < 2) {
|
|
326
|
-
throw new ConfigError(`Context Hub URL must point to a GitHub repository, got "${rawUrl}"`);
|
|
327
|
-
}
|
|
328
|
-
const owner = sanitizeString(segments[0]);
|
|
329
|
-
const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
|
|
330
|
-
let ref = "main";
|
|
331
|
-
if (segments[2] === "tree" && segments.length >= 4) {
|
|
332
|
-
ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
|
|
333
|
-
}
|
|
334
|
-
if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
335
|
-
throw new ConfigError(`Unsupported Context Hub repository URL: "${rawUrl}"`);
|
|
336
|
-
}
|
|
337
|
-
if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
|
338
|
-
throw new ConfigError(`Unsupported Context Hub branch/ref in URL: "${rawUrl}"`);
|
|
339
|
-
}
|
|
340
|
-
return {
|
|
341
|
-
owner,
|
|
342
|
-
repo,
|
|
343
|
-
ref,
|
|
344
|
-
canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
function makeContextHubRef(filePath) {
|
|
348
|
-
return `${CONTEXT_HUB_REF_PREFIX}${path.posix.normalize(filePath)}`;
|
|
349
|
-
}
|
|
350
|
-
function parseContextHubRef(ref) {
|
|
351
|
-
const trimmed = ref.trim();
|
|
352
|
-
if (!trimmed.startsWith(CONTEXT_HUB_REF_PREFIX)) {
|
|
353
|
-
throw new UsageError(`Invalid Context Hub ref: ${ref}`);
|
|
354
|
-
}
|
|
355
|
-
const filePath = trimmed.slice(CONTEXT_HUB_REF_PREFIX.length);
|
|
356
|
-
if (!filePath) {
|
|
357
|
-
throw new UsageError(`Invalid Context Hub ref: ${ref}`);
|
|
358
|
-
}
|
|
359
|
-
return filePath;
|
|
360
|
-
}
|
|
361
|
-
function parseCsv(value) {
|
|
362
|
-
if (typeof value !== "string")
|
|
363
|
-
return undefined;
|
|
364
|
-
const items = value
|
|
365
|
-
.split(",")
|
|
366
|
-
.map((item) => sanitizeString(item.trim(), 100))
|
|
367
|
-
.filter(Boolean);
|
|
368
|
-
return items.length > 0 ? items : undefined;
|
|
369
|
-
}
|
|
370
|
-
function sanitizeString(value, maxLength = 255) {
|
|
371
|
-
if (typeof value !== "string")
|
|
372
|
-
return "";
|
|
373
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips untrusted control chars from remote metadata
|
|
374
|
-
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
375
|
-
}
|
|
376
|
-
function isExpired(mtimeMs, ttlMs) {
|
|
377
|
-
return Date.now() - mtimeMs > ttlMs;
|
|
378
|
-
}
|
|
379
|
-
function isContextHubEntry(value) {
|
|
380
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
381
|
-
return false;
|
|
382
|
-
const obj = value;
|
|
383
|
-
return (typeof obj.id === "string" &&
|
|
384
|
-
typeof obj.ref === "string" &&
|
|
385
|
-
(obj.assetType === "knowledge" || obj.assetType === "skill") &&
|
|
386
|
-
typeof obj.filePath === "string" &&
|
|
387
|
-
typeof obj.sortName === "string");
|
|
388
|
-
}
|
|
389
|
-
export { ContextHubStashProvider, buildContextHubIndex, makeContextHubRef, parseContextHubRef, parseContextHubRepoUrl };
|