akm-cli 0.0.17 → 0.0.19
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 -2
- package/dist/cli.js +181 -0
- package/dist/common.js +6 -0
- package/dist/config-cli.js +6 -1
- package/dist/config.js +6 -0
- package/dist/db.js +9 -1
- package/dist/indexer.js +15 -1
- package/dist/provider-registry.js +8 -0
- package/dist/providers/skills-sh.js +166 -0
- package/dist/providers/static-index.js +347 -0
- package/dist/registry-provider.js +1 -0
- package/dist/registry-resolve.js +37 -0
- package/dist/registry-search.js +48 -319
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Kit Manager
|
|
2
|
+
|
|
3
|
+
> Agent-i-Kit
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/akm-cli)
|
|
4
6
|
[](https://github.com/itlackey/agentikit/actions/workflows/ci.yml)
|
|
@@ -43,7 +45,7 @@ akm search "deploy"
|
|
|
43
45
|
akm show script:deploy.sh
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
##
|
|
48
|
+
## Works Any AI Agent
|
|
47
49
|
|
|
48
50
|
`akm` is platform agnostic. Any model that can execute shell commands can search
|
|
49
51
|
your stash and use what it finds. The workflow is three commands:
|
package/dist/cli.js
CHANGED
|
@@ -699,6 +699,7 @@ const registryCommand = defineCommand({
|
|
|
699
699
|
args: {
|
|
700
700
|
url: { type: "positional", description: "Registry index URL", required: true },
|
|
701
701
|
name: { type: "string", description: "Human-friendly name for the registry" },
|
|
702
|
+
provider: { type: "string", description: "Provider type (e.g. static-index, skills-sh)" },
|
|
702
703
|
},
|
|
703
704
|
run({ args }) {
|
|
704
705
|
return runWithJsonErrors(() => {
|
|
@@ -715,6 +716,8 @@ const registryCommand = defineCommand({
|
|
|
715
716
|
const entry = { url: args.url };
|
|
716
717
|
if (args.name)
|
|
717
718
|
entry.name = args.name;
|
|
719
|
+
if (args.provider)
|
|
720
|
+
entry.provider = args.provider;
|
|
718
721
|
registries.push(entry);
|
|
719
722
|
saveConfig({ ...config, registries });
|
|
720
723
|
output("registry-add", { registries, added: true });
|
|
@@ -769,6 +772,19 @@ const sourcesCommand = defineCommand({
|
|
|
769
772
|
});
|
|
770
773
|
},
|
|
771
774
|
});
|
|
775
|
+
const hintsCommand = defineCommand({
|
|
776
|
+
meta: {
|
|
777
|
+
name: "hints",
|
|
778
|
+
description: "Print agent instructions on how to use akm, use --detail full for a complete guide",
|
|
779
|
+
},
|
|
780
|
+
args: {
|
|
781
|
+
detail: { type: "string", description: "Detail level (normal|full)", default: "normal" },
|
|
782
|
+
},
|
|
783
|
+
run({ args }) {
|
|
784
|
+
const detail = args.detail === "full" ? "full" : "normal";
|
|
785
|
+
process.stdout.write(loadHints(detail));
|
|
786
|
+
},
|
|
787
|
+
});
|
|
772
788
|
const main = defineCommand({
|
|
773
789
|
meta: {
|
|
774
790
|
name: "akm",
|
|
@@ -794,6 +810,7 @@ const main = defineCommand({
|
|
|
794
810
|
sources: sourcesCommand,
|
|
795
811
|
registry: registryCommand,
|
|
796
812
|
config: configCommand,
|
|
813
|
+
hints: hintsCommand,
|
|
797
814
|
},
|
|
798
815
|
});
|
|
799
816
|
const SEARCH_SOURCES = ["local", "registry", "both"];
|
|
@@ -932,3 +949,167 @@ function normalizeShowArgv(argv) {
|
|
|
932
949
|
result.push(...globalFlags);
|
|
933
950
|
return result;
|
|
934
951
|
}
|
|
952
|
+
// ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
|
|
953
|
+
function loadHints(detail = "normal") {
|
|
954
|
+
const filename = detail === "full" ? "AGENTS.full.md" : "AGENTS.md";
|
|
955
|
+
const fallback = detail === "full" ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
|
|
956
|
+
// Try reading from the docs/ directory (works in dev and when installed via npm)
|
|
957
|
+
try {
|
|
958
|
+
const docsPath = path.resolve(import.meta.dir ?? __dirname, `../docs/${filename}`);
|
|
959
|
+
if (fs.existsSync(docsPath)) {
|
|
960
|
+
return fs.readFileSync(docsPath, "utf8");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// fall through
|
|
965
|
+
}
|
|
966
|
+
// Fallback for compiled binary — inline content
|
|
967
|
+
return fallback;
|
|
968
|
+
}
|
|
969
|
+
const EMBEDDED_HINTS = `# akm CLI
|
|
970
|
+
|
|
971
|
+
You have access to a searchable library of scripts, skills, commands, agents, and knowledge documents via \`akm\`. Search the stash first before writing something from scratch.
|
|
972
|
+
|
|
973
|
+
## Quick Reference
|
|
974
|
+
|
|
975
|
+
\`\`\`sh
|
|
976
|
+
akm search "<query>" # Search for assets
|
|
977
|
+
akm search "<query>" --type skill # Filter by type
|
|
978
|
+
akm search "<query>" --source both # Search registries and local stashes for assets
|
|
979
|
+
akm show <ref> # View asset details
|
|
980
|
+
akm add <ref> # Install a kit (npm, GitHub, git, local)
|
|
981
|
+
akm clone <ref> # Copy an asset to the working stash (optional --dest arg to clone to specific location)
|
|
982
|
+
akm registry search "<query>" # Search all registries
|
|
983
|
+
\`\`\`
|
|
984
|
+
|
|
985
|
+
## Primary Asset Types
|
|
986
|
+
|
|
987
|
+
| Type | What \`akm show\` returns |
|
|
988
|
+
| --- | --- |
|
|
989
|
+
| script | A \`run\` command you can execute directly |
|
|
990
|
+
| skill | Instructions to follow (read the full content) |
|
|
991
|
+
| command | A prompt template with placeholders to fill in |
|
|
992
|
+
| agent | A system prompt with model and tool hints |
|
|
993
|
+
| knowledge | A reference doc (use \`toc\` or \`section "..."\` to navigate) |
|
|
994
|
+
|
|
995
|
+
Run \`akm -h\` for the full command reference.
|
|
996
|
+
`;
|
|
997
|
+
const EMBEDDED_HINTS_FULL = `# akm CLI — Full Reference
|
|
998
|
+
|
|
999
|
+
You have access to a searchable library of scripts, skills, commands, agents, and knowledge documents via \`akm\`. Search the stash first before writing something from scratch.
|
|
1000
|
+
|
|
1001
|
+
## Search
|
|
1002
|
+
|
|
1003
|
+
\`\`\`sh
|
|
1004
|
+
akm search "<query>" # Search local stash
|
|
1005
|
+
akm search "<query>" --type skill # Filter by asset type
|
|
1006
|
+
akm search "<query>" --source both # Search local stash and registries
|
|
1007
|
+
akm search "<query>" --source registry # Search registries only
|
|
1008
|
+
akm search "<query>" --limit 10 # Limit results
|
|
1009
|
+
akm search "<query>" --detail full # Include scores, paths, timing
|
|
1010
|
+
\`\`\`
|
|
1011
|
+
|
|
1012
|
+
| Flag | Values | Default |
|
|
1013
|
+
| --- | --- | --- |
|
|
1014
|
+
| \`--type\` | \`skill\`, \`command\`, \`agent\`, \`knowledge\`, \`script\`, \`any\` | \`any\` |
|
|
1015
|
+
| \`--source\` | \`local\`, \`registry\`, \`both\` | \`local\` |
|
|
1016
|
+
| \`--limit\` | number | \`20\` |
|
|
1017
|
+
| \`--format\` | \`json\`, \`text\`, \`yaml\` | \`json\` |
|
|
1018
|
+
| \`--detail\` | \`brief\`, \`normal\`, \`full\` | \`brief\` |
|
|
1019
|
+
|
|
1020
|
+
## Show
|
|
1021
|
+
|
|
1022
|
+
Display an asset by ref. Knowledge assets support view modes as positional arguments.
|
|
1023
|
+
|
|
1024
|
+
\`\`\`sh
|
|
1025
|
+
akm show script:deploy.sh # Show script (returns run command)
|
|
1026
|
+
akm show skill:code-review # Show skill (returns full content)
|
|
1027
|
+
akm show command:release # Show command (returns template)
|
|
1028
|
+
akm show agent:architect # Show agent (returns system prompt)
|
|
1029
|
+
akm show knowledge:guide toc # Table of contents
|
|
1030
|
+
akm show knowledge:guide section "Auth" # Specific section
|
|
1031
|
+
akm show knowledge:guide lines 10 30 # Line range
|
|
1032
|
+
\`\`\`
|
|
1033
|
+
|
|
1034
|
+
| Type | Key fields returned |
|
|
1035
|
+
| --- | --- |
|
|
1036
|
+
| script | \`run\`, \`setup\`, \`cwd\` |
|
|
1037
|
+
| skill | \`content\` (full SKILL.md) |
|
|
1038
|
+
| command | \`template\`, \`description\`, \`parameters\` |
|
|
1039
|
+
| agent | \`prompt\`, \`description\`, \`modelHint\`, \`toolPolicy\` |
|
|
1040
|
+
| knowledge | \`content\` (with view modes: \`full\`, \`toc\`, \`frontmatter\`, \`section\`, \`lines\`) |
|
|
1041
|
+
|
|
1042
|
+
## Install & Manage Kits
|
|
1043
|
+
|
|
1044
|
+
\`\`\`sh
|
|
1045
|
+
akm add <ref> # Install a kit
|
|
1046
|
+
akm add @scope/kit # From npm
|
|
1047
|
+
akm add owner/repo # From GitHub
|
|
1048
|
+
akm add ./path/to/local/kit # From local directory
|
|
1049
|
+
akm list # List installed kits
|
|
1050
|
+
akm remove <target> # Remove by id or ref
|
|
1051
|
+
akm update --all # Update all installed kits
|
|
1052
|
+
akm update <target> --force # Force re-download
|
|
1053
|
+
\`\`\`
|
|
1054
|
+
|
|
1055
|
+
## Clone
|
|
1056
|
+
|
|
1057
|
+
Copy an asset to the working stash or a custom destination for editing.
|
|
1058
|
+
|
|
1059
|
+
\`\`\`sh
|
|
1060
|
+
akm clone <ref> # Clone to working stash
|
|
1061
|
+
akm clone <ref> --name new-name # Rename on clone
|
|
1062
|
+
akm clone <ref> --dest ./project/.claude # Clone to custom location
|
|
1063
|
+
akm clone <ref> --force # Overwrite existing
|
|
1064
|
+
akm clone "npm:@scope/pkg//script:deploy.sh" # Clone from remote package
|
|
1065
|
+
\`\`\`
|
|
1066
|
+
|
|
1067
|
+
When \`--dest\` is provided, \`akm init\` is not required first.
|
|
1068
|
+
|
|
1069
|
+
## Registries
|
|
1070
|
+
|
|
1071
|
+
\`\`\`sh
|
|
1072
|
+
akm registry list # List configured registries
|
|
1073
|
+
akm registry add <url> # Add a registry
|
|
1074
|
+
akm registry add <url> --name my-team # Add with label
|
|
1075
|
+
akm registry add <url> --provider skills-sh # Specify provider type
|
|
1076
|
+
akm registry remove <url-or-name> # Remove a registry
|
|
1077
|
+
akm registry search "<query>" # Search all registries
|
|
1078
|
+
akm registry search "<query>" --assets # Include asset-level results
|
|
1079
|
+
\`\`\`
|
|
1080
|
+
|
|
1081
|
+
## Configuration
|
|
1082
|
+
|
|
1083
|
+
\`\`\`sh
|
|
1084
|
+
akm config list # Show current config
|
|
1085
|
+
akm config get <key> # Read a value
|
|
1086
|
+
akm config set <key> <value> # Set a value
|
|
1087
|
+
akm config unset <key> # Remove a key
|
|
1088
|
+
akm config path --all # Show all config paths
|
|
1089
|
+
\`\`\`
|
|
1090
|
+
|
|
1091
|
+
## Other Commands
|
|
1092
|
+
|
|
1093
|
+
\`\`\`sh
|
|
1094
|
+
akm init # Initialize stash directory
|
|
1095
|
+
akm index # Rebuild search index
|
|
1096
|
+
akm index --full # Full reindex
|
|
1097
|
+
akm sources # List stash search paths
|
|
1098
|
+
akm upgrade # Upgrade akm binary
|
|
1099
|
+
akm upgrade --check # Check for updates
|
|
1100
|
+
akm hints # Print this reference
|
|
1101
|
+
\`\`\`
|
|
1102
|
+
|
|
1103
|
+
## Output Control
|
|
1104
|
+
|
|
1105
|
+
All commands accept \`--format\` and \`--detail\` flags:
|
|
1106
|
+
|
|
1107
|
+
- \`--format json\` (default) — structured JSON
|
|
1108
|
+
- \`--format text\` — human-readable plain text
|
|
1109
|
+
- \`--format yaml\` — YAML output
|
|
1110
|
+
- \`--detail brief\` (default) — compact output
|
|
1111
|
+
- \`--detail normal\` — adds tags, refs, origins
|
|
1112
|
+
- \`--detail full\` — includes scores, paths, timing, debug info
|
|
1113
|
+
|
|
1114
|
+
Run \`akm -h\` or \`akm <command> -h\` for per-command help.
|
|
1115
|
+
`;
|
package/dist/common.js
CHANGED
|
@@ -147,6 +147,12 @@ export async function fetchWithTimeout(url, opts, timeoutMs = 30_000) {
|
|
|
147
147
|
try {
|
|
148
148
|
return await fetch(url, { ...opts, signal: controller.signal });
|
|
149
149
|
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
152
|
+
throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
|
|
153
|
+
}
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
150
156
|
finally {
|
|
151
157
|
clearTimeout(timer);
|
|
152
158
|
}
|
package/dist/config-cli.js
CHANGED
|
@@ -131,7 +131,7 @@ function parseRegistriesValue(value) {
|
|
|
131
131
|
parsed = JSON.parse(value);
|
|
132
132
|
}
|
|
133
133
|
catch {
|
|
134
|
-
throw new UsageError(`Invalid value for registries: expected JSON array of {url, name?, enabled?} objects` +
|
|
134
|
+
throw new UsageError(`Invalid value for registries: expected JSON array of {url, name?, enabled?, provider?, options?} objects` +
|
|
135
135
|
` (e.g. '[{"url":"https://example.com/index.json","name":"my-registry"}]')`);
|
|
136
136
|
}
|
|
137
137
|
if (!Array.isArray(parsed)) {
|
|
@@ -150,6 +150,11 @@ function parseRegistriesValue(value) {
|
|
|
150
150
|
result.name = obj.name;
|
|
151
151
|
if (typeof obj.enabled === "boolean")
|
|
152
152
|
result.enabled = obj.enabled;
|
|
153
|
+
if (typeof obj.provider === "string" && obj.provider)
|
|
154
|
+
result.provider = obj.provider;
|
|
155
|
+
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
156
|
+
result.options = obj.options;
|
|
157
|
+
}
|
|
153
158
|
return result;
|
|
154
159
|
});
|
|
155
160
|
}
|
package/dist/config.js
CHANGED
|
@@ -334,5 +334,11 @@ function parseRegistryConfigEntry(value) {
|
|
|
334
334
|
entry.name = name;
|
|
335
335
|
if (typeof obj.enabled === "boolean")
|
|
336
336
|
entry.enabled = obj.enabled;
|
|
337
|
+
const provider = asNonEmptyString(obj.provider);
|
|
338
|
+
if (provider)
|
|
339
|
+
entry.provider = provider;
|
|
340
|
+
if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
|
|
341
|
+
entry.options = obj.options;
|
|
342
|
+
}
|
|
337
343
|
return entry;
|
|
338
344
|
}
|
package/dist/db.js
CHANGED
|
@@ -180,6 +180,15 @@ export function upsertEntry(db, entryKey, dirPath, filePath, stashDir, entry, se
|
|
|
180
180
|
}
|
|
181
181
|
export function deleteEntriesByDir(db, dirPath) {
|
|
182
182
|
const ids = db.prepare("SELECT id FROM entries WHERE dir_path = ?").all(dirPath);
|
|
183
|
+
deleteRelatedRows(db, ids);
|
|
184
|
+
db.prepare("DELETE FROM entries WHERE dir_path = ?").run(dirPath);
|
|
185
|
+
}
|
|
186
|
+
export function deleteEntriesByStashDir(db, stashDir) {
|
|
187
|
+
const ids = db.prepare("SELECT id FROM entries WHERE stash_dir = ?").all(stashDir);
|
|
188
|
+
deleteRelatedRows(db, ids);
|
|
189
|
+
db.prepare("DELETE FROM entries WHERE stash_dir = ?").run(stashDir);
|
|
190
|
+
}
|
|
191
|
+
function deleteRelatedRows(db, ids) {
|
|
183
192
|
for (const { id } of ids) {
|
|
184
193
|
try {
|
|
185
194
|
db.prepare("DELETE FROM embeddings WHERE id = ?").run(id);
|
|
@@ -196,7 +205,6 @@ export function deleteEntriesByDir(db, dirPath) {
|
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
|
-
db.prepare("DELETE FROM entries WHERE dir_path = ?").run(dirPath);
|
|
200
208
|
}
|
|
201
209
|
export function rebuildFts(db) {
|
|
202
210
|
db.exec("DELETE FROM entries_fts");
|
package/dist/indexer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveStashDir } from "./common";
|
|
4
|
-
import { closeDatabase, DB_VERSION, deleteEntriesByDir, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, warnIfVecMissing, } from "./db";
|
|
4
|
+
import { closeDatabase, DB_VERSION, deleteEntriesByDir, deleteEntriesByStashDir, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, warnIfVecMissing, } from "./db";
|
|
5
5
|
import { generateMetadataFlat, loadStashFile } from "./metadata";
|
|
6
6
|
import { getDbPath } from "./paths";
|
|
7
7
|
import { walkStashFlat } from "./walker";
|
|
@@ -45,6 +45,20 @@ export async function agentikitIndex(options) {
|
|
|
45
45
|
db.exec("DELETE FROM entries_fts");
|
|
46
46
|
db.exec("DELETE FROM entries");
|
|
47
47
|
}
|
|
48
|
+
else {
|
|
49
|
+
// Incremental: purge entries from stash dirs that have been removed
|
|
50
|
+
// (e.g. after `akm remove`) so orphaned entries don't linger.
|
|
51
|
+
const prevStashDirsJson = getMeta(db, "stashDirs");
|
|
52
|
+
if (prevStashDirsJson) {
|
|
53
|
+
const prevStashDirs = JSON.parse(prevStashDirsJson);
|
|
54
|
+
const currentSet = new Set(allStashDirs);
|
|
55
|
+
for (const dir of prevStashDirs) {
|
|
56
|
+
if (!currentSet.has(dir)) {
|
|
57
|
+
deleteEntriesByStashDir(db, dir);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
48
62
|
const tWalkStart = Date.now();
|
|
49
63
|
// Walk stash dirs and index entries
|
|
50
64
|
const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm } = indexEntries(db, allStashDirs, stashDir, isIncremental, builtAtMs);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// ── Factory map ─────────────────────────────────────────────────────────────
|
|
2
|
+
const providers = new Map();
|
|
3
|
+
export function registerProvider(type, factory) {
|
|
4
|
+
providers.set(type, factory);
|
|
5
|
+
}
|
|
6
|
+
export function resolveProviderFactory(type) {
|
|
7
|
+
return providers.get(type) ?? null;
|
|
8
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fetchWithRetry } from "../common";
|
|
4
|
+
import { getRegistryIndexCacheDir } from "../paths";
|
|
5
|
+
import { registerProvider } from "../provider-registry";
|
|
6
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
7
|
+
/** Per-query cache TTL in milliseconds (15 minutes). */
|
|
8
|
+
const QUERY_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
9
|
+
/** Maximum age before query cache is considered stale but still usable (1 day). */
|
|
10
|
+
const QUERY_CACHE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
// ── Provider class ──────────────────────────────────────────────────────────
|
|
12
|
+
class SkillsShProvider {
|
|
13
|
+
type = "skills-sh";
|
|
14
|
+
config;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
async search(options) {
|
|
19
|
+
try {
|
|
20
|
+
const entries = await this.fetchSkills(options.query, options.limit);
|
|
21
|
+
const limited = entries.slice(0, options.limit);
|
|
22
|
+
const hits = this.mapToHits(limited);
|
|
23
|
+
let assetHits;
|
|
24
|
+
if (options.includeAssets) {
|
|
25
|
+
assetHits = this.mapToAssetHits(limited);
|
|
26
|
+
}
|
|
27
|
+
return { hits, assetHits };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const label = this.config.name ?? "skills.sh";
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { hits: [], warnings: [`Registry ${label}: ${message}`] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async fetchSkills(query, limit) {
|
|
36
|
+
// Check per-query cache first
|
|
37
|
+
const cachePath = this.queryCachePath(query, limit);
|
|
38
|
+
const cached = this.readQueryCache(cachePath);
|
|
39
|
+
if (cached && !isExpired(cached.mtime, QUERY_CACHE_TTL_MS)) {
|
|
40
|
+
return cached.entries;
|
|
41
|
+
}
|
|
42
|
+
// Fetch from API
|
|
43
|
+
const baseUrl = this.config.url.replace(/\/+$/, "");
|
|
44
|
+
const url = `${baseUrl}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetchWithRetry(url, undefined, { timeout: 10_000, retries: 1 });
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`HTTP ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
const data = (await response.json());
|
|
51
|
+
const entries = parseSkillsResponse(data);
|
|
52
|
+
this.writeQueryCache(cachePath, entries);
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Fall back to stale cache if available
|
|
57
|
+
if (cached && !isExpired(cached.mtime, QUERY_CACHE_STALE_MS)) {
|
|
58
|
+
return cached.entries;
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
mapToHits(entries) {
|
|
64
|
+
if (entries.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
// Assign decreasing synthetic scores for merge compatibility
|
|
67
|
+
const maxInstalls = Math.max(...entries.map((e) => e.installs), 1);
|
|
68
|
+
const registryName = this.config.name ?? "skills.sh";
|
|
69
|
+
const baseUrl = this.config.url.replace(/\/+$/, "");
|
|
70
|
+
return entries.map((entry) => {
|
|
71
|
+
const owner = entry.source.split("/")[0] ?? "";
|
|
72
|
+
const score = Math.round((entry.installs / maxInstalls) * 1000) / 1000;
|
|
73
|
+
return {
|
|
74
|
+
source: "github",
|
|
75
|
+
id: `skills-sh:${entry.id}`,
|
|
76
|
+
title: entry.name,
|
|
77
|
+
ref: entry.source,
|
|
78
|
+
installRef: `github:${entry.source}`,
|
|
79
|
+
homepage: `${baseUrl}/${entry.id}`,
|
|
80
|
+
score,
|
|
81
|
+
metadata: {
|
|
82
|
+
installs: String(entry.installs),
|
|
83
|
+
...(owner ? { author: owner } : {}),
|
|
84
|
+
},
|
|
85
|
+
registryName,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
mapToAssetHits(entries) {
|
|
90
|
+
if (entries.length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
const registryName = this.config.name ?? "skills.sh";
|
|
93
|
+
const maxInstalls = Math.max(...entries.map((e) => e.installs), 1);
|
|
94
|
+
const hits = entries.map((entry) => ({
|
|
95
|
+
type: "registry-asset",
|
|
96
|
+
assetType: "skill",
|
|
97
|
+
assetName: entry.name,
|
|
98
|
+
kit: { id: `skills-sh:${entry.id}`, name: entry.name },
|
|
99
|
+
registryName,
|
|
100
|
+
action: `akm add ${entry.source}`,
|
|
101
|
+
score: Math.round((entry.installs / maxInstalls) * 1000) / 1000,
|
|
102
|
+
}));
|
|
103
|
+
return hits.length > 0 ? hits : undefined;
|
|
104
|
+
}
|
|
105
|
+
// ── Per-query cache ─────────────────────────────────────────────────────
|
|
106
|
+
queryCachePath(query, limit) {
|
|
107
|
+
const cacheDir = getRegistryIndexCacheDir();
|
|
108
|
+
const hasher = new Bun.CryptoHasher("md5");
|
|
109
|
+
hasher.update(this.config.url);
|
|
110
|
+
hasher.update("\0");
|
|
111
|
+
hasher.update(query.trim().toLowerCase());
|
|
112
|
+
hasher.update("\0");
|
|
113
|
+
hasher.update(String(limit));
|
|
114
|
+
const hash = hasher.digest("hex");
|
|
115
|
+
return path.join(cacheDir, `skills-sh-search-${hash}.json`);
|
|
116
|
+
}
|
|
117
|
+
readQueryCache(cachePath) {
|
|
118
|
+
try {
|
|
119
|
+
const stat = fs.statSync(cachePath);
|
|
120
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
121
|
+
if (!Array.isArray(raw))
|
|
122
|
+
return null;
|
|
123
|
+
const entries = raw.filter(isValidSkillsEntry);
|
|
124
|
+
return { entries, mtime: stat.mtimeMs };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
writeQueryCache(cachePath, entries) {
|
|
131
|
+
try {
|
|
132
|
+
const dir = path.dirname(cachePath);
|
|
133
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}`;
|
|
135
|
+
fs.writeFileSync(tmpPath, JSON.stringify(entries), "utf8");
|
|
136
|
+
fs.renameSync(tmpPath, cachePath);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Best-effort caching
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── Self-register ───────────────────────────────────────────────────────────
|
|
144
|
+
registerProvider("skills-sh", (config) => new SkillsShProvider(config));
|
|
145
|
+
// ── Response parsing ────────────────────────────────────────────────────────
|
|
146
|
+
function parseSkillsResponse(data) {
|
|
147
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
148
|
+
return [];
|
|
149
|
+
const obj = data;
|
|
150
|
+
if (!Array.isArray(obj.skills))
|
|
151
|
+
return [];
|
|
152
|
+
return obj.skills.filter(isValidSkillsEntry);
|
|
153
|
+
}
|
|
154
|
+
function isValidSkillsEntry(entry) {
|
|
155
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
156
|
+
return false;
|
|
157
|
+
const obj = entry;
|
|
158
|
+
return (typeof obj.id === "string" &&
|
|
159
|
+
typeof obj.name === "string" &&
|
|
160
|
+
typeof obj.installs === "number" &&
|
|
161
|
+
typeof obj.source === "string");
|
|
162
|
+
}
|
|
163
|
+
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
164
|
+
function isExpired(mtimeMs, ttlMs) {
|
|
165
|
+
return Date.now() - mtimeMs > ttlMs;
|
|
166
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fetchWithRetry } from "../common";
|
|
4
|
+
import { getRegistryIndexCacheDir } from "../paths";
|
|
5
|
+
import { registerProvider } from "../provider-registry";
|
|
6
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
7
|
+
/** Cache TTL in milliseconds (1 hour). */
|
|
8
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
/** Maximum age before cache is considered stale but still usable as fallback (7 days). */
|
|
10
|
+
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
// ── Provider class ──────────────────────────────────────────────────────────
|
|
12
|
+
class StaticIndexProvider {
|
|
13
|
+
type = "static-index";
|
|
14
|
+
config;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
async search(options) {
|
|
19
|
+
const warnings = [];
|
|
20
|
+
const allKits = [];
|
|
21
|
+
try {
|
|
22
|
+
const index = await loadIndex(this.config);
|
|
23
|
+
if (index) {
|
|
24
|
+
const regName = this.config.name;
|
|
25
|
+
for (const kit of index.kits) {
|
|
26
|
+
allKits.push({ kit, registryName: regName });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const label = this.config.name ? `${this.config.name} (${this.config.url})` : this.config.url;
|
|
32
|
+
warnings.push(`Registry ${label}: ${toErrorMessage(err)}`);
|
|
33
|
+
}
|
|
34
|
+
const hits = scoreKits(allKits, options.query, options.limit);
|
|
35
|
+
let assetHits;
|
|
36
|
+
if (options.includeAssets) {
|
|
37
|
+
const scored = scoreAssets(allKits, options.query, options.limit);
|
|
38
|
+
if (scored.length > 0)
|
|
39
|
+
assetHits = scored;
|
|
40
|
+
}
|
|
41
|
+
return { hits, assetHits, warnings: warnings.length > 0 ? warnings : undefined };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Self-register ───────────────────────────────────────────────────────────
|
|
45
|
+
registerProvider("static-index", (config) => new StaticIndexProvider(config));
|
|
46
|
+
// ── Index loading with cache ────────────────────────────────────────────────
|
|
47
|
+
async function loadIndex(entry) {
|
|
48
|
+
const cachePath = indexCachePath(entry.url);
|
|
49
|
+
const cached = readCachedIndex(cachePath);
|
|
50
|
+
// Fresh cache: return immediately
|
|
51
|
+
if (cached && !isCacheExpired(cached.mtime)) {
|
|
52
|
+
return cached.index;
|
|
53
|
+
}
|
|
54
|
+
// Try to fetch fresh index
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetchWithRetry(entry.url, undefined, { timeout: 10_000 });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`HTTP ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
const data = (await response.json());
|
|
61
|
+
const index = parseRegistryIndex(data);
|
|
62
|
+
if (index) {
|
|
63
|
+
writeCachedIndex(cachePath, index);
|
|
64
|
+
return index;
|
|
65
|
+
}
|
|
66
|
+
throw new Error("Invalid registry index format");
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// Fetch failed — use stale cache if available
|
|
70
|
+
if (cached && !isCacheStale(cached.mtime)) {
|
|
71
|
+
return cached.index;
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// ── Cache helpers (exported for reuse by other providers) ───────────────────
|
|
77
|
+
export function indexCachePath(url) {
|
|
78
|
+
const indexDir = getRegistryIndexCacheDir();
|
|
79
|
+
// Deterministic filename from URL
|
|
80
|
+
const slug = url
|
|
81
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
82
|
+
.replace(/^-+|-+$/g, "")
|
|
83
|
+
.slice(0, 120);
|
|
84
|
+
return path.join(indexDir, `${slug}.json`);
|
|
85
|
+
}
|
|
86
|
+
export function readCachedIndex(cachePath) {
|
|
87
|
+
try {
|
|
88
|
+
const stat = fs.statSync(cachePath);
|
|
89
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
90
|
+
const index = parseRegistryIndex(raw);
|
|
91
|
+
if (!index)
|
|
92
|
+
return null;
|
|
93
|
+
return { index, mtime: stat.mtimeMs };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function writeCachedIndex(cachePath, index) {
|
|
100
|
+
try {
|
|
101
|
+
const dir = path.dirname(cachePath);
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
const tmpPath = `${cachePath}.tmp.${process.pid}`;
|
|
104
|
+
fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8");
|
|
105
|
+
fs.renameSync(tmpPath, cachePath);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best-effort caching — don't fail the search if we can't write
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function isCacheExpired(mtimeMs) {
|
|
112
|
+
return Date.now() - mtimeMs > CACHE_TTL_MS;
|
|
113
|
+
}
|
|
114
|
+
export function isCacheStale(mtimeMs) {
|
|
115
|
+
return Date.now() - mtimeMs > CACHE_STALE_MS;
|
|
116
|
+
}
|
|
117
|
+
// ── Index parsing (exported for reuse) ──────────────────────────────────────
|
|
118
|
+
export function parseRegistryIndex(data) {
|
|
119
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
120
|
+
return null;
|
|
121
|
+
const obj = data;
|
|
122
|
+
if (typeof obj.version !== "number" || (obj.version !== 1 && obj.version !== 2))
|
|
123
|
+
return null;
|
|
124
|
+
if (typeof obj.updatedAt !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
if (!Array.isArray(obj.kits))
|
|
127
|
+
return null;
|
|
128
|
+
const kits = obj.kits.flatMap((raw) => {
|
|
129
|
+
const kit = parseKitEntry(raw);
|
|
130
|
+
return kit ? [kit] : [];
|
|
131
|
+
});
|
|
132
|
+
return { version: obj.version, updatedAt: obj.updatedAt, kits };
|
|
133
|
+
}
|
|
134
|
+
// ── Kit entry parsing ───────────────────────────────────────────────────────
|
|
135
|
+
function parseKitEntry(raw) {
|
|
136
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
137
|
+
return null;
|
|
138
|
+
const obj = raw;
|
|
139
|
+
const id = asString(obj.id);
|
|
140
|
+
const name = asString(obj.name);
|
|
141
|
+
const ref = asString(obj.ref);
|
|
142
|
+
const source = asSource(obj.source);
|
|
143
|
+
if (!id || !name || !ref || !source)
|
|
144
|
+
return null;
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
name,
|
|
148
|
+
ref,
|
|
149
|
+
source,
|
|
150
|
+
description: asString(obj.description),
|
|
151
|
+
homepage: asString(obj.homepage),
|
|
152
|
+
tags: asStringArray(obj.tags),
|
|
153
|
+
assetTypes: asStringArray(obj.assetTypes),
|
|
154
|
+
assets: parseAssets(obj.assets),
|
|
155
|
+
author: asString(obj.author),
|
|
156
|
+
license: asString(obj.license),
|
|
157
|
+
latestVersion: asString(obj.latestVersion),
|
|
158
|
+
curated: obj.curated === true ? true : undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// ── Scoring ─────────────────────────────────────────────────────────────────
|
|
162
|
+
function scoreKits(kits, query, limit) {
|
|
163
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
164
|
+
const scored = [];
|
|
165
|
+
for (const { kit, registryName } of kits) {
|
|
166
|
+
const score = scoreKit(kit, tokens);
|
|
167
|
+
if (score > 0) {
|
|
168
|
+
scored.push({ kit, registryName, score });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
scored.sort((a, b) => b.score - a.score);
|
|
172
|
+
return scored.slice(0, limit).map(({ kit, registryName, score }) => toSearchHit(kit, score, registryName));
|
|
173
|
+
}
|
|
174
|
+
function scoreKit(kit, tokens) {
|
|
175
|
+
let score = 0;
|
|
176
|
+
const nameLower = kit.name.toLowerCase();
|
|
177
|
+
const descLower = (kit.description ?? "").toLowerCase();
|
|
178
|
+
const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase());
|
|
179
|
+
for (const token of tokens) {
|
|
180
|
+
// Exact name match is strongest signal
|
|
181
|
+
if (nameLower === token) {
|
|
182
|
+
score += 1.0;
|
|
183
|
+
}
|
|
184
|
+
else if (nameLower.includes(token)) {
|
|
185
|
+
score += 0.6;
|
|
186
|
+
}
|
|
187
|
+
// Tag matches are high-signal (curated keywords)
|
|
188
|
+
if (tagsLower.some((tag) => tag === token)) {
|
|
189
|
+
score += 0.5;
|
|
190
|
+
}
|
|
191
|
+
else if (tagsLower.some((tag) => tag.includes(token))) {
|
|
192
|
+
score += 0.25;
|
|
193
|
+
}
|
|
194
|
+
// Description substring
|
|
195
|
+
if (descLower.includes(token)) {
|
|
196
|
+
score += 0.2;
|
|
197
|
+
}
|
|
198
|
+
// Author match
|
|
199
|
+
if (kit.author?.toLowerCase().includes(token)) {
|
|
200
|
+
score += 0.15;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Normalize by token count so multi-word queries don't inflate scores
|
|
204
|
+
return tokens.length > 0 ? score / tokens.length : 0;
|
|
205
|
+
}
|
|
206
|
+
function toSearchHit(kit, score, registryName) {
|
|
207
|
+
const metadata = {};
|
|
208
|
+
if (kit.latestVersion)
|
|
209
|
+
metadata.version = kit.latestVersion;
|
|
210
|
+
if (kit.author)
|
|
211
|
+
metadata.author = kit.author;
|
|
212
|
+
if (kit.license)
|
|
213
|
+
metadata.license = kit.license;
|
|
214
|
+
if (kit.assetTypes?.length)
|
|
215
|
+
metadata.assetTypes = kit.assetTypes.join(", ");
|
|
216
|
+
return {
|
|
217
|
+
source: kit.source,
|
|
218
|
+
id: kit.id,
|
|
219
|
+
title: kit.name,
|
|
220
|
+
description: kit.description,
|
|
221
|
+
ref: kit.ref,
|
|
222
|
+
installRef: buildInstallRef(kit.source, kit.ref),
|
|
223
|
+
homepage: kit.homepage,
|
|
224
|
+
score: Math.round(score * 1000) / 1000,
|
|
225
|
+
metadata,
|
|
226
|
+
curated: kit.curated,
|
|
227
|
+
registryName,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// ── Asset parsing ───────────────────────────────────────────────────────────
|
|
231
|
+
function parseAssets(raw) {
|
|
232
|
+
if (!Array.isArray(raw))
|
|
233
|
+
return undefined;
|
|
234
|
+
const parsed = raw.flatMap((item) => {
|
|
235
|
+
const entry = parseAssetEntry(item);
|
|
236
|
+
return entry ? [entry] : [];
|
|
237
|
+
});
|
|
238
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
239
|
+
}
|
|
240
|
+
function parseAssetEntry(raw) {
|
|
241
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
242
|
+
return null;
|
|
243
|
+
const obj = raw;
|
|
244
|
+
const type = asString(obj.type);
|
|
245
|
+
const name = asString(obj.name);
|
|
246
|
+
if (!type || !name)
|
|
247
|
+
return null;
|
|
248
|
+
return {
|
|
249
|
+
type,
|
|
250
|
+
name,
|
|
251
|
+
description: asString(obj.description),
|
|
252
|
+
tags: asStringArray(obj.tags),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ── Asset-level scoring ─────────────────────────────────────────────────────
|
|
256
|
+
function scoreAssets(kits, query, limit) {
|
|
257
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
258
|
+
if (tokens.length === 0)
|
|
259
|
+
return [];
|
|
260
|
+
const scored = [];
|
|
261
|
+
for (const { kit, registryName } of kits) {
|
|
262
|
+
if (!kit.assets || kit.assets.length === 0)
|
|
263
|
+
continue;
|
|
264
|
+
const installRef = buildInstallRef(kit.source, kit.ref);
|
|
265
|
+
for (const asset of kit.assets) {
|
|
266
|
+
const score = scoreAsset(asset, tokens);
|
|
267
|
+
if (score > 0) {
|
|
268
|
+
scored.push({
|
|
269
|
+
hit: {
|
|
270
|
+
type: "registry-asset",
|
|
271
|
+
assetType: asset.type,
|
|
272
|
+
assetName: asset.name,
|
|
273
|
+
description: asset.description,
|
|
274
|
+
kit: { id: kit.id, name: kit.name },
|
|
275
|
+
registryName,
|
|
276
|
+
action: `akm add ${installRef}`,
|
|
277
|
+
score: Math.round(score * 1000) / 1000,
|
|
278
|
+
},
|
|
279
|
+
score,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
scored.sort((a, b) => b.score - a.score);
|
|
285
|
+
return scored.slice(0, limit).map(({ hit }) => hit);
|
|
286
|
+
}
|
|
287
|
+
function scoreAsset(asset, tokens) {
|
|
288
|
+
let score = 0;
|
|
289
|
+
const nameLower = asset.name.toLowerCase();
|
|
290
|
+
const descLower = (asset.description ?? "").toLowerCase();
|
|
291
|
+
const tagsLower = (asset.tags ?? []).map((t) => t.toLowerCase());
|
|
292
|
+
const typeLower = asset.type.toLowerCase();
|
|
293
|
+
for (const token of tokens) {
|
|
294
|
+
if (nameLower === token) {
|
|
295
|
+
score += 1.0;
|
|
296
|
+
}
|
|
297
|
+
else if (nameLower.includes(token)) {
|
|
298
|
+
score += 0.6;
|
|
299
|
+
}
|
|
300
|
+
if (typeLower === token) {
|
|
301
|
+
score += 0.4;
|
|
302
|
+
}
|
|
303
|
+
else if (typeLower.includes(token)) {
|
|
304
|
+
score += 0.2;
|
|
305
|
+
}
|
|
306
|
+
if (tagsLower.some((tag) => tag === token)) {
|
|
307
|
+
score += 0.5;
|
|
308
|
+
}
|
|
309
|
+
else if (tagsLower.some((tag) => tag.includes(token))) {
|
|
310
|
+
score += 0.25;
|
|
311
|
+
}
|
|
312
|
+
if (descLower.includes(token)) {
|
|
313
|
+
score += 0.2;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return tokens.length > 0 ? score / tokens.length : 0;
|
|
317
|
+
}
|
|
318
|
+
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
319
|
+
function asString(value) {
|
|
320
|
+
return typeof value === "string" && value ? value : undefined;
|
|
321
|
+
}
|
|
322
|
+
function asSource(value) {
|
|
323
|
+
if (value === "npm" || value === "github" || value === "git" || value === "local")
|
|
324
|
+
return value;
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
function asStringArray(value) {
|
|
328
|
+
if (!Array.isArray(value))
|
|
329
|
+
return undefined;
|
|
330
|
+
const filtered = value.filter((v) => typeof v === "string");
|
|
331
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
332
|
+
}
|
|
333
|
+
function buildInstallRef(source, ref) {
|
|
334
|
+
switch (source) {
|
|
335
|
+
case "npm":
|
|
336
|
+
return `npm:${ref}`;
|
|
337
|
+
case "git":
|
|
338
|
+
return `git+${ref}`;
|
|
339
|
+
case "local":
|
|
340
|
+
return ref;
|
|
341
|
+
default:
|
|
342
|
+
return `github:${ref}`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function toErrorMessage(error) {
|
|
346
|
+
return error instanceof Error ? error.message : String(error);
|
|
347
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/registry-resolve.js
CHANGED
|
@@ -9,6 +9,12 @@ export function parseRegistryRef(rawRef) {
|
|
|
9
9
|
const ref = rawRef.trim();
|
|
10
10
|
if (!ref)
|
|
11
11
|
throw new Error("Registry ref is required.");
|
|
12
|
+
// Detect registry search result IDs (e.g. "skills-sh:org/skills/name")
|
|
13
|
+
// that are not installable refs. Known installable prefixes are handled below.
|
|
14
|
+
const registryIdHint = detectRegistrySearchId(ref);
|
|
15
|
+
if (registryIdHint) {
|
|
16
|
+
throw new Error(registryIdHint);
|
|
17
|
+
}
|
|
12
18
|
if (ref.startsWith("npm:")) {
|
|
13
19
|
return parseNpmRef(ref.slice(4), ref);
|
|
14
20
|
}
|
|
@@ -33,6 +39,37 @@ export function parseRegistryRef(rawRef) {
|
|
|
33
39
|
}
|
|
34
40
|
return parseGithubShorthand(ref, ref);
|
|
35
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Known prefixes that `parseRegistryRef` handles as installable sources.
|
|
44
|
+
* Anything with a colon that doesn't start with one of these is likely a
|
|
45
|
+
* registry search result ID (e.g. `skills-sh:org/skills/name`).
|
|
46
|
+
*/
|
|
47
|
+
const KNOWN_PREFIXES = ["npm:", "github:", "git+", "file:", "http://", "https://"];
|
|
48
|
+
function detectRegistrySearchId(ref) {
|
|
49
|
+
const colonIdx = ref.indexOf(":");
|
|
50
|
+
if (colonIdx < 1)
|
|
51
|
+
return undefined;
|
|
52
|
+
// Skip known installable prefixes
|
|
53
|
+
for (const prefix of KNOWN_PREFIXES) {
|
|
54
|
+
if (ref.startsWith(prefix))
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const prefix = ref.slice(0, colonIdx);
|
|
58
|
+
// Registry IDs use lowercase-with-hyphens prefixes (e.g. skills-sh, static-index)
|
|
59
|
+
if (!/^[a-z][a-z0-9-]*$/.test(prefix))
|
|
60
|
+
return undefined;
|
|
61
|
+
const rest = ref.slice(colonIdx + 1);
|
|
62
|
+
return [
|
|
63
|
+
`"${ref}" looks like a registry search result ID, not an installable ref.`,
|
|
64
|
+
`The "${prefix}:" prefix is a registry identifier and cannot be passed to \`akm add\`.`,
|
|
65
|
+
"",
|
|
66
|
+
"Use the installRef or ref field from the search result instead. For example:",
|
|
67
|
+
` akm registry search "${rest}" --format json`,
|
|
68
|
+
"Then install using the installRef value from the result:",
|
|
69
|
+
" akm add github:owner/repo",
|
|
70
|
+
" akm add npm:package-name",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
36
73
|
export async function resolveRegistryArtifact(parsed) {
|
|
37
74
|
if (parsed.source === "npm") {
|
|
38
75
|
return resolveNpmArtifact(parsed);
|
package/dist/registry-search.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fetchWithRetry } from "./common";
|
|
4
1
|
import { DEFAULT_CONFIG, loadConfig } from "./config";
|
|
5
|
-
import {
|
|
6
|
-
// ──
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/** Maximum age before cache is considered stale but still usable as fallback (7 days). */
|
|
10
|
-
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
|
+
import { resolveProviderFactory } from "./provider-registry";
|
|
3
|
+
// ── Eagerly import providers to trigger self-registration ───────────────────
|
|
4
|
+
import "./providers/static-index";
|
|
5
|
+
import "./providers/skills-sh";
|
|
11
6
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
12
7
|
export async function searchRegistry(query, options) {
|
|
13
8
|
const trimmed = query.trim();
|
|
@@ -15,33 +10,45 @@ export async function searchRegistry(query, options) {
|
|
|
15
10
|
return { query: "", hits: [], warnings: [] };
|
|
16
11
|
}
|
|
17
12
|
const limit = clampLimit(options?.limit);
|
|
18
|
-
|
|
13
|
+
// resolveRegistries() already filters by enabled; explicit registries are filtered here
|
|
14
|
+
const raw = options?.registries ?? resolveRegistries();
|
|
15
|
+
const entries = options?.registries ? raw.filter((r) => r.enabled !== false) : raw;
|
|
19
16
|
const warnings = [];
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
warnings.push(`Registry ${label}: ${toErrorMessage(err)}`);
|
|
17
|
+
// Resolve and search all providers concurrently
|
|
18
|
+
const results = await Promise.allSettled(entries.map((entry) => {
|
|
19
|
+
const provider = createProvider(entry, warnings);
|
|
20
|
+
if (!provider)
|
|
21
|
+
return Promise.resolve(null);
|
|
22
|
+
return provider.search({ query: trimmed, limit, includeAssets: options?.includeAssets });
|
|
23
|
+
}));
|
|
24
|
+
// Merge results grouped by provider
|
|
25
|
+
const allHits = [];
|
|
26
|
+
const allAssetHits = [];
|
|
27
|
+
for (const result of results) {
|
|
28
|
+
if (result.status === "rejected") {
|
|
29
|
+
warnings.push(toErrorMessage(result.reason));
|
|
30
|
+
continue;
|
|
35
31
|
}
|
|
32
|
+
const value = result.value;
|
|
33
|
+
if (!value)
|
|
34
|
+
continue;
|
|
35
|
+
allHits.push(...value.hits);
|
|
36
|
+
if (value.assetHits)
|
|
37
|
+
allAssetHits.push(...value.assetHits);
|
|
38
|
+
if (value.warnings)
|
|
39
|
+
warnings.push(...value.warnings);
|
|
36
40
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// Sort merged hits by score descending, apply limit
|
|
42
|
+
allHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
43
|
+
const limitedHits = allHits.slice(0, limit);
|
|
44
|
+
allAssetHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
45
|
+
const limitedAssetHits = allAssetHits.slice(0, limit);
|
|
46
|
+
return {
|
|
47
|
+
query: trimmed,
|
|
48
|
+
hits: limitedHits,
|
|
49
|
+
warnings,
|
|
50
|
+
assetHits: limitedAssetHits.length > 0 ? limitedAssetHits : undefined,
|
|
51
|
+
};
|
|
45
52
|
}
|
|
46
53
|
// ── Registry resolution ─────────────────────────────────────────────────────
|
|
47
54
|
/**
|
|
@@ -65,280 +72,16 @@ export function resolveRegistries(configRegistries) {
|
|
|
65
72
|
const registries = configRegistries ?? loadConfig().registries ?? DEFAULT_CONFIG.registries ?? [];
|
|
66
73
|
return registries.filter((r) => r.enabled !== false);
|
|
67
74
|
}
|
|
68
|
-
// ──
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
// Try to fetch fresh index
|
|
77
|
-
try {
|
|
78
|
-
const response = await fetchWithRetry(entry.url, undefined, { timeout: 10_000 });
|
|
79
|
-
if (!response.ok) {
|
|
80
|
-
throw new Error(`HTTP ${response.status}`);
|
|
81
|
-
}
|
|
82
|
-
const data = (await response.json());
|
|
83
|
-
const index = parseRegistryIndex(data);
|
|
84
|
-
if (index) {
|
|
85
|
-
writeCachedIndex(cachePath, index);
|
|
86
|
-
return index;
|
|
87
|
-
}
|
|
88
|
-
throw new Error("Invalid registry index format");
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
// Fetch failed — use stale cache if available
|
|
92
|
-
if (cached && !isCacheStale(cached.mtime)) {
|
|
93
|
-
return cached.index;
|
|
94
|
-
}
|
|
95
|
-
throw err;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
function readCachedIndex(cachePath) {
|
|
99
|
-
try {
|
|
100
|
-
const stat = fs.statSync(cachePath);
|
|
101
|
-
const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
102
|
-
const index = parseRegistryIndex(raw);
|
|
103
|
-
if (!index)
|
|
104
|
-
return null;
|
|
105
|
-
return { index, mtime: stat.mtimeMs };
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
function writeCachedIndex(cachePath, index) {
|
|
112
|
-
try {
|
|
113
|
-
const dir = path.dirname(cachePath);
|
|
114
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
115
|
-
const tmpPath = `${cachePath}.tmp.${process.pid}`;
|
|
116
|
-
fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8");
|
|
117
|
-
fs.renameSync(tmpPath, cachePath);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
// Best-effort caching — don't fail the search if we can't write
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
function indexCachePath(url) {
|
|
124
|
-
const indexDir = getRegistryIndexCacheDir();
|
|
125
|
-
// Deterministic filename from URL
|
|
126
|
-
const slug = url
|
|
127
|
-
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
128
|
-
.replace(/^-+|-+$/g, "")
|
|
129
|
-
.slice(0, 120);
|
|
130
|
-
return path.join(indexDir, `${slug}.json`);
|
|
131
|
-
}
|
|
132
|
-
function isCacheExpired(mtimeMs) {
|
|
133
|
-
return Date.now() - mtimeMs > CACHE_TTL_MS;
|
|
134
|
-
}
|
|
135
|
-
function isCacheStale(mtimeMs) {
|
|
136
|
-
return Date.now() - mtimeMs > CACHE_STALE_MS;
|
|
137
|
-
}
|
|
138
|
-
// ── Index parsing ───────────────────────────────────────────────────────────
|
|
139
|
-
function parseRegistryIndex(data) {
|
|
140
|
-
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
141
|
-
return null;
|
|
142
|
-
const obj = data;
|
|
143
|
-
if (typeof obj.version !== "number" || (obj.version !== 1 && obj.version !== 2))
|
|
144
|
-
return null;
|
|
145
|
-
if (typeof obj.updatedAt !== "string")
|
|
146
|
-
return null;
|
|
147
|
-
if (!Array.isArray(obj.kits))
|
|
148
|
-
return null;
|
|
149
|
-
const kits = obj.kits.flatMap((raw) => {
|
|
150
|
-
const kit = parseKitEntry(raw);
|
|
151
|
-
return kit ? [kit] : [];
|
|
152
|
-
});
|
|
153
|
-
return { version: obj.version, updatedAt: obj.updatedAt, kits };
|
|
154
|
-
}
|
|
155
|
-
function parseKitEntry(raw) {
|
|
156
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
157
|
-
return null;
|
|
158
|
-
const obj = raw;
|
|
159
|
-
const id = asString(obj.id);
|
|
160
|
-
const name = asString(obj.name);
|
|
161
|
-
const ref = asString(obj.ref);
|
|
162
|
-
const source = asSource(obj.source);
|
|
163
|
-
if (!id || !name || !ref || !source)
|
|
164
|
-
return null;
|
|
165
|
-
return {
|
|
166
|
-
id,
|
|
167
|
-
name,
|
|
168
|
-
ref,
|
|
169
|
-
source,
|
|
170
|
-
description: asString(obj.description),
|
|
171
|
-
homepage: asString(obj.homepage),
|
|
172
|
-
tags: asStringArray(obj.tags),
|
|
173
|
-
assetTypes: asStringArray(obj.assetTypes),
|
|
174
|
-
assets: parseAssets(obj.assets),
|
|
175
|
-
author: asString(obj.author),
|
|
176
|
-
license: asString(obj.license),
|
|
177
|
-
latestVersion: asString(obj.latestVersion),
|
|
178
|
-
curated: obj.curated === true ? true : undefined,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
// ── Scoring ─────────────────────────────────────────────────────────────────
|
|
182
|
-
function scoreKits(kits, query, limit) {
|
|
183
|
-
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
184
|
-
const scored = [];
|
|
185
|
-
for (const { kit, registryName } of kits) {
|
|
186
|
-
const score = scoreKit(kit, tokens);
|
|
187
|
-
if (score > 0) {
|
|
188
|
-
scored.push({ kit, registryName, score });
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
scored.sort((a, b) => b.score - a.score);
|
|
192
|
-
return scored.slice(0, limit).map(({ kit, registryName, score }) => toSearchHit(kit, score, registryName));
|
|
193
|
-
}
|
|
194
|
-
function scoreKit(kit, tokens) {
|
|
195
|
-
let score = 0;
|
|
196
|
-
const nameLower = kit.name.toLowerCase();
|
|
197
|
-
const descLower = (kit.description ?? "").toLowerCase();
|
|
198
|
-
const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase());
|
|
199
|
-
for (const token of tokens) {
|
|
200
|
-
// Exact name match is strongest signal
|
|
201
|
-
if (nameLower === token) {
|
|
202
|
-
score += 1.0;
|
|
203
|
-
}
|
|
204
|
-
else if (nameLower.includes(token)) {
|
|
205
|
-
score += 0.6;
|
|
206
|
-
}
|
|
207
|
-
// Tag matches are high-signal (curated keywords)
|
|
208
|
-
if (tagsLower.some((tag) => tag === token)) {
|
|
209
|
-
score += 0.5;
|
|
210
|
-
}
|
|
211
|
-
else if (tagsLower.some((tag) => tag.includes(token))) {
|
|
212
|
-
score += 0.25;
|
|
213
|
-
}
|
|
214
|
-
// Description substring
|
|
215
|
-
if (descLower.includes(token)) {
|
|
216
|
-
score += 0.2;
|
|
217
|
-
}
|
|
218
|
-
// Author match
|
|
219
|
-
if (kit.author?.toLowerCase().includes(token)) {
|
|
220
|
-
score += 0.15;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
// Normalize by token count so multi-word queries don't inflate scores
|
|
224
|
-
return tokens.length > 0 ? score / tokens.length : 0;
|
|
225
|
-
}
|
|
226
|
-
function toSearchHit(kit, score, registryName) {
|
|
227
|
-
const metadata = {};
|
|
228
|
-
if (kit.latestVersion)
|
|
229
|
-
metadata.version = kit.latestVersion;
|
|
230
|
-
if (kit.author)
|
|
231
|
-
metadata.author = kit.author;
|
|
232
|
-
if (kit.license)
|
|
233
|
-
metadata.license = kit.license;
|
|
234
|
-
if (kit.assetTypes?.length)
|
|
235
|
-
metadata.assetTypes = kit.assetTypes.join(", ");
|
|
236
|
-
return {
|
|
237
|
-
source: kit.source,
|
|
238
|
-
id: kit.id,
|
|
239
|
-
title: kit.name,
|
|
240
|
-
description: kit.description,
|
|
241
|
-
ref: kit.ref,
|
|
242
|
-
homepage: kit.homepage,
|
|
243
|
-
score: Math.round(score * 1000) / 1000,
|
|
244
|
-
metadata,
|
|
245
|
-
curated: kit.curated,
|
|
246
|
-
registryName,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
// ── Asset parsing ────────────────────────────────────────────────────────────
|
|
250
|
-
function parseAssets(raw) {
|
|
251
|
-
if (!Array.isArray(raw))
|
|
252
|
-
return undefined;
|
|
253
|
-
const parsed = raw.flatMap((item) => {
|
|
254
|
-
const entry = parseAssetEntry(item);
|
|
255
|
-
return entry ? [entry] : [];
|
|
256
|
-
});
|
|
257
|
-
return parsed.length > 0 ? parsed : undefined;
|
|
258
|
-
}
|
|
259
|
-
function parseAssetEntry(raw) {
|
|
260
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
75
|
+
// ── Provider resolution ─────────────────────────────────────────────────────
|
|
76
|
+
function createProvider(entry, warnings) {
|
|
77
|
+
const providerType = entry.provider ?? "static-index";
|
|
78
|
+
const factory = resolveProviderFactory(providerType);
|
|
79
|
+
if (!factory) {
|
|
80
|
+
const label = entry.name ? `${entry.name} (${entry.url})` : entry.url;
|
|
81
|
+
warnings.push(`Registry ${label}: unknown provider type "${providerType}"`);
|
|
261
82
|
return null;
|
|
262
|
-
const obj = raw;
|
|
263
|
-
const type = asString(obj.type);
|
|
264
|
-
const name = asString(obj.name);
|
|
265
|
-
if (!type || !name)
|
|
266
|
-
return null;
|
|
267
|
-
return {
|
|
268
|
-
type,
|
|
269
|
-
name,
|
|
270
|
-
description: asString(obj.description),
|
|
271
|
-
tags: asStringArray(obj.tags),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
// ── Asset-level scoring ──────────────────────────────────────────────────────
|
|
275
|
-
function scoreAssets(kits, query, limit) {
|
|
276
|
-
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
277
|
-
if (tokens.length === 0)
|
|
278
|
-
return [];
|
|
279
|
-
const scored = [];
|
|
280
|
-
for (const { kit, registryName } of kits) {
|
|
281
|
-
if (!kit.assets || kit.assets.length === 0)
|
|
282
|
-
continue;
|
|
283
|
-
const installRef = kit.source === "npm"
|
|
284
|
-
? `npm:${kit.ref}`
|
|
285
|
-
: kit.source === "git"
|
|
286
|
-
? `git+${kit.ref}`
|
|
287
|
-
: kit.source === "local"
|
|
288
|
-
? kit.ref
|
|
289
|
-
: `github:${kit.ref}`;
|
|
290
|
-
for (const asset of kit.assets) {
|
|
291
|
-
const score = scoreAsset(asset, tokens);
|
|
292
|
-
if (score > 0) {
|
|
293
|
-
scored.push({
|
|
294
|
-
hit: {
|
|
295
|
-
type: "registry-asset",
|
|
296
|
-
assetType: asset.type,
|
|
297
|
-
assetName: asset.name,
|
|
298
|
-
description: asset.description,
|
|
299
|
-
kit: { id: kit.id, name: kit.name },
|
|
300
|
-
registryName,
|
|
301
|
-
action: `akm add ${installRef}`,
|
|
302
|
-
score: Math.round(score * 1000) / 1000,
|
|
303
|
-
},
|
|
304
|
-
score,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
scored.sort((a, b) => b.score - a.score);
|
|
310
|
-
return scored.slice(0, limit).map(({ hit }) => hit);
|
|
311
|
-
}
|
|
312
|
-
function scoreAsset(asset, tokens) {
|
|
313
|
-
let score = 0;
|
|
314
|
-
const nameLower = asset.name.toLowerCase();
|
|
315
|
-
const descLower = (asset.description ?? "").toLowerCase();
|
|
316
|
-
const tagsLower = (asset.tags ?? []).map((t) => t.toLowerCase());
|
|
317
|
-
const typeLower = asset.type.toLowerCase();
|
|
318
|
-
for (const token of tokens) {
|
|
319
|
-
if (nameLower === token) {
|
|
320
|
-
score += 1.0;
|
|
321
|
-
}
|
|
322
|
-
else if (nameLower.includes(token)) {
|
|
323
|
-
score += 0.6;
|
|
324
|
-
}
|
|
325
|
-
if (typeLower === token) {
|
|
326
|
-
score += 0.4;
|
|
327
|
-
}
|
|
328
|
-
else if (typeLower.includes(token)) {
|
|
329
|
-
score += 0.2;
|
|
330
|
-
}
|
|
331
|
-
if (tagsLower.some((tag) => tag === token)) {
|
|
332
|
-
score += 0.5;
|
|
333
|
-
}
|
|
334
|
-
else if (tagsLower.some((tag) => tag.includes(token))) {
|
|
335
|
-
score += 0.25;
|
|
336
|
-
}
|
|
337
|
-
if (descLower.includes(token)) {
|
|
338
|
-
score += 0.2;
|
|
339
|
-
}
|
|
340
83
|
}
|
|
341
|
-
return
|
|
84
|
+
return factory(entry);
|
|
342
85
|
}
|
|
343
86
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
344
87
|
function clampLimit(limit) {
|
|
@@ -346,20 +89,6 @@ function clampLimit(limit) {
|
|
|
346
89
|
return 20;
|
|
347
90
|
return Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
348
91
|
}
|
|
349
|
-
function asString(value) {
|
|
350
|
-
return typeof value === "string" && value ? value : undefined;
|
|
351
|
-
}
|
|
352
|
-
function asSource(value) {
|
|
353
|
-
if (value === "npm" || value === "github" || value === "git" || value === "local")
|
|
354
|
-
return value;
|
|
355
|
-
return undefined;
|
|
356
|
-
}
|
|
357
|
-
function asStringArray(value) {
|
|
358
|
-
if (!Array.isArray(value))
|
|
359
|
-
return undefined;
|
|
360
|
-
const filtered = value.filter((v) => typeof v === "string");
|
|
361
|
-
return filtered.length > 0 ? filtered : undefined;
|
|
362
|
-
}
|
|
363
92
|
function toErrorMessage(error) {
|
|
364
93
|
return error instanceof Error ? error.message : String(error);
|
|
365
94
|
}
|