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/asset-registry.js +48 -0
- package/dist/asset-spec.js +11 -32
- package/dist/cli.js +173 -59
- package/dist/common.js +3 -0
- package/dist/completions.js +4 -2
- package/dist/config.js +35 -7
- package/dist/db.js +182 -22
- package/dist/embedder.js +140 -23
- package/dist/file-context.js +3 -0
- package/dist/indexer.js +198 -42
- 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/renderers.js +1 -1
- package/dist/search-fields.js +69 -0
- package/dist/search-source.js +42 -0
- package/dist/setup.js +151 -7
- 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 -3
- package/dist/stash-providers/context-hub.js +0 -390
package/dist/setup.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* Collects all choices and writes config once at the end.
|
|
7
7
|
*/
|
|
8
8
|
import * as p from "@clack/prompts";
|
|
9
|
+
import { isHttpUrl } from "./common";
|
|
9
10
|
import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from "./config";
|
|
11
|
+
import { closeDatabase, isVecAvailable, openDatabase } from "./db";
|
|
10
12
|
import { detectAgentPlatforms, detectOllama, detectOpenViking } from "./detect";
|
|
13
|
+
import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "./embedder";
|
|
11
14
|
import { akmIndex } from "./indexer";
|
|
12
15
|
import { akmInit } from "./init";
|
|
13
16
|
import { getDefaultStashDir } from "./paths";
|
|
@@ -20,6 +23,11 @@ const RECOMMENDED_GITHUB_REPOS = [
|
|
|
20
23
|
hint: "community knowledge",
|
|
21
24
|
},
|
|
22
25
|
];
|
|
26
|
+
// Approximate first-download sizes used in the setup note.
|
|
27
|
+
// LOCAL_MODEL_APPROX_SIZE_MB tracks the default local model (DEFAULT_LOCAL_MODEL).
|
|
28
|
+
const LOCAL_MODEL_APPROX_SIZE_MB = 130;
|
|
29
|
+
// SQLITE_VEC_APPROX_SIZE_MB reflects the optional sqlite-vec install footprint.
|
|
30
|
+
const SQLITE_VEC_APPROX_SIZE_MB = 5;
|
|
23
31
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
24
32
|
function bail() {
|
|
25
33
|
p.cancel("Setup cancelled. No changes were saved.");
|
|
@@ -70,6 +78,112 @@ async function promptOrBack(fn) {
|
|
|
70
78
|
return null;
|
|
71
79
|
return result;
|
|
72
80
|
}
|
|
81
|
+
function isRemoteEmbeddingConfig(embedding) {
|
|
82
|
+
return isHttpUrl(embedding?.endpoint);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* @internal Exported for testing only.
|
|
86
|
+
*/
|
|
87
|
+
export function describeSemanticSearchAssets(embedding) {
|
|
88
|
+
if (isRemoteEmbeddingConfig(embedding)) {
|
|
89
|
+
return [
|
|
90
|
+
`• Embedding endpoint: ${embedding?.provider ?? "custom"} / ${embedding?.model} (no local model download)`,
|
|
91
|
+
`• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
return [
|
|
95
|
+
`• Local embedding model: ${embedding?.localModel ?? DEFAULT_LOCAL_MODEL} (~${LOCAL_MODEL_APPROX_SIZE_MB} MB download on first use)`,
|
|
96
|
+
`• sqlite-vec acceleration: optional native extension (~${SQLITE_VEC_APPROX_SIZE_MB} MB when installed separately)`,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
export async function stepSemanticSearch(current, embedding) {
|
|
100
|
+
const enabled = await prompt(() => p.confirm({
|
|
101
|
+
message: "Enable semantic search?",
|
|
102
|
+
initialValue: current.semanticSearch,
|
|
103
|
+
}));
|
|
104
|
+
if (!enabled) {
|
|
105
|
+
return { enabled: false, prepareAssets: false };
|
|
106
|
+
}
|
|
107
|
+
p.note(describeSemanticSearchAssets(embedding).join("\n"), "Semantic Search Assets");
|
|
108
|
+
const prepareAssets = await prompt(() => p.confirm({
|
|
109
|
+
message: isRemoteEmbeddingConfig(embedding)
|
|
110
|
+
? "Check the embedding endpoint and verify semantic search now?"
|
|
111
|
+
: "Download and verify semantic-search assets now?",
|
|
112
|
+
initialValue: true,
|
|
113
|
+
}));
|
|
114
|
+
return { enabled: true, prepareAssets };
|
|
115
|
+
}
|
|
116
|
+
async function prepareSemanticSearchAssets(config) {
|
|
117
|
+
const remote = isRemoteEmbeddingConfig(config.embedding);
|
|
118
|
+
// For local embeddings, ensure the required package is installed first.
|
|
119
|
+
if (!remote) {
|
|
120
|
+
if (!(await isTransformersAvailable())) {
|
|
121
|
+
const spin = p.spinner();
|
|
122
|
+
spin.start("Installing @huggingface/transformers...");
|
|
123
|
+
try {
|
|
124
|
+
const proc = Bun.spawn(["bun", "add", "@huggingface/transformers"], {
|
|
125
|
+
stdout: "pipe",
|
|
126
|
+
stderr: "pipe",
|
|
127
|
+
});
|
|
128
|
+
await proc.exited;
|
|
129
|
+
if (proc.exitCode !== 0) {
|
|
130
|
+
const stderr = await new Response(proc.stderr).text();
|
|
131
|
+
throw new Error(stderr || `exit code ${proc.exitCode}`);
|
|
132
|
+
}
|
|
133
|
+
spin.stop("@huggingface/transformers installed.");
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
137
|
+
spin.stop("Could not install @huggingface/transformers.");
|
|
138
|
+
p.log.warn(`Automatic install failed: ${msg}\n` +
|
|
139
|
+
"Install it manually with: bun add @huggingface/transformers\n" +
|
|
140
|
+
"Then re-run `akm setup` or `akm index --full --verbose`.");
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const spin = p.spinner();
|
|
146
|
+
spin.start(remote
|
|
147
|
+
? "Checking remote embedding endpoint..."
|
|
148
|
+
: `Downloading local embedding model (${config.embedding?.localModel ?? DEFAULT_LOCAL_MODEL})...`);
|
|
149
|
+
const result = await checkEmbeddingAvailability(config.embedding);
|
|
150
|
+
if (!result.available) {
|
|
151
|
+
spin.stop("Semantic-search assets could not be prepared.");
|
|
152
|
+
if (result.reason === "remote-unreachable") {
|
|
153
|
+
p.log.warn("The remote embedding endpoint is not reachable. Check your endpoint and credentials, then retry `akm index --full --verbose`.");
|
|
154
|
+
}
|
|
155
|
+
else if (result.reason === "missing-package") {
|
|
156
|
+
p.log.warn("@huggingface/transformers is not installed. Install it with: bun add @huggingface/transformers\n" +
|
|
157
|
+
"Then re-run `akm setup` or `akm index --full --verbose`.");
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
p.log.warn(`The local embedding model could not be downloaded: ${result.message}\n` +
|
|
161
|
+
"Retry `akm index --full --verbose` after confirming local model downloads are permitted.");
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
spin.stop(remote ? "Remote embedding endpoint is ready." : "Local embedding model downloaded and ready.");
|
|
166
|
+
let db;
|
|
167
|
+
try {
|
|
168
|
+
db = openDatabase();
|
|
169
|
+
if (isVecAvailable(db)) {
|
|
170
|
+
p.log.info("sqlite-vec is available for fast vector search.");
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
p.log.info("sqlite-vec is not available. Semantic search will use the JS fallback until the optional extension is installed.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
178
|
+
p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will use the JS fallback. (${message})\n` +
|
|
179
|
+
"Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
if (db)
|
|
183
|
+
closeDatabase(db);
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
73
187
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
|
74
188
|
async function stepStashDir(current) {
|
|
75
189
|
const defaultDir = current.stashDir ?? getDefaultStashDir();
|
|
@@ -431,14 +545,17 @@ export async function runSetupWizard() {
|
|
|
431
545
|
// Step 2: Ollama / Embedding / LLM
|
|
432
546
|
p.log.step("Step 2: Embedding & LLM");
|
|
433
547
|
const { embedding, llm } = await stepOllama(current);
|
|
434
|
-
// Step 3:
|
|
435
|
-
p.log.step("Step 3:
|
|
548
|
+
// Step 3: Semantic search assets
|
|
549
|
+
p.log.step("Step 3: Semantic Search");
|
|
550
|
+
const semanticSearch = await stepSemanticSearch(current, embedding);
|
|
551
|
+
// Step 4: Registries
|
|
552
|
+
p.log.step("Step 4: Registries");
|
|
436
553
|
const registries = await stepRegistries(current);
|
|
437
|
-
// Step
|
|
438
|
-
p.log.step("Step
|
|
554
|
+
// Step 5: Stash sources
|
|
555
|
+
p.log.step("Step 5: Stash Sources");
|
|
439
556
|
const stashes = await stepStashSources(current);
|
|
440
|
-
// Step
|
|
441
|
-
p.log.step("Step
|
|
557
|
+
// Step 6: Agent platform detection
|
|
558
|
+
p.log.step("Step 6: Agent Platform Detection");
|
|
442
559
|
const platformStashes = await stepAgentPlatforms(current);
|
|
443
560
|
// Merge platform stashes into main stashes list
|
|
444
561
|
const allStashes = [...stashes];
|
|
@@ -456,7 +573,7 @@ export async function runSetupWizard() {
|
|
|
456
573
|
registries,
|
|
457
574
|
stashes: allStashes.length > 0 ? allStashes : undefined,
|
|
458
575
|
// Preserve existing fields
|
|
459
|
-
semanticSearch:
|
|
576
|
+
semanticSearch: semanticSearch.enabled,
|
|
460
577
|
installed: current.installed,
|
|
461
578
|
output: current.output,
|
|
462
579
|
};
|
|
@@ -466,6 +583,7 @@ export async function runSetupWizard() {
|
|
|
466
583
|
`Stash directory: ${stashDir}`,
|
|
467
584
|
`Embedding: ${embedding ? `${embedding.provider ?? "remote"} / ${embedding.model}` : "built-in local"}`,
|
|
468
585
|
`LLM: ${llm ? `${llm.provider ?? "remote"} / ${llm.model}` : "disabled"}`,
|
|
586
|
+
`Semantic search: ${semanticSearch.enabled ? "enabled" : "disabled"}`,
|
|
469
587
|
`Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`,
|
|
470
588
|
`Stash sources: ${allStashes.length}`,
|
|
471
589
|
].join("\n"), "Configuration Summary");
|
|
@@ -479,12 +597,38 @@ export async function runSetupWizard() {
|
|
|
479
597
|
saveConfig(newConfig);
|
|
480
598
|
// Initialize stash directory
|
|
481
599
|
await akmInit({ dir: stashDir });
|
|
600
|
+
if (semanticSearch.enabled) {
|
|
601
|
+
if (semanticSearch.prepareAssets) {
|
|
602
|
+
const ready = await prepareSemanticSearchAssets(newConfig);
|
|
603
|
+
if (!ready) {
|
|
604
|
+
// Asset preparation failed: disable semantic search and persist the update.
|
|
605
|
+
newConfig.semanticSearch = false;
|
|
606
|
+
saveConfig(newConfig);
|
|
607
|
+
p.log.warn("Semantic search has been disabled in the saved configuration. Re-run `akm setup` or `akm index --full --verbose` once the issue is resolved.");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
p.log.info("Semantic search will be enabled, but asset preparation was skipped. Run `akm index --full --verbose` later to verify it.");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
482
614
|
// Build search index
|
|
615
|
+
p.log.info("Building search index...");
|
|
483
616
|
const spin = p.spinner();
|
|
484
617
|
spin.start("Building search index...");
|
|
485
618
|
try {
|
|
486
619
|
const indexResult = await akmIndex({ stashDir });
|
|
487
620
|
spin.stop(`Indexed ${indexResult.totalEntries} assets.`);
|
|
621
|
+
if (newConfig.semanticSearch) {
|
|
622
|
+
if (indexResult.verification.ok) {
|
|
623
|
+
p.log.success(indexResult.verification.message);
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
p.log.warn(indexResult.verification.message);
|
|
627
|
+
if (indexResult.verification.guidance) {
|
|
628
|
+
p.log.info(indexResult.verification.guidance);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
488
632
|
}
|
|
489
633
|
catch (err) {
|
|
490
634
|
spin.stop("Indexing failed — you can run `akm index` manually later.");
|
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 { ensureGitMirror, GitStashProvider, getCachePaths, parseGitRepoUrl };
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fetchWithRetry } from "../common";
|
|
4
5
|
import { ConfigError, NotFoundError } from "../errors";
|
|
5
6
|
import { getRegistryIndexCacheDir } from "../paths";
|
|
6
7
|
import { registerStashProvider } from "../stash-provider-factory";
|
|
7
|
-
|
|
8
|
-
function sanitizeString(value, maxLength = 255) {
|
|
9
|
-
if (typeof value !== "string")
|
|
10
|
-
return "";
|
|
11
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
12
|
-
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
13
|
-
}
|
|
8
|
+
import { isExpired, sanitizeString } from "./provider-utils";
|
|
14
9
|
/** Per-query cache TTL in milliseconds (5 minutes). */
|
|
15
10
|
const QUERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
16
11
|
/** Maximum age before query cache is considered stale but still usable (1 hour). */
|
|
@@ -70,7 +65,9 @@ class OpenVikingStashProvider {
|
|
|
70
65
|
}
|
|
71
66
|
}
|
|
72
67
|
async show(ref, _view) {
|
|
73
|
-
const
|
|
68
|
+
const trimmed = ref.trim();
|
|
69
|
+
// Accept both viking:// URIs (legacy/internal) and type:name refs
|
|
70
|
+
const uri = trimmed.startsWith("viking://") ? trimmed : refToVikingUri(trimmed);
|
|
74
71
|
const baseUrl = this.baseUrl;
|
|
75
72
|
const headers = this.authHeaders;
|
|
76
73
|
const [statResult, contentResult] = await Promise.all([
|
|
@@ -78,10 +75,10 @@ class OpenVikingStashProvider {
|
|
|
78
75
|
fetchOVJson(`${baseUrl}/api/v1/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`, headers),
|
|
79
76
|
]);
|
|
80
77
|
if (statResult == null && contentResult == null) {
|
|
81
|
-
throw new NotFoundError(`Could not fetch remote asset "${
|
|
78
|
+
throw new NotFoundError(`Could not fetch remote asset "${trimmed}". The OpenViking server at ${baseUrl} may be unreachable or the resource does not exist.`);
|
|
82
79
|
}
|
|
83
80
|
if (contentResult == null) {
|
|
84
|
-
throw new NotFoundError(`Content not found for remote asset "${
|
|
81
|
+
throw new NotFoundError(`Content not found for remote asset "${trimmed}". The server returned metadata but no content.`);
|
|
85
82
|
}
|
|
86
83
|
const stat = (typeof statResult === "object" && statResult !== null ? statResult : {});
|
|
87
84
|
const uriPath = uri.replace(/^viking:\/\//, "");
|
|
@@ -91,19 +88,20 @@ class OpenVikingStashProvider {
|
|
|
91
88
|
const assetType = OV_TYPE_MAP[ovType] ?? "knowledge";
|
|
92
89
|
const content = typeof contentResult === "string" ? contentResult : "";
|
|
93
90
|
const description = sanitizeString(stat.abstract, 1000) || undefined;
|
|
91
|
+
const assetRef = `${assetType}:${name}`;
|
|
94
92
|
return {
|
|
95
93
|
type: assetType,
|
|
96
94
|
name,
|
|
97
|
-
path:
|
|
98
|
-
action: `Remote content from OpenViking — ${
|
|
95
|
+
path: assetRef,
|
|
96
|
+
action: `Remote content from OpenViking — ${assetRef}`,
|
|
99
97
|
content,
|
|
100
98
|
description,
|
|
101
99
|
editable: false,
|
|
102
100
|
origin: "remote",
|
|
103
101
|
};
|
|
104
102
|
}
|
|
105
|
-
canShow(
|
|
106
|
-
return
|
|
103
|
+
canShow(_ref) {
|
|
104
|
+
return !!(this.config.url ?? "").trim();
|
|
107
105
|
}
|
|
108
106
|
get baseUrl() {
|
|
109
107
|
return (this.config.url ?? "").replace(/\/+$/, "");
|
|
@@ -162,9 +160,8 @@ class OpenVikingStashProvider {
|
|
|
162
160
|
const name = sanitizeString(entry.name);
|
|
163
161
|
const abstract = sanitizeString(entry.abstract, 1000);
|
|
164
162
|
const type = sanitizeString(entry.type);
|
|
165
|
-
const uri = sanitizeString(entry.uri, 2048);
|
|
166
163
|
const assetType = OV_TYPE_MAP[type] ?? "knowledge";
|
|
167
|
-
const ref =
|
|
164
|
+
const ref = `${assetType}:${name}`;
|
|
168
165
|
return {
|
|
169
166
|
type: assetType,
|
|
170
167
|
name,
|
|
@@ -180,7 +177,7 @@ class OpenVikingStashProvider {
|
|
|
180
177
|
}
|
|
181
178
|
queryCachePath(query, limit) {
|
|
182
179
|
const cacheDir = getRegistryIndexCacheDir();
|
|
183
|
-
const hasher =
|
|
180
|
+
const hasher = createHash("md5");
|
|
184
181
|
hasher.update(this.config.url ?? "");
|
|
185
182
|
hasher.update("\0");
|
|
186
183
|
hasher.update(query.trim().toLowerCase());
|
|
@@ -225,11 +222,28 @@ class OpenVikingStashProvider {
|
|
|
225
222
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
226
223
|
registerStashProvider("openviking", (config) => new OpenVikingStashProvider(config));
|
|
227
224
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Convert a type:name ref to a viking:// URI for the OpenViking API.
|
|
227
|
+
* Maps the akm asset type back to the OV plural form (e.g. "skill" -> "skills").
|
|
228
|
+
*/
|
|
229
|
+
function refToVikingUri(ref) {
|
|
230
|
+
const colon = ref.indexOf(":");
|
|
231
|
+
if (colon <= 0)
|
|
232
|
+
return `viking://${ref}`;
|
|
233
|
+
const name = ref.slice(colon + 1);
|
|
234
|
+
const type = ref.slice(0, colon);
|
|
235
|
+
const ovDir = AKM_TO_OV_DIR[type] ?? type;
|
|
236
|
+
return `viking://${ovDir}/${name}`;
|
|
232
237
|
}
|
|
238
|
+
/** Reverse map: akm asset type → OpenViking directory name (plural). */
|
|
239
|
+
const AKM_TO_OV_DIR = {
|
|
240
|
+
skill: "skills",
|
|
241
|
+
memory: "memories",
|
|
242
|
+
knowledge: "resources",
|
|
243
|
+
agent: "agents",
|
|
244
|
+
command: "commands",
|
|
245
|
+
script: "scripts",
|
|
246
|
+
};
|
|
233
247
|
function parseOVSearchResponse(result) {
|
|
234
248
|
if (Array.isArray(result))
|
|
235
249
|
return result.filter(isValidOVEntry);
|
|
@@ -311,9 +325,6 @@ function extractNameFromUri(uri) {
|
|
|
311
325
|
const last = segments[segments.length - 1] ?? "unknown";
|
|
312
326
|
return last.replace(/\.[^.]+$/, "");
|
|
313
327
|
}
|
|
314
|
-
function isExpired(mtimeMs, ttlMs) {
|
|
315
|
-
return Date.now() - mtimeMs > ttlMs;
|
|
316
|
-
}
|
|
317
328
|
async function fetchOVJson(url, headers) {
|
|
318
329
|
try {
|
|
319
330
|
const response = await fetchWithRetry(url, { headers }, { timeout: 10_000, retries: 1 });
|
|
@@ -334,4 +345,4 @@ function inferTypeFromUri(uri) {
|
|
|
334
345
|
return OV_TYPE_MAP[firstSegment] ?? "knowledge";
|
|
335
346
|
}
|
|
336
347
|
// ── Exports for testing ─────────────────────────────────────────────────────
|
|
337
|
-
export { OpenVikingStashProvider,
|
|
348
|
+
export { OpenVikingStashProvider, parseOVSearchResponse, refToVikingUri };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Strip terminal control characters from untrusted strings. */
|
|
2
|
+
export function sanitizeString(value, maxLength = 255) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return "";
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
6
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
7
|
+
}
|
|
8
|
+
/** Check whether a cached timestamp has exceeded its TTL. */
|
|
9
|
+
export function isExpired(mtimeMs, ttlMs) {
|
|
10
|
+
return Date.now() - mtimeMs > ttlMs;
|
|
11
|
+
}
|