agentikit 0.0.7 → 0.0.8

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 (80) hide show
  1. package/README.md +113 -77
  2. package/dist/index.d.ts +13 -3
  3. package/dist/index.js +7 -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 +154 -52
  7. package/dist/src/common.d.ts +8 -0
  8. package/dist/src/common.js +46 -0
  9. package/dist/src/config.d.ts +31 -0
  10. package/dist/src/config.js +74 -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 +10 -2
  24. package/dist/src/metadata.js +146 -30
  25. package/dist/src/ripgrep-install.d.ts +12 -0
  26. package/dist/src/ripgrep-install.js +169 -0
  27. package/dist/src/ripgrep-resolve.d.ts +13 -0
  28. package/dist/src/ripgrep-resolve.js +68 -0
  29. package/dist/src/ripgrep.d.ts +3 -36
  30. package/dist/src/ripgrep.js +2 -262
  31. package/dist/src/similarity.d.ts +1 -2
  32. package/dist/src/similarity.js +11 -0
  33. package/dist/src/stash-ref.d.ts +7 -0
  34. package/dist/src/stash-ref.js +33 -0
  35. package/dist/src/stash-resolve.d.ts +2 -0
  36. package/dist/src/stash-resolve.js +45 -0
  37. package/dist/src/stash-search.d.ts +6 -0
  38. package/dist/src/stash-search.js +269 -0
  39. package/dist/src/stash-show.d.ts +5 -0
  40. package/dist/src/stash-show.js +107 -0
  41. package/dist/src/stash-types.d.ts +53 -0
  42. package/dist/src/stash-types.js +1 -0
  43. package/dist/src/stash.d.ts +8 -63
  44. package/dist/src/stash.js +4 -633
  45. package/dist/src/tool-runner.d.ts +35 -0
  46. package/dist/src/tool-runner.js +100 -0
  47. package/dist/src/walker.d.ts +19 -0
  48. package/dist/src/walker.js +47 -0
  49. package/package.json +8 -14
  50. package/src/asset-spec.ts +69 -0
  51. package/src/cli.ts +164 -48
  52. package/src/common.ts +58 -0
  53. package/src/config.ts +124 -0
  54. package/src/embedder.ts +117 -0
  55. package/src/frontmatter.ts +95 -0
  56. package/src/indexer.ts +244 -84
  57. package/src/init.ts +106 -0
  58. package/src/llm.ts +124 -0
  59. package/src/markdown.ts +106 -0
  60. package/src/metadata.ts +157 -29
  61. package/src/ripgrep-install.ts +200 -0
  62. package/src/ripgrep-resolve.ts +72 -0
  63. package/src/ripgrep.ts +3 -315
  64. package/src/similarity.ts +13 -1
  65. package/src/stash-ref.ts +41 -0
  66. package/src/stash-resolve.ts +47 -0
  67. package/src/stash-search.ts +343 -0
  68. package/src/stash-show.ts +104 -0
  69. package/src/stash-types.ts +46 -0
  70. package/src/stash.ts +16 -760
  71. package/src/tool-runner.ts +129 -0
  72. package/src/walker.ts +53 -0
  73. package/.claude-plugin/plugin.json +0 -21
  74. package/commands/open.md +0 -11
  75. package/commands/run.md +0 -11
  76. package/commands/search.md +0 -11
  77. package/dist/src/plugin.d.ts +0 -2
  78. package/dist/src/plugin.js +0 -55
  79. package/skills/stash/SKILL.md +0 -73
  80. 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,15 @@ 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[]
22
31
  }
23
32
 
24
33
  export interface StashFile {
@@ -59,7 +68,7 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
59
68
  if (typeof entry !== "object" || entry === null) return null
60
69
  const e = entry as Record<string, unknown>
61
70
  if (typeof e.name !== "string" || !e.name) return null
62
- if (typeof e.type !== "string" || !isValidType(e.type)) return null
71
+ if (typeof e.type !== "string" || !isAssetType(e.type)) return null
63
72
 
64
73
  const result: StashEntry = {
65
74
  name: e.name,
@@ -68,6 +77,10 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
68
77
  if (typeof e.description === "string" && e.description) result.description = e.description
69
78
  if (Array.isArray(e.tags)) result.tags = e.tags.filter((t): t is string => typeof t === "string")
70
79
  if (Array.isArray(e.examples)) result.examples = e.examples.filter((x): x is string => typeof x === "string")
80
+ if (Array.isArray(e.intents)) {
81
+ const filtered = e.intents.filter((s): s is string => typeof s === "string" && s.trim().length > 0)
82
+ if (filtered.length > 0) result.intents = filtered
83
+ }
71
84
  if (typeof e.intent === "object" && e.intent !== null) {
72
85
  const intent = e.intent as Record<string, unknown>
73
86
  result.intent = {}
@@ -77,67 +90,120 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
77
90
  }
78
91
  if (typeof e.entry === "string" && e.entry) result.entry = e.entry
79
92
  if (e.generated === true) result.generated = true
93
+ if (e.quality === "generated" || e.quality === "curated") result.quality = e.quality
94
+ if (typeof e.confidence === "number" && Number.isFinite(e.confidence)) result.confidence = Math.max(0, Math.min(1, e.confidence))
95
+ if (typeof e.source === "string" && ["package", "frontmatter", "comments", "filename", "manual", "llm"].includes(e.source)) {
96
+ result.source = e.source as StashEntry["source"]
97
+ }
98
+ if (Array.isArray(e.aliases)) {
99
+ const filtered = e.aliases.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
100
+ if (filtered.length > 0) result.aliases = normalizeTerms(filtered)
101
+ }
102
+ if (Array.isArray(e.toc)) {
103
+ const validated = e.toc.filter(
104
+ (h: unknown): h is TocHeading => {
105
+ if (typeof h !== "object" || h === null) return false
106
+ const rec = h as Record<string, unknown>
107
+ return typeof rec.level === "number"
108
+ && typeof rec.text === "string"
109
+ && typeof rec.line === "number"
110
+ },
111
+ )
112
+ if (validated.length > 0) result.toc = validated
113
+ }
80
114
 
81
115
  return result
82
116
  }
83
117
 
84
- function isValidType(type: string): boolean {
85
- return type === "tool" || type === "skill" || type === "command" || type === "agent"
86
- }
87
-
88
118
  // ── Metadata Generation ─────────────────────────────────────────────────────
89
119
 
90
- const SCRIPT_EXTENSIONS = new Set([".sh", ".ts", ".js", ".ps1", ".cmd", ".bat"])
91
-
92
120
  export function generateMetadata(
93
121
  dirPath: string,
94
122
  assetType: AgentikitAssetType,
95
123
  files: string[],
124
+ typeRoot = dirPath,
96
125
  ): StashFile {
97
126
  const entries: StashEntry[] = []
127
+ const pkgMeta = extractPackageMetadata(dirPath)
98
128
 
99
129
  for (const file of files) {
100
130
  const ext = path.extname(file).toLowerCase()
101
131
  const baseName = path.basename(file, ext)
132
+ const fileName = path.basename(file)
102
133
 
103
134
  // 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
135
+ if (!isRelevantAssetFile(assetType, fileName)) continue
136
+
137
+ const canonicalName = assetType === "skill"
138
+ ? deriveCanonicalAssetName(assetType, typeRoot, file) ?? baseName
139
+ : baseName
107
140
 
108
141
  const entry: StashEntry = {
109
- name: baseName,
142
+ name: canonicalName,
110
143
  type: assetType,
111
144
  generated: true,
145
+ quality: "generated",
146
+ confidence: 0.55,
147
+ source: "filename",
112
148
  }
113
149
 
114
- // Priority 3: package.json metadata
115
- const pkgMeta = extractPackageMetadata(dirPath)
150
+ // Priority 1: package.json metadata
116
151
  if (pkgMeta) {
117
- if (pkgMeta.description && !entry.description) entry.description = pkgMeta.description
118
- if (pkgMeta.keywords && pkgMeta.keywords.length > 0) entry.tags = pkgMeta.keywords
152
+ if (pkgMeta.description && !entry.description) {
153
+ entry.description = pkgMeta.description
154
+ entry.source = "package"
155
+ entry.confidence = 0.8
156
+ }
157
+ if (pkgMeta.keywords && pkgMeta.keywords.length > 0) entry.tags = normalizeTerms(pkgMeta.keywords)
119
158
  }
120
159
 
121
- // Priority 2: Frontmatter (for .md files)
160
+ // Priority 2: Frontmatter (for .md files — overrides package.json description)
122
161
  if (ext === ".md") {
123
162
  const fm = extractFrontmatterDescription(file)
124
- if (fm) entry.description = fm
163
+ if (fm) {
164
+ entry.description = fm
165
+ entry.source = "frontmatter"
166
+ entry.confidence = 0.9
167
+ }
168
+ }
169
+
170
+ // Knowledge entries: generate TOC from headings
171
+ if (assetType === "knowledge") {
172
+ try {
173
+ const mdContent = fs.readFileSync(file, "utf8")
174
+ const toc = parseMarkdownToc(mdContent)
175
+ if (toc.headings.length > 0) entry.toc = toc.headings
176
+ } catch {
177
+ // Non-fatal: skip TOC if file can't be read
178
+ }
125
179
  }
126
180
 
127
- // Priority 4: Code comments (for script files)
181
+ // Priority 3: Code comments (for script files)
128
182
  if (SCRIPT_EXTENSIONS.has(ext) && ext !== ".md") {
129
183
  const commentDesc = extractDescriptionFromComments(file)
130
- if (commentDesc && !entry.description) entry.description = commentDesc
184
+ if (commentDesc && !entry.description) {
185
+ entry.description = commentDesc
186
+ entry.source = "comments"
187
+ entry.confidence = 0.7
188
+ }
131
189
  }
132
190
 
133
- // Priority 5: Filename heuristics (fallback)
191
+ // Priority 4: Filename heuristics (fallback)
134
192
  if (!entry.description) {
135
193
  entry.description = fileNameToDescription(baseName)
194
+ entry.source = "filename"
195
+ entry.confidence = Math.min(entry.confidence ?? 0.55, 0.55)
136
196
  }
137
197
  if (!entry.tags || entry.tags.length === 0) {
138
198
  entry.tags = extractTagsFromPath(file, dirPath)
139
199
  }
140
200
 
201
+ entry.tags = normalizeTerms(entry.tags ?? [])
202
+ entry.aliases = buildAliases(canonicalName, entry.tags)
203
+
204
+ // Intents are only generated when LLM is configured (via enhanceStashWithLlm)
205
+ // Heuristic intents are too noisy to be useful for search quality
206
+
141
207
  entry.entry = path.basename(file)
142
208
  entries.push(entry)
143
209
  }
@@ -145,6 +211,74 @@ export function generateMetadata(
145
211
  return { entries }
146
212
  }
147
213
 
214
+
215
+ function normalizeTerms(values: string[]): string[] {
216
+ const normalized = new Set<string>()
217
+ for (const value of values) {
218
+ const cleaned = value.toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim()
219
+ if (!cleaned) continue
220
+ normalized.add(cleaned)
221
+ if (cleaned.endsWith("s") && cleaned.length > 3) {
222
+ normalized.add(cleaned.slice(0, -1))
223
+ }
224
+ }
225
+ return Array.from(normalized)
226
+ }
227
+
228
+ function buildAliases(name: string, tags: string[]): string[] {
229
+ const aliases = new Set<string>()
230
+ const spaced = name.replace(/[-_]+/g, " ").trim().toLowerCase()
231
+ if (spaced && spaced !== name.toLowerCase()) aliases.add(spaced)
232
+ if (tags.length > 1) aliases.add(tags.join(" "))
233
+ return Array.from(aliases)
234
+ }
235
+
236
+ // ── Intent Generation ────────────────────────────────────────────────────────
237
+
238
+ export function generateIntents(description: string, tags: string[], name: string): string[] {
239
+ const intents = new Set<string>()
240
+
241
+ // Split name on separators to extract tokens and potential verb
242
+ const nameTokens = name
243
+ .replace(/[-_]+/g, " ")
244
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
245
+ .toLowerCase()
246
+ .trim()
247
+ .split(/\s+/)
248
+ .filter((t) => t.length > 1)
249
+
250
+ // Intent from name as phrase (e.g. "summarize diff")
251
+ const namePhrase = nameTokens.join(" ")
252
+ if (namePhrase.length > 2) intents.add(namePhrase)
253
+
254
+ // Intent from description (lowercased)
255
+ const desc = description.toLowerCase().trim()
256
+ if (desc.length > 2) intents.add(desc)
257
+
258
+ // Combine first name token (potential verb) with tags
259
+ // e.g. name "summarize-diff", tags ["git"] → "summarize git diff"
260
+ if (nameTokens.length >= 1 && tags.length > 0) {
261
+ const verb = nameTokens[0]
262
+ const rest = nameTokens.slice(1).join(" ")
263
+ for (const tag of tags) {
264
+ const tagLower = tag.toLowerCase()
265
+ // verb + tag + rest (e.g. "summarize git diff")
266
+ const parts = [verb, tagLower, rest].filter((p) => p.length > 0)
267
+ const phrase = parts.join(" ")
268
+ if (phrase !== namePhrase && phrase.length > 2) intents.add(phrase)
269
+ }
270
+ }
271
+
272
+ // Join tag pairs (e.g. ["git", "diff"] → "git diff")
273
+ if (tags.length >= 2) {
274
+ const tagPhrase = tags.map((t) => t.toLowerCase()).join(" ")
275
+ if (tagPhrase.length > 2) intents.add(tagPhrase)
276
+ }
277
+
278
+ // Cap at 8 intents
279
+ return Array.from(intents).slice(0, 8)
280
+ }
281
+
148
282
  export function extractDescriptionFromComments(filePath: string): string | null {
149
283
  let content: string
150
284
  try {
@@ -195,14 +329,8 @@ export function extractFrontmatterDescription(filePath: string): string | null {
195
329
  return null
196
330
  }
197
331
 
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
332
+ const parsed = parseFrontmatter(content)
333
+ return toStringOrUndefined(parsed.data.description) ?? null
206
334
  }
207
335
 
208
336
  export function extractPackageMetadata(
@@ -0,0 +1,200 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { IS_WINDOWS } from "./common"
5
+ import { RG_BINARY, resolveRg } from "./ripgrep-resolve"
6
+
7
+ /**
8
+ * Platform and architecture detection for ripgrep binary downloads.
9
+ */
10
+ function getRgPlatformTarget(): { platform: string; arch: string; ext: string } | null {
11
+ const platform = process.platform
12
+ const arch = process.arch
13
+
14
+ if (platform === "linux" && arch === "x64") {
15
+ return { platform: "x86_64-unknown-linux-musl", arch: "x64", ext: ".tar.gz" }
16
+ }
17
+ if (platform === "linux" && arch === "arm64") {
18
+ return { platform: "aarch64-unknown-linux-gnu", arch: "arm64", ext: ".tar.gz" }
19
+ }
20
+ if (platform === "darwin" && arch === "x64") {
21
+ return { platform: "x86_64-apple-darwin", arch: "x64", ext: ".tar.gz" }
22
+ }
23
+ if (platform === "darwin" && arch === "arm64") {
24
+ return { platform: "aarch64-apple-darwin", arch: "arm64", ext: ".tar.gz" }
25
+ }
26
+ if (platform === "win32" && arch === "x64") {
27
+ return { platform: "x86_64-pc-windows-msvc", arch: "x64", ext: ".zip" }
28
+ }
29
+
30
+ return null
31
+ }
32
+
33
+ const RG_VERSION = "14.1.1"
34
+
35
+ export interface EnsureRgResult {
36
+ rgPath: string
37
+ installed: boolean
38
+ version: string
39
+ }
40
+
41
+ /**
42
+ * Ensure ripgrep is available. If not found on PATH or in stash/bin,
43
+ * download and install it to stash/bin.
44
+ *
45
+ * Returns the path to the ripgrep binary and whether it was newly installed.
46
+ */
47
+ export function ensureRg(stashDir: string): EnsureRgResult {
48
+ // Already available?
49
+ const existing = resolveRg(stashDir)
50
+ if (existing) {
51
+ return { rgPath: existing, installed: false, version: getRgVersion(existing) }
52
+ }
53
+
54
+ // Determine platform
55
+ const target = getRgPlatformTarget()
56
+ if (!target) {
57
+ throw new Error(
58
+ `Unsupported platform for ripgrep auto-install: ${process.platform}/${process.arch}. ` +
59
+ `Install ripgrep manually: https://github.com/BurntSushi/ripgrep#installation`
60
+ )
61
+ }
62
+
63
+ const binDir = path.join(stashDir, "bin")
64
+ if (!fs.existsSync(binDir)) {
65
+ fs.mkdirSync(binDir, { recursive: true })
66
+ }
67
+
68
+ const archiveName = `ripgrep-${RG_VERSION}-${target.platform}`
69
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${archiveName}${target.ext}`
70
+ const destBinary = path.join(binDir, RG_BINARY)
71
+
72
+ if (target.ext === ".tar.gz") {
73
+ downloadAndExtractTarGz(url, archiveName, destBinary)
74
+ } else {
75
+ downloadAndExtractZip(url, archiveName, destBinary)
76
+ }
77
+
78
+ // Make executable
79
+ if (!IS_WINDOWS) {
80
+ fs.chmodSync(destBinary, 0o755)
81
+ }
82
+
83
+ return { rgPath: destBinary, installed: true, version: RG_VERSION }
84
+ }
85
+
86
+ function downloadAndExtractTarGz(url: string, archiveName: string, destBinary: string): void {
87
+ const destDir = path.dirname(destBinary)
88
+ const tmpTarGz = path.join(destDir, "rg-download.tar.gz")
89
+
90
+ try {
91
+ // Download archive to a temporary file without using a shell
92
+ const curlResult = spawnSync(
93
+ "curl",
94
+ ["-fsSL", "-o", tmpTarGz, url],
95
+ {
96
+ encoding: "utf8",
97
+ timeout: 60_000,
98
+ }
99
+ )
100
+
101
+ if (curlResult.status !== 0) {
102
+ const err = curlResult.stderr?.trim() || curlResult.error?.message || "unknown error"
103
+ throw new Error(`Failed to download ripgrep from ${url}: ${err}`)
104
+ }
105
+
106
+ // Extract the specific binary from the archive into destDir
107
+ const tarResult = spawnSync(
108
+ "tar",
109
+ [
110
+ "xzf",
111
+ tmpTarGz,
112
+ "--strip-components=1",
113
+ "-C",
114
+ destDir,
115
+ `${archiveName}/rg`,
116
+ ],
117
+ {
118
+ encoding: "utf8",
119
+ timeout: 60_000,
120
+ }
121
+ )
122
+
123
+ if (tarResult.status !== 0) {
124
+ const err = tarResult.stderr?.trim() || tarResult.error?.message || "unknown error"
125
+ throw new Error(`Failed to extract ripgrep from ${url}: ${err}`)
126
+ }
127
+
128
+ if (!fs.existsSync(destBinary)) {
129
+ throw new Error(`ripgrep binary not found at ${destBinary} after extraction`)
130
+ }
131
+ } finally {
132
+ // Best-effort cleanup of temporary archive
133
+ try {
134
+ if (fs.existsSync(tmpTarGz)) {
135
+ fs.unlinkSync(tmpTarGz)
136
+ }
137
+ } catch {
138
+ // ignore cleanup errors
139
+ }
140
+ }
141
+ }
142
+
143
+ function downloadAndExtractZip(url: string, archiveName: string, destBinary: string): void {
144
+ const destDir = path.dirname(destBinary)
145
+ const tmpZip = path.join(destDir, "rg-download.zip")
146
+ const expandedDir = path.join(destDir, archiveName)
147
+ try {
148
+ // Download
149
+ const dlResult = spawnSync("curl", ["-fsSL", "-o", tmpZip, url], {
150
+ encoding: "utf8",
151
+ timeout: 60_000,
152
+ })
153
+ if (dlResult.status !== 0) {
154
+ throw new Error(dlResult.stderr?.trim() || "download failed")
155
+ }
156
+
157
+ // Extract the zip archive using separate spawnSync calls with argument arrays
158
+ // to avoid shell injection via path interpolation in PowerShell -Command strings
159
+ const expandResult = spawnSync("powershell", [
160
+ "-Command",
161
+ "Expand-Archive",
162
+ "-Path", tmpZip,
163
+ "-DestinationPath", destDir,
164
+ "-Force",
165
+ ], {
166
+ encoding: "utf8",
167
+ timeout: 60_000,
168
+ })
169
+ if (expandResult.status !== 0) {
170
+ throw new Error(expandResult.stderr?.trim() || "extraction failed")
171
+ }
172
+
173
+ const srcRgExe = path.join(destDir, archiveName, "rg.exe")
174
+ const moveResult = spawnSync("powershell", [
175
+ "-Command",
176
+ "Move-Item",
177
+ "-Force",
178
+ "-Path", srcRgExe,
179
+ "-Destination", destBinary,
180
+ ], {
181
+ encoding: "utf8",
182
+ timeout: 60_000,
183
+ })
184
+ if (moveResult.status !== 0) {
185
+ throw new Error(moveResult.stderr?.trim() || "move failed")
186
+ }
187
+ } finally {
188
+ if (fs.existsSync(tmpZip)) fs.unlinkSync(tmpZip)
189
+ if (fs.existsSync(expandedDir)) fs.rmSync(expandedDir, { recursive: true, force: true })
190
+ }
191
+ }
192
+
193
+ function getRgVersion(rgPath: string): string {
194
+ const result = spawnSync(rgPath, ["--version"], { encoding: "utf8", timeout: 5_000 })
195
+ if (result.status === 0 && result.stdout) {
196
+ const match = result.stdout.match(/ripgrep\s+([\d.]+)/)
197
+ return match ? match[1] : "unknown"
198
+ }
199
+ return "unknown"
200
+ }
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { IS_WINDOWS } from "./common"
4
+
5
+ export const RG_BINARY = IS_WINDOWS ? "rg.exe" : "rg"
6
+
7
+ function canExecute(filePath: string): boolean {
8
+ if (!fs.existsSync(filePath)) return false
9
+ if (IS_WINDOWS) return true
10
+ try {
11
+ fs.accessSync(filePath, fs.constants.X_OK)
12
+ return true
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ function resolveFromPath(): string | null {
19
+ const rawPath = process.env.PATH
20
+ if (!rawPath) return null
21
+
22
+ const pathEntries = rawPath.split(path.delimiter).filter(Boolean)
23
+
24
+ if (IS_WINDOWS) {
25
+ const pathext = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
26
+ .split(";")
27
+ .filter(Boolean)
28
+ .map((ext) => ext.toLowerCase())
29
+
30
+ for (const entry of pathEntries) {
31
+ const directCandidate = path.join(entry, "rg")
32
+ if (canExecute(directCandidate)) return directCandidate
33
+
34
+ for (const ext of pathext) {
35
+ const candidate = path.join(entry, `rg${ext}`)
36
+ if (canExecute(candidate)) return candidate
37
+ }
38
+ }
39
+ return null
40
+ }
41
+
42
+ for (const entry of pathEntries) {
43
+ const candidate = path.join(entry, "rg")
44
+ if (canExecute(candidate)) return candidate
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Resolve the path to a usable ripgrep binary.
52
+ * Checks in order:
53
+ * 1. stashDir/bin/rg
54
+ * 2. system PATH (rg)
55
+ * Returns null if ripgrep is not available.
56
+ */
57
+ export function resolveRg(stashDir?: string): string | null {
58
+ // Check stash bin directory first
59
+ if (stashDir) {
60
+ const stashRg = path.join(stashDir, "bin", RG_BINARY)
61
+ if (canExecute(stashRg)) return stashRg
62
+ }
63
+
64
+ return resolveFromPath()
65
+ }
66
+
67
+ /**
68
+ * Check if ripgrep is available (either in stash/bin or system PATH).
69
+ */
70
+ export function isRgAvailable(stashDir?: string): boolean {
71
+ return resolveRg(stashDir) !== null
72
+ }