agentikit 0.0.7 → 0.0.9

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 (98) hide show
  1. package/README.md +215 -76
  2. package/dist/index.d.ts +17 -3
  3. package/dist/index.js +10 -2
  4. package/dist/src/asset-spec.d.ts +14 -0
  5. package/dist/src/asset-spec.js +46 -0
  6. package/dist/src/cli.js +268 -57
  7. package/dist/src/common.d.ts +8 -0
  8. package/dist/src/common.js +46 -0
  9. package/dist/src/config.d.ts +37 -0
  10. package/dist/src/config.js +124 -0
  11. package/dist/src/embedder.d.ts +10 -0
  12. package/dist/src/embedder.js +87 -0
  13. package/dist/src/frontmatter.d.ts +30 -0
  14. package/dist/src/frontmatter.js +86 -0
  15. package/dist/src/indexer.d.ts +20 -2
  16. package/dist/src/indexer.js +212 -80
  17. package/dist/src/init.d.ts +19 -0
  18. package/dist/src/init.js +87 -0
  19. package/dist/src/llm.d.ts +15 -0
  20. package/dist/src/llm.js +91 -0
  21. package/dist/src/markdown.d.ts +18 -0
  22. package/dist/src/markdown.js +77 -0
  23. package/dist/src/metadata.d.ts +11 -2
  24. package/dist/src/metadata.js +161 -29
  25. package/dist/src/registry-install.d.ts +11 -0
  26. package/dist/src/registry-install.js +208 -0
  27. package/dist/src/registry-resolve.d.ts +3 -0
  28. package/dist/src/registry-resolve.js +231 -0
  29. package/dist/src/registry-search.d.ts +5 -0
  30. package/dist/src/registry-search.js +129 -0
  31. package/dist/src/registry-types.d.ts +55 -0
  32. package/dist/src/registry-types.js +1 -0
  33. package/dist/src/ripgrep-install.d.ts +12 -0
  34. package/dist/src/ripgrep-install.js +169 -0
  35. package/dist/src/ripgrep-resolve.d.ts +13 -0
  36. package/dist/src/ripgrep-resolve.js +68 -0
  37. package/dist/src/ripgrep.d.ts +3 -36
  38. package/dist/src/ripgrep.js +2 -262
  39. package/dist/src/similarity.d.ts +1 -2
  40. package/dist/src/similarity.js +11 -0
  41. package/dist/src/stash-add.d.ts +4 -0
  42. package/dist/src/stash-add.js +59 -0
  43. package/dist/src/stash-ref.d.ts +7 -0
  44. package/dist/src/stash-ref.js +33 -0
  45. package/dist/src/stash-registry.d.ts +18 -0
  46. package/dist/src/stash-registry.js +221 -0
  47. package/dist/src/stash-resolve.d.ts +2 -0
  48. package/dist/src/stash-resolve.js +45 -0
  49. package/dist/src/stash-search.d.ts +8 -0
  50. package/dist/src/stash-search.js +484 -0
  51. package/dist/src/stash-show.d.ts +5 -0
  52. package/dist/src/stash-show.js +114 -0
  53. package/dist/src/stash-types.d.ts +217 -0
  54. package/dist/src/stash-types.js +1 -0
  55. package/dist/src/stash.d.ts +10 -63
  56. package/dist/src/stash.js +6 -633
  57. package/dist/src/tool-runner.d.ts +35 -0
  58. package/dist/src/tool-runner.js +100 -0
  59. package/dist/src/walker.d.ts +19 -0
  60. package/dist/src/walker.js +47 -0
  61. package/package.json +8 -14
  62. package/src/asset-spec.ts +69 -0
  63. package/src/cli.ts +282 -46
  64. package/src/common.ts +58 -0
  65. package/src/config.ts +183 -0
  66. package/src/embedder.ts +117 -0
  67. package/src/frontmatter.ts +95 -0
  68. package/src/indexer.ts +244 -84
  69. package/src/init.ts +106 -0
  70. package/src/llm.ts +124 -0
  71. package/src/markdown.ts +106 -0
  72. package/src/metadata.ts +171 -27
  73. package/src/registry-install.ts +245 -0
  74. package/src/registry-resolve.ts +272 -0
  75. package/src/registry-search.ts +145 -0
  76. package/src/registry-types.ts +64 -0
  77. package/src/ripgrep-install.ts +200 -0
  78. package/src/ripgrep-resolve.ts +72 -0
  79. package/src/ripgrep.ts +3 -315
  80. package/src/similarity.ts +13 -1
  81. package/src/stash-add.ts +66 -0
  82. package/src/stash-ref.ts +41 -0
  83. package/src/stash-registry.ts +259 -0
  84. package/src/stash-resolve.ts +47 -0
  85. package/src/stash-search.ts +595 -0
  86. package/src/stash-show.ts +112 -0
  87. package/src/stash-types.ts +221 -0
  88. package/src/stash.ts +31 -760
  89. package/src/tool-runner.ts +129 -0
  90. package/src/walker.ts +53 -0
  91. package/.claude-plugin/plugin.json +0 -21
  92. package/commands/open.md +0 -11
  93. package/commands/run.md +0 -11
  94. package/commands/search.md +0 -11
  95. package/dist/src/plugin.d.ts +0 -2
  96. package/dist/src/plugin.js +0 -55
  97. package/skills/stash/SKILL.md +0 -73
  98. package/src/plugin.ts +0 -56
@@ -0,0 +1,106 @@
1
+ import { parseFrontmatter } from "./frontmatter"
2
+
3
+ // ── Types ───────────────────────────────────────────────────────────────────
4
+
5
+ export interface TocHeading {
6
+ level: number
7
+ text: string
8
+ line: number
9
+ }
10
+
11
+ export interface KnowledgeToc {
12
+ headings: TocHeading[]
13
+ totalLines: number
14
+ }
15
+
16
+ // ── Parsing ─────────────────────────────────────────────────────────────────
17
+
18
+ export function parseMarkdownToc(content: string): KnowledgeToc {
19
+ const lines = content.split(/\r?\n/)
20
+ const headings: TocHeading[] = []
21
+
22
+ const parsed = parseFrontmatter(content)
23
+ const start = parsed.frontmatter ? parsed.bodyStartLine - 1 : 0
24
+
25
+ for (let i = start; i < lines.length; i++) {
26
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/)
27
+ if (match) {
28
+ headings.push({
29
+ level: match[1].length,
30
+ text: match[2].replace(/\s+#+\s*$/, "").trim(),
31
+ line: i + 1,
32
+ })
33
+ }
34
+ }
35
+
36
+ return { headings, totalLines: lines.length }
37
+ }
38
+
39
+ // ── Extraction ──────────────────────────────────────────────────────────────
40
+
41
+ export function extractSection(
42
+ content: string,
43
+ heading: string,
44
+ ): { content: string; startLine: number; endLine: number } | null {
45
+ const lines = content.split(/\r?\n/)
46
+ const target = heading.toLowerCase()
47
+
48
+ let startIdx = -1
49
+ let startLevel = 0
50
+
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const match = lines[i].match(/^(#{1,6})\s+(.+)$/)
53
+ if (!match) continue
54
+ const text = match[2].replace(/\s+#+\s*$/, "").trim()
55
+ if (text.toLowerCase() === target && startIdx === -1) {
56
+ startIdx = i
57
+ startLevel = match[1].length
58
+ } else if (startIdx !== -1 && match[1].length <= startLevel) {
59
+ return {
60
+ content: lines.slice(startIdx, i).join("\n"),
61
+ startLine: startIdx + 1,
62
+ endLine: i,
63
+ }
64
+ }
65
+ }
66
+
67
+ if (startIdx === -1) return null
68
+
69
+ return {
70
+ content: lines.slice(startIdx).join("\n"),
71
+ startLine: startIdx + 1,
72
+ endLine: lines.length,
73
+ }
74
+ }
75
+
76
+ export function extractLineRange(content: string, start: number, end: number): string {
77
+ const lines = content.split(/\r?\n/)
78
+ if (end < start) return ""
79
+ const s = Math.max(1, Math.min(start, lines.length))
80
+ const e = Math.min(end, lines.length)
81
+ return lines.slice(s - 1, e).join("\n")
82
+ }
83
+
84
+ export function extractFrontmatterOnly(content: string): string | null {
85
+ const parsed = parseFrontmatter(content)
86
+ return parsed.frontmatter
87
+ }
88
+
89
+ // ── Formatting ──────────────────────────────────────────────────────────────
90
+
91
+ export function formatToc(toc: KnowledgeToc): string {
92
+ if (toc.headings.length === 0) {
93
+ return `(no headings found — ${toc.totalLines} lines total)`
94
+ }
95
+
96
+ const lineWidth = String(toc.totalLines).length
97
+ const parts = toc.headings.map((h) => {
98
+ const lineNum = `L${String(h.line).padStart(lineWidth)}`
99
+ const indent = " ".repeat(h.level - 1)
100
+ const prefix = "#".repeat(h.level)
101
+ return `${lineNum} ${indent}${prefix} ${h.text}`
102
+ })
103
+
104
+ parts.push(`\n${toc.totalLines} lines total`)
105
+ return parts.join("\n")
106
+ }
package/src/metadata.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
- import type { AgentikitAssetType } from "./stash"
3
+ import { type AgentikitAssetType, isAssetType } from "./common"
4
+ import { SCRIPT_EXTENSIONS, isRelevantAssetFile, deriveCanonicalAssetName } from "./asset-spec"
5
+ import { parseFrontmatter, toStringOrUndefined } from "./frontmatter"
6
+ import { parseMarkdownToc, type TocHeading } from "./markdown"
4
7
 
5
8
  // ── Schema ──────────────────────────────────────────────────────────────────
6
9
 
@@ -16,9 +19,16 @@ export interface StashEntry {
16
19
  description?: string
17
20
  tags?: string[]
18
21
  examples?: string[]
22
+ intents?: string[]
19
23
  intent?: StashIntent
20
24
  entry?: string
21
25
  generated?: boolean
26
+ quality?: "generated" | "curated"
27
+ confidence?: number
28
+ source?: "package" | "frontmatter" | "comments" | "filename" | "manual" | "llm"
29
+ aliases?: string[]
30
+ toc?: TocHeading[]
31
+ usage?: string[]
22
32
  }
23
33
 
24
34
  export interface StashFile {
@@ -59,7 +69,7 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
59
69
  if (typeof entry !== "object" || entry === null) return null
60
70
  const e = entry as Record<string, unknown>
61
71
  if (typeof e.name !== "string" || !e.name) return null
62
- if (typeof e.type !== "string" || !isValidType(e.type)) return null
72
+ if (typeof e.type !== "string" || !isAssetType(e.type)) return null
63
73
 
64
74
  const result: StashEntry = {
65
75
  name: e.name,
@@ -68,6 +78,10 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
68
78
  if (typeof e.description === "string" && e.description) result.description = e.description
69
79
  if (Array.isArray(e.tags)) result.tags = e.tags.filter((t): t is string => typeof t === "string")
70
80
  if (Array.isArray(e.examples)) result.examples = e.examples.filter((x): x is string => typeof x === "string")
81
+ if (Array.isArray(e.intents)) {
82
+ const filtered = e.intents.filter((s): s is string => typeof s === "string" && s.trim().length > 0)
83
+ if (filtered.length > 0) result.intents = filtered
84
+ }
71
85
  if (typeof e.intent === "object" && e.intent !== null) {
72
86
  const intent = e.intent as Record<string, unknown>
73
87
  result.intent = {}
@@ -77,67 +91,135 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
77
91
  }
78
92
  if (typeof e.entry === "string" && e.entry) result.entry = e.entry
79
93
  if (e.generated === true) result.generated = true
94
+ if (e.quality === "generated" || e.quality === "curated") result.quality = e.quality
95
+ if (typeof e.confidence === "number" && Number.isFinite(e.confidence)) result.confidence = Math.max(0, Math.min(1, e.confidence))
96
+ if (typeof e.source === "string" && ["package", "frontmatter", "comments", "filename", "manual", "llm"].includes(e.source)) {
97
+ result.source = e.source as StashEntry["source"]
98
+ }
99
+ if (Array.isArray(e.aliases)) {
100
+ const filtered = e.aliases.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
101
+ if (filtered.length > 0) result.aliases = normalizeTerms(filtered)
102
+ }
103
+ if (Array.isArray(e.toc)) {
104
+ const validated = e.toc.filter(
105
+ (h: unknown): h is TocHeading => {
106
+ if (typeof h !== "object" || h === null) return false
107
+ const rec = h as Record<string, unknown>
108
+ return typeof rec.level === "number"
109
+ && typeof rec.text === "string"
110
+ && typeof rec.line === "number"
111
+ },
112
+ )
113
+ if (validated.length > 0) result.toc = validated
114
+ }
115
+ const usage = normalizeNonEmptyStringList(e.usage)
116
+ if (usage) result.usage = usage
80
117
 
81
118
  return result
82
119
  }
83
120
 
84
- function isValidType(type: string): boolean {
85
- return type === "tool" || type === "skill" || type === "command" || type === "agent"
121
+ function normalizeNonEmptyStringList(value: unknown): string[] | undefined {
122
+ if (typeof value === "string") {
123
+ const trimmed = value.trim()
124
+ return trimmed ? [trimmed] : undefined
125
+ }
126
+ if (!Array.isArray(value)) return undefined
127
+ const filtered = value
128
+ .filter((item): item is string => typeof item === "string")
129
+ .map((item) => item.trim())
130
+ .filter((item) => item.length > 0)
131
+ return filtered.length > 0 ? filtered : undefined
86
132
  }
87
133
 
88
134
  // ── Metadata Generation ─────────────────────────────────────────────────────
89
135
 
90
- const SCRIPT_EXTENSIONS = new Set([".sh", ".ts", ".js", ".ps1", ".cmd", ".bat"])
91
-
92
136
  export function generateMetadata(
93
137
  dirPath: string,
94
138
  assetType: AgentikitAssetType,
95
139
  files: string[],
140
+ typeRoot = dirPath,
96
141
  ): StashFile {
97
142
  const entries: StashEntry[] = []
143
+ const pkgMeta = extractPackageMetadata(dirPath)
98
144
 
99
145
  for (const file of files) {
100
146
  const ext = path.extname(file).toLowerCase()
101
147
  const baseName = path.basename(file, ext)
148
+ const fileName = path.basename(file)
102
149
 
103
150
  // Skip non-relevant files
104
- if (assetType === "tool" && !SCRIPT_EXTENSIONS.has(ext)) continue
105
- if ((assetType === "command" || assetType === "agent") && ext !== ".md") continue
106
- if (assetType === "skill" && path.basename(file) !== "SKILL.md") continue
151
+ if (!isRelevantAssetFile(assetType, fileName)) continue
152
+
153
+ const canonicalName = assetType === "skill"
154
+ ? deriveCanonicalAssetName(assetType, typeRoot, file) ?? baseName
155
+ : baseName
107
156
 
108
157
  const entry: StashEntry = {
109
- name: baseName,
158
+ name: canonicalName,
110
159
  type: assetType,
111
160
  generated: true,
161
+ quality: "generated",
162
+ confidence: 0.55,
163
+ source: "filename",
112
164
  }
113
165
 
114
- // Priority 3: package.json metadata
115
- const pkgMeta = extractPackageMetadata(dirPath)
166
+ // Priority 1: package.json metadata
116
167
  if (pkgMeta) {
117
- if (pkgMeta.description && !entry.description) entry.description = pkgMeta.description
118
- if (pkgMeta.keywords && pkgMeta.keywords.length > 0) entry.tags = pkgMeta.keywords
168
+ if (pkgMeta.description && !entry.description) {
169
+ entry.description = pkgMeta.description
170
+ entry.source = "package"
171
+ entry.confidence = 0.8
172
+ }
173
+ if (pkgMeta.keywords && pkgMeta.keywords.length > 0) entry.tags = normalizeTerms(pkgMeta.keywords)
119
174
  }
120
175
 
121
- // Priority 2: Frontmatter (for .md files)
176
+ // Priority 2: Frontmatter (for .md files — overrides package.json description)
122
177
  if (ext === ".md") {
123
178
  const fm = extractFrontmatterDescription(file)
124
- if (fm) entry.description = fm
179
+ if (fm) {
180
+ entry.description = fm
181
+ entry.source = "frontmatter"
182
+ entry.confidence = 0.9
183
+ }
184
+ }
185
+
186
+ // Knowledge entries: generate TOC from headings
187
+ if (assetType === "knowledge") {
188
+ try {
189
+ const mdContent = fs.readFileSync(file, "utf8")
190
+ const toc = parseMarkdownToc(mdContent)
191
+ if (toc.headings.length > 0) entry.toc = toc.headings
192
+ } catch {
193
+ // Non-fatal: skip TOC if file can't be read
194
+ }
125
195
  }
126
196
 
127
- // Priority 4: Code comments (for script files)
197
+ // Priority 3: Code comments (for script files)
128
198
  if (SCRIPT_EXTENSIONS.has(ext) && ext !== ".md") {
129
199
  const commentDesc = extractDescriptionFromComments(file)
130
- if (commentDesc && !entry.description) entry.description = commentDesc
200
+ if (commentDesc && !entry.description) {
201
+ entry.description = commentDesc
202
+ entry.source = "comments"
203
+ entry.confidence = 0.7
204
+ }
131
205
  }
132
206
 
133
- // Priority 5: Filename heuristics (fallback)
207
+ // Priority 4: Filename heuristics (fallback)
134
208
  if (!entry.description) {
135
209
  entry.description = fileNameToDescription(baseName)
210
+ entry.source = "filename"
211
+ entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55)
136
212
  }
137
213
  if (!entry.tags || entry.tags.length === 0) {
138
214
  entry.tags = extractTagsFromPath(file, dirPath)
139
215
  }
140
216
 
217
+ entry.tags = normalizeTerms(entry.tags ?? [])
218
+ entry.aliases = buildAliases(canonicalName, entry.tags)
219
+
220
+ // Intents are only generated when LLM is configured (via enhanceStashWithLlm)
221
+ // Heuristic intents are too noisy to be useful for search quality
222
+
141
223
  entry.entry = path.basename(file)
142
224
  entries.push(entry)
143
225
  }
@@ -145,6 +227,74 @@ export function generateMetadata(
145
227
  return { entries }
146
228
  }
147
229
 
230
+
231
+ function normalizeTerms(values: string[]): string[] {
232
+ const normalized = new Set<string>()
233
+ for (const value of values) {
234
+ const cleaned = value.toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim()
235
+ if (!cleaned) continue
236
+ normalized.add(cleaned)
237
+ if (cleaned.endsWith("s") && cleaned.length > 3) {
238
+ normalized.add(cleaned.slice(0, -1))
239
+ }
240
+ }
241
+ return Array.from(normalized)
242
+ }
243
+
244
+ function buildAliases(name: string, tags: string[]): string[] {
245
+ const aliases = new Set<string>()
246
+ const spaced = name.replace(/[-_]+/g, " ").trim().toLowerCase()
247
+ if (spaced && spaced !== name.toLowerCase()) aliases.add(spaced)
248
+ if (tags.length > 1) aliases.add(tags.join(" "))
249
+ return Array.from(aliases)
250
+ }
251
+
252
+ // ── Intent Generation ────────────────────────────────────────────────────────
253
+
254
+ export function generateIntents(description: string, tags: string[], name: string): string[] {
255
+ const intents = new Set<string>()
256
+
257
+ // Split name on separators to extract tokens and potential verb
258
+ const nameTokens = name
259
+ .replace(/[-_]+/g, " ")
260
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
261
+ .toLowerCase()
262
+ .trim()
263
+ .split(/\s+/)
264
+ .filter((t) => t.length > 1)
265
+
266
+ // Intent from name as phrase (e.g. "summarize diff")
267
+ const namePhrase = nameTokens.join(" ")
268
+ if (namePhrase.length > 2) intents.add(namePhrase)
269
+
270
+ // Intent from description (lowercased)
271
+ const desc = description.toLowerCase().trim()
272
+ if (desc.length > 2) intents.add(desc)
273
+
274
+ // Combine first name token (potential verb) with tags
275
+ // e.g. name "summarize-diff", tags ["git"] → "summarize git diff"
276
+ if (nameTokens.length >= 1 && tags.length > 0) {
277
+ const verb = nameTokens[0]
278
+ const rest = nameTokens.slice(1).join(" ")
279
+ for (const tag of tags) {
280
+ const tagLower = tag.toLowerCase()
281
+ // verb + tag + rest (e.g. "summarize git diff")
282
+ const parts = [verb, tagLower, rest].filter((p) => p.length > 0)
283
+ const phrase = parts.join(" ")
284
+ if (phrase !== namePhrase && phrase.length > 2) intents.add(phrase)
285
+ }
286
+ }
287
+
288
+ // Join tag pairs (e.g. ["git", "diff"] → "git diff")
289
+ if (tags.length >= 2) {
290
+ const tagPhrase = tags.map((t) => t.toLowerCase()).join(" ")
291
+ if (tagPhrase.length > 2) intents.add(tagPhrase)
292
+ }
293
+
294
+ // Cap at 8 intents
295
+ return Array.from(intents).slice(0, 8)
296
+ }
297
+
148
298
  export function extractDescriptionFromComments(filePath: string): string | null {
149
299
  let content: string
150
300
  try {
@@ -195,14 +345,8 @@ export function extractFrontmatterDescription(filePath: string): string | null {
195
345
  return null
196
346
  }
197
347
 
198
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
199
- if (!match) return null
200
-
201
- for (const line of match[1].split(/\r?\n/)) {
202
- const m = line.match(/^description:\s*"?(.+?)"?\s*$/)
203
- if (m) return m[1]
204
- }
205
- return null
348
+ const parsed = parseFrontmatter(content)
349
+ return toStringOrUndefined(parsed.data.description) ?? null
206
350
  }
207
351
 
208
352
  export function extractPackageMetadata(
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { TYPE_DIRS } from "./common"
5
+ import { loadConfig, saveConfig, type AgentikitConfig } from "./config"
6
+ import { parseRegistryRef, resolveRegistryArtifact } from "./registry-resolve"
7
+ import type { RegistryInstallResult, RegistryInstalledEntry, RegistrySource } from "./registry-types"
8
+
9
+ const REGISTRY_STASH_DIR_NAMES = new Set<string>(Object.values(TYPE_DIRS))
10
+
11
+ export interface InstallRegistryRefOptions {
12
+ cacheRootDir?: string
13
+ now?: Date
14
+ }
15
+
16
+ export async function installRegistryRef(ref: string, options?: InstallRegistryRefOptions): Promise<RegistryInstallResult> {
17
+ const parsed = parseRegistryRef(ref)
18
+ const resolved = await resolveRegistryArtifact(parsed)
19
+
20
+ const installedAt = (options?.now ?? new Date()).toISOString()
21
+ const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheRootDir()
22
+ const cacheDir = buildInstallCacheDir(cacheRootDir, resolved.source, resolved.id)
23
+ const archivePath = path.join(cacheDir, "artifact.tar.gz")
24
+ const extractedDir = path.join(cacheDir, "extracted")
25
+
26
+ fs.mkdirSync(cacheDir, { recursive: true })
27
+
28
+ await downloadArchive(resolved.artifactUrl, archivePath)
29
+ extractTarGzSecure(archivePath, extractedDir)
30
+
31
+ const stashRoot = detectStashRoot(extractedDir)
32
+
33
+ return {
34
+ id: resolved.id,
35
+ source: resolved.source,
36
+ ref: resolved.ref,
37
+ artifactUrl: resolved.artifactUrl,
38
+ resolvedVersion: resolved.resolvedVersion,
39
+ resolvedRevision: resolved.resolvedRevision,
40
+ installedAt,
41
+ cacheDir,
42
+ extractedDir,
43
+ stashRoot,
44
+ }
45
+ }
46
+
47
+ export function upsertInstalledRegistryEntry(entry: RegistryInstalledEntry, stashDir?: string): AgentikitConfig {
48
+ const current = loadConfig(stashDir)
49
+ const currentInstalled = current.registry?.installed ?? []
50
+ const previousRegistryRoots = new Set(currentInstalled.map((item) => path.resolve(item.stashRoot)))
51
+
52
+ const withoutExisting = currentInstalled.filter((item) => item.id !== entry.id)
53
+ const nextInstalled = [...withoutExisting, normalizeInstalledEntry(entry)]
54
+ const nextRegistryRoots = new Set(nextInstalled.map((item) => path.resolve(item.stashRoot)))
55
+ const preservedAdditional = current.additionalStashDirs.filter(
56
+ (dir) => !previousRegistryRoots.has(path.resolve(dir)),
57
+ )
58
+ const syncedAdditional = uniquePaths([...preservedAdditional, ...nextRegistryRoots])
59
+
60
+ const nextConfig: AgentikitConfig = {
61
+ ...current,
62
+ additionalStashDirs: syncedAdditional,
63
+ registry: {
64
+ installed: nextInstalled,
65
+ },
66
+ }
67
+ saveConfig(nextConfig, stashDir)
68
+ return nextConfig
69
+ }
70
+
71
+ export function removeInstalledRegistryEntry(id: string, stashDir?: string): AgentikitConfig {
72
+ const current = loadConfig(stashDir)
73
+ const currentInstalled = current.registry?.installed ?? []
74
+ const previousRegistryRoots = new Set(currentInstalled.map((item) => path.resolve(item.stashRoot)))
75
+
76
+ const nextInstalled = currentInstalled.filter((item) => item.id !== id)
77
+ const nextRegistryRoots = new Set(nextInstalled.map((item) => path.resolve(item.stashRoot)))
78
+
79
+ const preservedAdditional = current.additionalStashDirs.filter(
80
+ (dir) => !previousRegistryRoots.has(path.resolve(dir)),
81
+ )
82
+ const syncedAdditional = uniquePaths([...preservedAdditional, ...nextRegistryRoots])
83
+
84
+ const nextConfig: AgentikitConfig = {
85
+ ...current,
86
+ additionalStashDirs: syncedAdditional,
87
+ registry: nextInstalled.length > 0 ? { installed: nextInstalled } : undefined,
88
+ }
89
+ saveConfig(nextConfig, stashDir)
90
+ return nextConfig
91
+ }
92
+
93
+ export function getRegistryCacheRootDir(): string {
94
+ const xdgCache = process.env.XDG_CACHE_HOME?.trim()
95
+ if (xdgCache) {
96
+ return path.join(path.resolve(xdgCache), "agentikit", "registry")
97
+ }
98
+ const home = process.env.HOME?.trim()
99
+ if (!home) {
100
+ throw new Error("Unable to determine cache directory. Set XDG_CACHE_HOME or HOME.")
101
+ }
102
+ return path.join(path.resolve(home), ".cache", "agentikit", "registry")
103
+ }
104
+
105
+ export function detectStashRoot(extractedDir: string): string {
106
+ const root = path.resolve(extractedDir)
107
+
108
+ const rootDotStash = path.join(root, ".stash")
109
+ if (isDirectory(rootDotStash)) {
110
+ return root
111
+ }
112
+
113
+ if (hasStashDirs(root)) {
114
+ return root
115
+ }
116
+
117
+ const opencodeDir = path.join(root, "opencode")
118
+ if (hasStashDirs(opencodeDir)) {
119
+ return opencodeDir
120
+ }
121
+
122
+ const shallowest = findShallowestDotStashRoot(root)
123
+ if (shallowest) return shallowest
124
+
125
+ return root
126
+ }
127
+
128
+ function buildInstallCacheDir(cacheRootDir: string, source: RegistrySource, id: string): string {
129
+ const slug = `${source}-${id.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "")}`
130
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
131
+ return path.join(cacheRootDir, slug || source, stamp)
132
+ }
133
+
134
+ async function downloadArchive(url: string, destination: string): Promise<void> {
135
+ const response = await fetch(url)
136
+ if (!response.ok) {
137
+ throw new Error(`Failed to download archive (${response.status}) from ${url}`)
138
+ }
139
+ const arrayBuffer = await response.arrayBuffer()
140
+ fs.writeFileSync(destination, Buffer.from(arrayBuffer))
141
+ }
142
+
143
+ function extractTarGzSecure(archivePath: string, destinationDir: string): void {
144
+ const listResult = spawnSync("tar", ["tzf", archivePath], { encoding: "utf8" })
145
+ if (listResult.status !== 0) {
146
+ const err = listResult.stderr?.trim() || listResult.error?.message || "unknown error"
147
+ throw new Error(`Failed to inspect archive ${archivePath}: ${err}`)
148
+ }
149
+
150
+ validateTarEntries(listResult.stdout)
151
+
152
+ fs.rmSync(destinationDir, { recursive: true, force: true })
153
+ fs.mkdirSync(destinationDir, { recursive: true })
154
+
155
+ const extractResult = spawnSync("tar", ["xzf", archivePath, "--strip-components=1", "-C", destinationDir], {
156
+ encoding: "utf8",
157
+ })
158
+ if (extractResult.status !== 0) {
159
+ const err = extractResult.stderr?.trim() || extractResult.error?.message || "unknown error"
160
+ throw new Error(`Failed to extract archive ${archivePath}: ${err}`)
161
+ }
162
+ }
163
+
164
+ function validateTarEntries(listOutput: string): void {
165
+ const lines = listOutput.split(/\r?\n/).filter(Boolean)
166
+ for (const rawLine of lines) {
167
+ const entry = rawLine.trim()
168
+ if (!entry || entry.includes("\0")) {
169
+ throw new Error(`Archive contains an invalid entry: ${JSON.stringify(rawLine)}`)
170
+ }
171
+ if (entry.startsWith("/")) {
172
+ throw new Error(`Archive contains an absolute path entry: ${entry}`)
173
+ }
174
+
175
+ const normalized = path.posix.normalize(entry)
176
+ if (normalized === ".." || normalized.startsWith("../")) {
177
+ throw new Error(`Archive contains a path traversal entry: ${entry}`)
178
+ }
179
+
180
+ const parts = normalized.split("/").filter(Boolean)
181
+ const stripped = parts.slice(1).join("/")
182
+ if (!stripped) continue
183
+ const normalizedStripped = path.posix.normalize(stripped)
184
+ if (normalizedStripped === ".." || normalizedStripped.startsWith("../") || path.posix.isAbsolute(normalizedStripped)) {
185
+ throw new Error(`Archive contains an unsafe entry after strip-components: ${entry}`)
186
+ }
187
+ }
188
+ }
189
+
190
+ function isDirectory(target: string): boolean {
191
+ try {
192
+ return fs.statSync(target).isDirectory()
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ function hasStashDirs(dirPath: string): boolean {
199
+ if (!isDirectory(dirPath)) return false
200
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
201
+ return entries.some((entry) => entry.isDirectory() && REGISTRY_STASH_DIR_NAMES.has(entry.name))
202
+ }
203
+
204
+ function findShallowestDotStashRoot(root: string): string | undefined {
205
+ const queue: string[] = [root]
206
+ while (queue.length > 0) {
207
+ const current = queue.shift()!
208
+ const dotStash = path.join(current, ".stash")
209
+ if (isDirectory(dotStash)) {
210
+ return current
211
+ }
212
+ let children: fs.Dirent[]
213
+ try {
214
+ children = fs.readdirSync(current, { withFileTypes: true })
215
+ } catch {
216
+ continue
217
+ }
218
+ for (const child of children) {
219
+ if (!child.isDirectory()) continue
220
+ if (child.name === ".git" || child.name === "node_modules") continue
221
+ queue.push(path.join(current, child.name))
222
+ }
223
+ }
224
+ return undefined
225
+ }
226
+
227
+ function normalizeInstalledEntry(entry: RegistryInstalledEntry): RegistryInstalledEntry {
228
+ return {
229
+ ...entry,
230
+ stashRoot: path.resolve(entry.stashRoot),
231
+ cacheDir: path.resolve(entry.cacheDir),
232
+ }
233
+ }
234
+
235
+ function uniquePaths(paths: Iterable<string>): string[] {
236
+ const seen = new Set<string>()
237
+ const result: string[] = []
238
+ for (const candidate of paths) {
239
+ const normalized = path.resolve(candidate)
240
+ if (seen.has(normalized)) continue
241
+ seen.add(normalized)
242
+ result.push(normalized)
243
+ }
244
+ return result
245
+ }