agentikit 0.0.8 → 0.0.12

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.
Files changed (112) hide show
  1. package/README.md +135 -117
  2. package/dist/index.d.ts +13 -3
  3. package/dist/index.js +7 -1
  4. package/dist/src/asset-spec.d.ts +2 -0
  5. package/dist/src/asset-spec.js +22 -3
  6. package/dist/src/asset-type-handler.d.ts +27 -0
  7. package/dist/src/asset-type-handler.js +33 -0
  8. package/dist/src/cli.js +335 -100
  9. package/dist/src/common.d.ts +6 -1
  10. package/dist/src/common.js +18 -4
  11. package/dist/src/config-cli.d.ts +9 -0
  12. package/dist/src/config-cli.js +473 -0
  13. package/dist/src/config.d.ts +25 -6
  14. package/dist/src/config.js +188 -28
  15. package/dist/src/db.d.ts +46 -0
  16. package/dist/src/db.js +299 -0
  17. package/dist/src/embedder.js +12 -7
  18. package/dist/src/github.d.ts +4 -0
  19. package/dist/src/github.js +19 -0
  20. package/dist/src/handlers/agent-handler.d.ts +2 -0
  21. package/dist/src/handlers/agent-handler.js +26 -0
  22. package/dist/src/handlers/command-handler.d.ts +2 -0
  23. package/dist/src/handlers/command-handler.js +23 -0
  24. package/dist/src/handlers/index.d.ts +6 -0
  25. package/dist/src/handlers/index.js +23 -0
  26. package/dist/src/handlers/knowledge-handler.d.ts +2 -0
  27. package/dist/src/handlers/knowledge-handler.js +56 -0
  28. package/dist/src/handlers/markdown-helpers.d.ts +7 -0
  29. package/dist/src/handlers/markdown-helpers.js +15 -0
  30. package/dist/src/handlers/script-handler.d.ts +2 -0
  31. package/dist/src/handlers/script-handler.js +78 -0
  32. package/dist/src/handlers/skill-handler.d.ts +2 -0
  33. package/dist/src/handlers/skill-handler.js +30 -0
  34. package/dist/src/handlers/tool-handler.d.ts +2 -0
  35. package/dist/src/handlers/tool-handler.js +58 -0
  36. package/dist/src/indexer.d.ts +1 -23
  37. package/dist/src/indexer.js +162 -155
  38. package/dist/src/init.d.ts +2 -2
  39. package/dist/src/init.js +21 -9
  40. package/dist/src/llm.js +4 -3
  41. package/dist/src/metadata.d.ts +1 -1
  42. package/dist/src/metadata.js +22 -64
  43. package/dist/src/origin-resolve.d.ts +19 -0
  44. package/dist/src/origin-resolve.js +53 -0
  45. package/dist/src/registry-install.d.ts +11 -0
  46. package/dist/src/registry-install.js +315 -0
  47. package/dist/src/registry-resolve.d.ts +3 -0
  48. package/dist/src/registry-resolve.js +299 -0
  49. package/dist/src/registry-search.d.ts +27 -0
  50. package/dist/src/registry-search.js +263 -0
  51. package/dist/src/registry-types.d.ts +62 -0
  52. package/dist/src/registry-types.js +1 -0
  53. package/dist/src/stash-add.d.ts +4 -0
  54. package/dist/src/stash-add.js +59 -0
  55. package/dist/src/stash-clone.d.ts +22 -0
  56. package/dist/src/stash-clone.js +83 -0
  57. package/dist/src/stash-ref.d.ts +27 -3
  58. package/dist/src/stash-ref.js +63 -24
  59. package/dist/src/stash-registry.d.ts +18 -0
  60. package/dist/src/stash-registry.js +221 -0
  61. package/dist/src/stash-resolve.js +3 -0
  62. package/dist/src/stash-search.d.ts +3 -1
  63. package/dist/src/stash-search.js +357 -138
  64. package/dist/src/stash-show.d.ts +1 -1
  65. package/dist/src/stash-show.js +28 -89
  66. package/dist/src/stash-source.d.ts +24 -0
  67. package/dist/src/stash-source.js +81 -0
  68. package/dist/src/stash-types.d.ts +175 -1
  69. package/dist/src/stash.d.ts +9 -1
  70. package/dist/src/stash.js +5 -0
  71. package/dist/src/tool-runner.d.ts +1 -1
  72. package/dist/src/tool-runner.js +18 -5
  73. package/package.json +7 -2
  74. package/src/asset-spec.ts +20 -4
  75. package/src/asset-type-handler.ts +77 -0
  76. package/src/cli.ts +354 -103
  77. package/src/common.ts +23 -5
  78. package/src/config-cli.ts +499 -0
  79. package/src/config.ts +218 -37
  80. package/src/db.ts +411 -0
  81. package/src/embedder.ts +22 -11
  82. package/src/github.ts +21 -0
  83. package/src/handlers/agent-handler.ts +32 -0
  84. package/src/handlers/command-handler.ts +29 -0
  85. package/src/handlers/index.ts +25 -0
  86. package/src/handlers/knowledge-handler.ts +62 -0
  87. package/src/handlers/markdown-helpers.ts +19 -0
  88. package/src/handlers/script-handler.ts +92 -0
  89. package/src/handlers/skill-handler.ts +37 -0
  90. package/src/handlers/tool-handler.ts +71 -0
  91. package/src/indexer.ts +208 -187
  92. package/src/init.ts +17 -9
  93. package/src/llm.ts +4 -3
  94. package/src/metadata.ts +21 -65
  95. package/src/origin-resolve.ts +67 -0
  96. package/src/registry-install.ts +361 -0
  97. package/src/registry-resolve.ts +341 -0
  98. package/src/registry-search.ts +335 -0
  99. package/src/registry-types.ts +72 -0
  100. package/src/stash-add.ts +63 -0
  101. package/src/stash-clone.ts +127 -0
  102. package/src/stash-ref.ts +84 -26
  103. package/src/stash-registry.ts +259 -0
  104. package/src/stash-resolve.ts +3 -0
  105. package/src/stash-search.ts +425 -155
  106. package/src/stash-show.ts +33 -82
  107. package/src/stash-source.ts +103 -0
  108. package/src/stash-types.ts +186 -1
  109. package/src/stash.ts +23 -0
  110. package/src/tool-runner.ts +18 -5
  111. package/dist/src/similarity.d.ts +0 -34
  112. package/src/similarity.ts +0 -271
@@ -0,0 +1,341 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import { fetchWithTimeout } from "./common"
6
+ import type { ParsedGitRef, ParsedGithubRef, ParsedNpmRef, ParsedRegistryRef, ResolvedRegistryArtifact } from "./registry-types"
7
+ import { GITHUB_API_BASE, githubHeaders, asRecord, asString } from "./github"
8
+
9
+ export function parseRegistryRef(rawRef: string): ParsedRegistryRef {
10
+ const ref = rawRef.trim()
11
+ if (!ref) throw new Error("Registry ref is required.")
12
+
13
+ if (ref.startsWith("npm:")) {
14
+ return parseNpmRef(ref.slice(4), ref)
15
+ }
16
+ if (ref.startsWith("github:")) {
17
+ return parseGithubShorthand(ref.slice(7), ref)
18
+ }
19
+ if (ref.startsWith("http://") || ref.startsWith("https://")) {
20
+ return parseGithubUrl(ref)
21
+ }
22
+ const localGitRef = tryParseLocalGitRef(ref, isPathLikeRef(ref))
23
+ if (localGitRef) {
24
+ return localGitRef
25
+ }
26
+
27
+ if (ref.startsWith("@") || !looksLikeGithubOwnerRepo(ref)) {
28
+ return parseNpmRef(ref, ref)
29
+ }
30
+
31
+ return parseGithubShorthand(ref, ref)
32
+ }
33
+
34
+ export async function resolveRegistryArtifact(parsed: ParsedRegistryRef): Promise<ResolvedRegistryArtifact> {
35
+ if (parsed.source === "npm") {
36
+ return resolveNpmArtifact(parsed)
37
+ }
38
+ if (parsed.source === "git") {
39
+ return resolveGitArtifact(parsed)
40
+ }
41
+ return resolveGithubArtifact(parsed)
42
+ }
43
+
44
+ function parseNpmRef(input: string, originalRef: string): ParsedNpmRef {
45
+ const trimmed = input.trim()
46
+ if (!trimmed) throw new Error("Invalid npm ref.")
47
+
48
+ const parsed = splitNpmNameAndVersion(trimmed)
49
+ validateNpmPackageName(parsed.packageName)
50
+
51
+ return {
52
+ source: "npm",
53
+ ref: originalRef,
54
+ id: `npm:${parsed.packageName}`,
55
+ packageName: parsed.packageName,
56
+ requestedVersionOrTag: parsed.requestedVersionOrTag,
57
+ }
58
+ }
59
+
60
+ function parseGithubShorthand(input: string, originalRef: string): ParsedGithubRef {
61
+ const [repoPart, requestedRef] = splitRefSuffix(input.trim())
62
+ const segments = repoPart.split("/").filter(Boolean)
63
+ if (segments.length !== 2) {
64
+ throw new Error("Invalid GitHub ref. Expected owner/repo or owner/repo#ref.")
65
+ }
66
+ const owner = segments[0]
67
+ const repo = segments[1].replace(/\.git$/i, "")
68
+ if (!owner || !repo) {
69
+ throw new Error("Invalid GitHub ref. Expected owner/repo.")
70
+ }
71
+ return {
72
+ source: "github",
73
+ ref: originalRef,
74
+ id: `github:${owner}/${repo}`,
75
+ owner,
76
+ repo,
77
+ requestedRef,
78
+ }
79
+ }
80
+
81
+ function parseGithubUrl(rawUrl: string): ParsedGithubRef {
82
+ let url: URL
83
+ try {
84
+ url = new URL(rawUrl)
85
+ } catch {
86
+ throw new Error("Invalid registry URL.")
87
+ }
88
+ if (url.hostname !== "github.com") {
89
+ throw new Error("Only GitHub URLs are currently supported for URL refs.")
90
+ }
91
+
92
+ const segments = url.pathname.split("/").filter(Boolean)
93
+ if (segments.length < 2) {
94
+ throw new Error("Invalid GitHub URL. Expected https://github.com/owner/repo.")
95
+ }
96
+ const owner = segments[0]
97
+ const repo = segments[1].replace(/\.git$/i, "")
98
+ const requestedRef = url.hash ? decodeURIComponent(url.hash.slice(1)) : undefined
99
+
100
+ return {
101
+ source: "github",
102
+ ref: rawUrl,
103
+ id: `github:${owner}/${repo}`,
104
+ owner,
105
+ repo,
106
+ requestedRef,
107
+ }
108
+ }
109
+
110
+ function tryParseLocalGitRef(rawRef: string, explicitPath: boolean): ParsedGitRef | undefined {
111
+ if (!explicitPath) {
112
+ return undefined
113
+ }
114
+
115
+ const resolvedPath = path.resolve(rawRef)
116
+ let stat: fs.Stats
117
+ try {
118
+ stat = fs.statSync(resolvedPath)
119
+ } catch {
120
+ throw new Error(`Local path not found: ${resolvedPath}`)
121
+ }
122
+
123
+ if (!stat.isDirectory()) {
124
+ throw new Error("Local add path must be a directory, but the provided path is not one.")
125
+ }
126
+
127
+ const repoRoot = findGitRepoRoot(resolvedPath)
128
+ if (!repoRoot) {
129
+ throw new Error("Local add path must be inside a git repository.")
130
+ }
131
+
132
+ return {
133
+ source: "git",
134
+ ref: rawRef,
135
+ id: `git:${encodeURIComponent(resolvedPath)}`,
136
+ repoRoot,
137
+ sourcePath: resolvedPath,
138
+ }
139
+ }
140
+
141
+ function isPathLikeRef(ref: string): boolean {
142
+ if (path.isAbsolute(ref)) return true
143
+ if (ref.startsWith("./") || ref.startsWith("../") || ref.startsWith(".\\") || ref.startsWith("..\\")) {
144
+ return true
145
+ }
146
+ return ref.includes("/") || ref.includes("\\")
147
+ }
148
+
149
+ async function resolveNpmArtifact(parsed: ParsedNpmRef): Promise<ResolvedRegistryArtifact> {
150
+ const encodedName = encodeURIComponent(parsed.packageName)
151
+ const metadata = await fetchJson<Record<string, unknown>>(`https://registry.npmjs.org/${encodedName}`)
152
+
153
+ const versions = asRecord(metadata.versions)
154
+ const distTags = asRecord(metadata["dist-tags"])
155
+
156
+ const requested = parsed.requestedVersionOrTag
157
+ let resolvedVersion: string | undefined
158
+ if (!requested) {
159
+ resolvedVersion = asString(distTags.latest)
160
+ } else if (requested in versions) {
161
+ resolvedVersion = requested
162
+ } else {
163
+ resolvedVersion = asString(distTags[requested])
164
+ }
165
+
166
+ if (!resolvedVersion || !(resolvedVersion in versions)) {
167
+ throw new Error(`Unable to resolve npm ref \"${parsed.ref}\".`)
168
+ }
169
+
170
+ const versionMeta = asRecord(versions[resolvedVersion])
171
+ const dist = asRecord(versionMeta.dist)
172
+ const tarballUrl = asString(dist.tarball)
173
+ if (!tarballUrl) {
174
+ throw new Error(`npm package ${parsed.packageName}@${resolvedVersion} does not expose a tarball URL.`)
175
+ }
176
+
177
+ const resolvedRevision = asString(dist.shasum) ?? asString(dist.integrity)
178
+
179
+ return {
180
+ id: parsed.id,
181
+ source: parsed.source,
182
+ ref: parsed.ref,
183
+ artifactUrl: tarballUrl,
184
+ resolvedVersion,
185
+ resolvedRevision,
186
+ }
187
+ }
188
+
189
+ async function resolveGithubArtifact(parsed: ParsedGithubRef): Promise<ResolvedRegistryArtifact> {
190
+ const headers = githubHeaders()
191
+
192
+ if (parsed.requestedRef) {
193
+ const commit = await tryFetchJson<Record<string, unknown>>(
194
+ `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/commits/${encodeURIComponent(parsed.requestedRef)}`,
195
+ headers,
196
+ )
197
+ const resolvedRevision = asString(commit?.sha) ?? parsed.requestedRef
198
+ return {
199
+ id: parsed.id,
200
+ source: parsed.source,
201
+ ref: parsed.ref,
202
+ artifactUrl: `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/tarball/${encodeURIComponent(parsed.requestedRef)}`,
203
+ resolvedRevision,
204
+ resolvedVersion: parsed.requestedRef,
205
+ }
206
+ }
207
+
208
+ const latestRelease = await tryFetchJson<Record<string, unknown>>(
209
+ `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/releases/latest`,
210
+ headers,
211
+ )
212
+ if (latestRelease) {
213
+ const tarballUrl = asString(latestRelease.tarball_url)
214
+ if (tarballUrl) {
215
+ return {
216
+ id: parsed.id,
217
+ source: parsed.source,
218
+ ref: parsed.ref,
219
+ artifactUrl: tarballUrl,
220
+ resolvedVersion: asString(latestRelease.tag_name),
221
+ resolvedRevision: asString(latestRelease.target_commitish),
222
+ }
223
+ }
224
+ }
225
+
226
+ const repoMeta = await fetchJson<Record<string, unknown>>(
227
+ `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`,
228
+ headers,
229
+ )
230
+ const defaultBranch = asString(repoMeta.default_branch)
231
+ if (!defaultBranch) {
232
+ throw new Error(`Unable to resolve default branch for ${parsed.owner}/${parsed.repo}.`)
233
+ }
234
+
235
+ const commit = await tryFetchJson<Record<string, unknown>>(
236
+ `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/commits/${encodeURIComponent(defaultBranch)}`,
237
+ headers,
238
+ )
239
+
240
+ return {
241
+ id: parsed.id,
242
+ source: parsed.source,
243
+ ref: parsed.ref,
244
+ artifactUrl: `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/tarball/${encodeURIComponent(defaultBranch)}`,
245
+ resolvedVersion: defaultBranch,
246
+ resolvedRevision: asString(commit?.sha) ?? defaultBranch,
247
+ }
248
+ }
249
+
250
+ async function resolveGitArtifact(parsed: ParsedGitRef): Promise<ResolvedRegistryArtifact> {
251
+ return {
252
+ id: parsed.id,
253
+ source: parsed.source,
254
+ ref: parsed.ref,
255
+ artifactUrl: pathToFileURL(parsed.sourcePath).toString(),
256
+ resolvedRevision: readGitValue(parsed.repoRoot, "rev-parse", "HEAD"),
257
+ resolvedVersion: readGitValue(parsed.repoRoot, "rev-parse", "--abbrev-ref", "HEAD"),
258
+ }
259
+ }
260
+
261
+ function splitNpmNameAndVersion(input: string): { packageName: string; requestedVersionOrTag?: string } {
262
+ if (input.startsWith("@")) {
263
+ const secondAt = input.indexOf("@", 1)
264
+ if (secondAt > 0) {
265
+ return {
266
+ packageName: input.slice(0, secondAt),
267
+ requestedVersionOrTag: input.slice(secondAt + 1) || undefined,
268
+ }
269
+ }
270
+ return { packageName: input }
271
+ }
272
+
273
+ const at = input.lastIndexOf("@")
274
+ if (at > 0) {
275
+ return {
276
+ packageName: input.slice(0, at),
277
+ requestedVersionOrTag: input.slice(at + 1) || undefined,
278
+ }
279
+ }
280
+ return { packageName: input }
281
+ }
282
+
283
+ function validateNpmPackageName(name: string): void {
284
+ if (!name) throw new Error('Invalid npm package name: name is required.')
285
+ if (name.length > 214) throw new Error(`Invalid npm package name: "${name}" exceeds 214 characters.`)
286
+ if (name !== name.toLowerCase() && !name.startsWith('@')) {
287
+ throw new Error(`Invalid npm package name: "${name}" must be lowercase.`)
288
+ }
289
+ if (name.startsWith('.') || name.startsWith('_')) {
290
+ throw new Error(`Invalid npm package name: "${name}" cannot start with . or _.`)
291
+ }
292
+ if (/[~'!()*]/.test(name) || name.includes(' ') || encodeURIComponent(name).replace(/%40/g, '@').replace(/%2[Ff]/g, '/') !== name) {
293
+ throw new Error(`Invalid npm package name: "${name}" contains invalid characters.`)
294
+ }
295
+ }
296
+
297
+ function looksLikeGithubOwnerRepo(ref: string): boolean {
298
+ const [repoPart] = splitRefSuffix(ref)
299
+ const parts = repoPart.split("/").filter(Boolean)
300
+ return parts.length === 2
301
+ }
302
+
303
+ function splitRefSuffix(value: string): [string, string | undefined] {
304
+ const hash = value.indexOf("#")
305
+ if (hash < 0) return [value, undefined]
306
+ return [value.slice(0, hash), value.slice(hash + 1) || undefined]
307
+ }
308
+
309
+ function findGitRepoRoot(startDir: string): string | undefined {
310
+ let current = path.resolve(startDir)
311
+ while (true) {
312
+ if (fs.existsSync(path.join(current, ".git"))) {
313
+ return current
314
+ }
315
+ const parent = path.dirname(current)
316
+ if (parent === current) return undefined
317
+ current = parent
318
+ }
319
+ }
320
+
321
+ function readGitValue(repoRoot: string, ...args: string[]): string | undefined {
322
+ const result = spawnSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" })
323
+ if (result.status !== 0) return undefined
324
+ const value = result.stdout.trim()
325
+ return value || undefined
326
+ }
327
+
328
+ async function fetchJson<T>(url: string, headers?: HeadersInit): Promise<T> {
329
+ const response = await fetchWithTimeout(url, { headers })
330
+ if (!response.ok) {
331
+ throw new Error(`Request failed (${response.status}) for ${url}`)
332
+ }
333
+ return await response.json() as T
334
+ }
335
+
336
+ async function tryFetchJson<T>(url: string, headers?: HeadersInit): Promise<T | null> {
337
+ const response = await fetchWithTimeout(url, { headers })
338
+ if (!response.ok) return null
339
+ return await response.json() as T
340
+ }
341
+
@@ -0,0 +1,335 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { fetchWithTimeout } from "./common"
4
+ import type { RegistrySearchHit, RegistrySearchResponse } from "./registry-types"
5
+
6
+ // ── Constants ───────────────────────────────────────────────────────────────
7
+
8
+ /** Default registry index URL. Override via config or AKM_REGISTRY_URL env var. */
9
+ const DEFAULT_REGISTRY_URL =
10
+ "https://raw.githubusercontent.com/itlackey/agentikit-registry/main/index.json"
11
+
12
+ /** Cache TTL in milliseconds (1 hour). */
13
+ const CACHE_TTL_MS = 60 * 60 * 1000
14
+
15
+ /** Maximum age before cache is considered stale but still usable as fallback (7 days). */
16
+ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000
17
+
18
+ // ── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export interface RegistryIndex {
21
+ version: number
22
+ updatedAt: string
23
+ kits: RegistryKitEntry[]
24
+ }
25
+
26
+ export interface RegistryKitEntry {
27
+ id: string
28
+ name: string
29
+ description?: string
30
+ ref: string
31
+ source: "npm" | "github" | "git"
32
+ homepage?: string
33
+ tags?: string[]
34
+ assetTypes?: string[]
35
+ author?: string
36
+ license?: string
37
+ latestVersion?: string
38
+ /** Whether this entry was manually reviewed and approved */
39
+ curated?: boolean
40
+ }
41
+
42
+ export interface RegistrySearchOptions {
43
+ limit?: number
44
+ /** Override registry URL(s). Accepts a single URL or an array. */
45
+ registryUrls?: string | string[]
46
+ }
47
+
48
+ // ── Public API ──────────────────────────────────────────────────────────────
49
+
50
+ export async function searchRegistry(
51
+ query: string,
52
+ options?: RegistrySearchOptions,
53
+ ): Promise<RegistrySearchResponse> {
54
+ const trimmed = query.trim()
55
+ if (!trimmed) {
56
+ return { query: "", hits: [], warnings: [] }
57
+ }
58
+
59
+ const limit = clampLimit(options?.limit)
60
+ const urls = resolveRegistryUrls(options?.registryUrls)
61
+ const warnings: string[] = []
62
+
63
+ // Load index from all configured registries, merge kits
64
+ const allKits: RegistryKitEntry[] = []
65
+ for (const url of urls) {
66
+ try {
67
+ const index = await loadIndex(url)
68
+ if (index) {
69
+ allKits.push(...index.kits)
70
+ }
71
+ } catch (err) {
72
+ warnings.push(`Registry ${url}: ${toErrorMessage(err)}`)
73
+ }
74
+ }
75
+
76
+ // Score and rank
77
+ const hits = scoreKits(allKits, trimmed, limit)
78
+
79
+ return { query: trimmed, hits, warnings }
80
+ }
81
+
82
+ // ── Index loading with cache ────────────────────────────────────────────────
83
+
84
+ async function loadIndex(url: string): Promise<RegistryIndex | null> {
85
+ const cachePath = indexCachePath(url)
86
+ const cached = readCachedIndex(cachePath)
87
+
88
+ // Fresh cache: return immediately
89
+ if (cached && !isCacheExpired(cached.mtime)) {
90
+ return cached.index
91
+ }
92
+
93
+ // Try to fetch fresh index
94
+ try {
95
+ const response = await fetchWithTimeout(url, undefined, 10_000)
96
+ if (!response.ok) {
97
+ throw new Error(`HTTP ${response.status}`)
98
+ }
99
+ const data = (await response.json()) as unknown
100
+ const index = parseRegistryIndex(data)
101
+ if (index) {
102
+ writeCachedIndex(cachePath, index)
103
+ return index
104
+ }
105
+ throw new Error("Invalid registry index format")
106
+ } catch (err) {
107
+ // Fetch failed — use stale cache if available
108
+ if (cached && !isCacheStale(cached.mtime)) {
109
+ return cached.index
110
+ }
111
+ throw err
112
+ }
113
+ }
114
+
115
+ function readCachedIndex(
116
+ cachePath: string,
117
+ ): { index: RegistryIndex; mtime: number } | null {
118
+ try {
119
+ const stat = fs.statSync(cachePath)
120
+ const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"))
121
+ const index = parseRegistryIndex(raw)
122
+ if (!index) return null
123
+ return { index, mtime: stat.mtimeMs }
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ function writeCachedIndex(cachePath: string, index: RegistryIndex): void {
130
+ try {
131
+ const dir = path.dirname(cachePath)
132
+ fs.mkdirSync(dir, { recursive: true })
133
+ const tmpPath = cachePath + `.tmp.${process.pid}`
134
+ fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8")
135
+ fs.renameSync(tmpPath, cachePath)
136
+ } catch {
137
+ // Best-effort caching — don't fail the search if we can't write
138
+ }
139
+ }
140
+
141
+ function indexCachePath(url: string): string {
142
+ const cacheRoot = resolveCacheDir()
143
+ // Deterministic filename from URL
144
+ const slug = url
145
+ .replace(/[^a-zA-Z0-9]+/g, "-")
146
+ .replace(/^-+|-+$/g, "")
147
+ .slice(0, 120)
148
+ return path.join(cacheRoot, "registry-index", `${slug}.json`)
149
+ }
150
+
151
+ function resolveCacheDir(): string {
152
+ const xdgCache = process.env.XDG_CACHE_HOME?.trim()
153
+ if (xdgCache) return path.join(path.resolve(xdgCache), "agentikit")
154
+ const home = process.env.HOME?.trim()
155
+ if (!home) return path.join("/tmp", "agentikit-cache")
156
+ return path.join(path.resolve(home), ".cache", "agentikit")
157
+ }
158
+
159
+ function isCacheExpired(mtimeMs: number): boolean {
160
+ return Date.now() - mtimeMs > CACHE_TTL_MS
161
+ }
162
+
163
+ function isCacheStale(mtimeMs: number): boolean {
164
+ return Date.now() - mtimeMs > CACHE_STALE_MS
165
+ }
166
+
167
+ // ── Index parsing ───────────────────────────────────────────────────────────
168
+
169
+ function parseRegistryIndex(data: unknown): RegistryIndex | null {
170
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null
171
+ const obj = data as Record<string, unknown>
172
+
173
+ if (typeof obj.version !== "number" || obj.version !== 1) return null
174
+ if (typeof obj.updatedAt !== "string") return null
175
+ if (!Array.isArray(obj.kits)) return null
176
+
177
+ const kits = obj.kits.flatMap((raw): RegistryKitEntry[] => {
178
+ const kit = parseKitEntry(raw)
179
+ return kit ? [kit] : []
180
+ })
181
+
182
+ return { version: 1, updatedAt: obj.updatedAt, kits }
183
+ }
184
+
185
+ function parseKitEntry(raw: unknown): RegistryKitEntry | null {
186
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null
187
+ const obj = raw as Record<string, unknown>
188
+
189
+ const id = asString(obj.id)
190
+ const name = asString(obj.name)
191
+ const ref = asString(obj.ref)
192
+ const source = asSource(obj.source)
193
+ if (!id || !name || !ref || !source) return null
194
+
195
+ return {
196
+ id,
197
+ name,
198
+ ref,
199
+ source,
200
+ description: asString(obj.description),
201
+ homepage: asString(obj.homepage),
202
+ tags: asStringArray(obj.tags),
203
+ assetTypes: asStringArray(obj.assetTypes),
204
+ author: asString(obj.author),
205
+ license: asString(obj.license),
206
+ latestVersion: asString(obj.latestVersion),
207
+ curated: obj.curated === true ? true : undefined,
208
+ }
209
+ }
210
+
211
+ // ── Scoring ─────────────────────────────────────────────────────────────────
212
+
213
+ function scoreKits(
214
+ kits: RegistryKitEntry[],
215
+ query: string,
216
+ limit: number,
217
+ ): RegistrySearchHit[] {
218
+ const tokens = query
219
+ .toLowerCase()
220
+ .split(/\s+/)
221
+ .filter(Boolean)
222
+
223
+ const scored: Array<{ kit: RegistryKitEntry; score: number }> = []
224
+
225
+ for (const kit of kits) {
226
+ const score = scoreKit(kit, tokens)
227
+ if (score > 0) {
228
+ scored.push({ kit, score })
229
+ }
230
+ }
231
+
232
+ scored.sort((a, b) => b.score - a.score)
233
+
234
+ return scored.slice(0, limit).map(({ kit, score }) => toSearchHit(kit, score))
235
+ }
236
+
237
+ function scoreKit(kit: RegistryKitEntry, tokens: string[]): number {
238
+ let score = 0
239
+ const nameLower = kit.name.toLowerCase()
240
+ const descLower = (kit.description ?? "").toLowerCase()
241
+ const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase())
242
+
243
+ for (const token of tokens) {
244
+ // Exact name match is strongest signal
245
+ if (nameLower === token) {
246
+ score += 1.0
247
+ } else if (nameLower.includes(token)) {
248
+ score += 0.6
249
+ }
250
+
251
+ // Tag matches are high-signal (curated keywords)
252
+ if (tagsLower.some((tag) => tag === token)) {
253
+ score += 0.5
254
+ } else if (tagsLower.some((tag) => tag.includes(token))) {
255
+ score += 0.25
256
+ }
257
+
258
+ // Description substring
259
+ if (descLower.includes(token)) {
260
+ score += 0.2
261
+ }
262
+
263
+ // Author match
264
+ if (kit.author?.toLowerCase().includes(token)) {
265
+ score += 0.15
266
+ }
267
+ }
268
+
269
+ // Normalize by token count so multi-word queries don't inflate scores
270
+ return tokens.length > 0 ? score / tokens.length : 0
271
+ }
272
+
273
+ function toSearchHit(kit: RegistryKitEntry, score: number): RegistrySearchHit {
274
+ const metadata: Record<string, string> = {}
275
+ if (kit.latestVersion) metadata.version = kit.latestVersion
276
+ if (kit.author) metadata.author = kit.author
277
+ if (kit.license) metadata.license = kit.license
278
+ if (kit.assetTypes?.length) metadata.assetTypes = kit.assetTypes.join(", ")
279
+
280
+ return {
281
+ source: kit.source,
282
+ id: kit.id,
283
+ title: kit.name,
284
+ description: kit.description,
285
+ ref: kit.ref,
286
+ homepage: kit.homepage,
287
+ score: Math.round(score * 1000) / 1000,
288
+ metadata,
289
+ curated: kit.curated,
290
+ }
291
+ }
292
+
293
+ // ── Registry URL resolution ─────────────────────────────────────────────────
294
+
295
+ function resolveRegistryUrls(override?: string | string[]): string[] {
296
+ if (override) {
297
+ const urls = Array.isArray(override) ? override : [override]
298
+ return urls.filter(Boolean)
299
+ }
300
+
301
+ // Allow env var override (comma-separated)
302
+ const envUrls = process.env.AKM_REGISTRY_URL?.trim()
303
+ if (envUrls) {
304
+ return envUrls.split(",").map((u) => u.trim()).filter(Boolean)
305
+ }
306
+
307
+ return [DEFAULT_REGISTRY_URL]
308
+ }
309
+
310
+ // ── Utilities ───────────────────────────────────────────────────────────────
311
+
312
+ function clampLimit(limit: number | undefined): number {
313
+ if (!limit || !Number.isFinite(limit)) return 20
314
+ return Math.min(100, Math.max(1, Math.trunc(limit)))
315
+ }
316
+
317
+ function asString(value: unknown): string | undefined {
318
+ return typeof value === "string" && value ? value : undefined
319
+ }
320
+
321
+ function asSource(value: unknown): "npm" | "github" | "git" | undefined {
322
+ return value === "npm" || value === "github" || value === "git"
323
+ ? value
324
+ : undefined
325
+ }
326
+
327
+ function asStringArray(value: unknown): string[] | undefined {
328
+ if (!Array.isArray(value)) return undefined
329
+ const filtered = value.filter((v): v is string => typeof v === "string")
330
+ return filtered.length > 0 ? filtered : undefined
331
+ }
332
+
333
+ function toErrorMessage(error: unknown): string {
334
+ return error instanceof Error ? error.message : String(error)
335
+ }