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 CHANGED
@@ -1,4 +1,6 @@
1
- # akm -- the Agent-i-Kit Manager
1
+ # Agent Kit Manager
2
+
3
+ > Agent-i-Kit
2
4
 
3
5
  [![npm version](https://img.shields.io/npm/v/akm-cli)](https://www.npmjs.com/package/akm-cli)
4
6
  [![CI](https://github.com/itlackey/agentikit/actions/workflows/ci.yml/badge.svg)](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
- ## Using With Any AI Agent
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
  }
@@ -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 {};
@@ -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);
@@ -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 { getRegistryIndexCacheDir } from "./paths";
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;
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
- const entries = (options?.registries ?? resolveRegistries()).filter((r) => r.enabled !== false);
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
- // Load index from all configured registries, merge kits
21
- const allKits = [];
22
- for (const entry of entries) {
23
- try {
24
- const index = await loadIndex(entry);
25
- if (index) {
26
- const regName = entry.name;
27
- for (const kit of index.kits) {
28
- allKits.push({ kit, registryName: regName });
29
- }
30
- }
31
- }
32
- catch (err) {
33
- const label = entry.name ? `${entry.name} (${entry.url})` : entry.url;
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
- // Score and rank
38
- const hits = scoreKits(allKits, trimmed, limit);
39
- // When includeAssets is enabled, also search asset-level metadata
40
- let assetHits = [];
41
- if (options?.includeAssets) {
42
- assetHits = scoreAssets(allKits, trimmed, limit);
43
- }
44
- return { query: trimmed, hits, warnings, assetHits: assetHits.length > 0 ? assetHits : undefined };
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
- // ── Index loading with cache ────────────────────────────────────────────────
69
- async function loadIndex(entry) {
70
- const cachePath = indexCachePath(entry.url);
71
- const cached = readCachedIndex(cachePath);
72
- // Fresh cache: return immediately
73
- if (cached && !isCacheExpired(cached.mtime)) {
74
- return cached.index;
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 tokens.length > 0 ? score / tokens.length : 0;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "description": "CLI tool to search, open, and run extension assets from an akm stash directory.",
6
6
  "keywords": [