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.
- package/README.md +113 -77
- package/dist/index.d.ts +13 -3
- package/dist/index.js +7 -2
- package/dist/src/asset-spec.d.ts +14 -0
- package/dist/src/asset-spec.js +46 -0
- package/dist/src/cli.js +154 -52
- package/dist/src/common.d.ts +8 -0
- package/dist/src/common.js +46 -0
- package/dist/src/config.d.ts +31 -0
- package/dist/src/config.js +74 -0
- package/dist/src/embedder.d.ts +10 -0
- package/dist/src/embedder.js +87 -0
- package/dist/src/frontmatter.d.ts +30 -0
- package/dist/src/frontmatter.js +86 -0
- package/dist/src/indexer.d.ts +20 -2
- package/dist/src/indexer.js +212 -80
- package/dist/src/init.d.ts +19 -0
- package/dist/src/init.js +87 -0
- package/dist/src/llm.d.ts +15 -0
- package/dist/src/llm.js +91 -0
- package/dist/src/markdown.d.ts +18 -0
- package/dist/src/markdown.js +77 -0
- package/dist/src/metadata.d.ts +10 -2
- package/dist/src/metadata.js +146 -30
- package/dist/src/ripgrep-install.d.ts +12 -0
- package/dist/src/ripgrep-install.js +169 -0
- package/dist/src/ripgrep-resolve.d.ts +13 -0
- package/dist/src/ripgrep-resolve.js +68 -0
- package/dist/src/ripgrep.d.ts +3 -36
- package/dist/src/ripgrep.js +2 -262
- package/dist/src/similarity.d.ts +1 -2
- package/dist/src/similarity.js +11 -0
- package/dist/src/stash-ref.d.ts +7 -0
- package/dist/src/stash-ref.js +33 -0
- package/dist/src/stash-resolve.d.ts +2 -0
- package/dist/src/stash-resolve.js +45 -0
- package/dist/src/stash-search.d.ts +6 -0
- package/dist/src/stash-search.js +269 -0
- package/dist/src/stash-show.d.ts +5 -0
- package/dist/src/stash-show.js +107 -0
- package/dist/src/stash-types.d.ts +53 -0
- package/dist/src/stash-types.js +1 -0
- package/dist/src/stash.d.ts +8 -63
- package/dist/src/stash.js +4 -633
- package/dist/src/tool-runner.d.ts +35 -0
- package/dist/src/tool-runner.js +100 -0
- package/dist/src/walker.d.ts +19 -0
- package/dist/src/walker.js +47 -0
- package/package.json +8 -14
- package/src/asset-spec.ts +69 -0
- package/src/cli.ts +164 -48
- package/src/common.ts +58 -0
- package/src/config.ts +124 -0
- package/src/embedder.ts +117 -0
- package/src/frontmatter.ts +95 -0
- package/src/indexer.ts +244 -84
- package/src/init.ts +106 -0
- package/src/llm.ts +124 -0
- package/src/markdown.ts +106 -0
- package/src/metadata.ts +157 -29
- package/src/ripgrep-install.ts +200 -0
- package/src/ripgrep-resolve.ts +72 -0
- package/src/ripgrep.ts +3 -315
- package/src/similarity.ts +13 -1
- package/src/stash-ref.ts +41 -0
- package/src/stash-resolve.ts +47 -0
- package/src/stash-search.ts +343 -0
- package/src/stash-show.ts +104 -0
- package/src/stash-types.ts +46 -0
- package/src/stash.ts +16 -760
- package/src/tool-runner.ts +129 -0
- package/src/walker.ts +53 -0
- package/.claude-plugin/plugin.json +0 -21
- package/commands/open.md +0 -11
- package/commands/run.md +0 -11
- package/commands/search.md +0 -11
- package/dist/src/plugin.d.ts +0 -2
- package/dist/src/plugin.js +0 -55
- package/skills/stash/SKILL.md +0 -73
- package/src/plugin.ts +0 -56
package/src/markdown.ts
ADDED
|
@@ -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
|
|
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" || !
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
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
|
|
115
|
-
const pkgMeta = extractPackageMetadata(dirPath)
|
|
150
|
+
// Priority 1: package.json metadata
|
|
116
151
|
if (pkgMeta) {
|
|
117
|
-
if (pkgMeta.description && !entry.description)
|
|
118
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
199
|
-
|
|
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
|
+
}
|