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.
- package/README.md +215 -76
- package/dist/index.d.ts +17 -3
- package/dist/index.js +10 -2
- package/dist/src/asset-spec.d.ts +14 -0
- package/dist/src/asset-spec.js +46 -0
- package/dist/src/cli.js +268 -57
- package/dist/src/common.d.ts +8 -0
- package/dist/src/common.js +46 -0
- package/dist/src/config.d.ts +37 -0
- package/dist/src/config.js +124 -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 +11 -2
- package/dist/src/metadata.js +161 -29
- package/dist/src/registry-install.d.ts +11 -0
- package/dist/src/registry-install.js +208 -0
- package/dist/src/registry-resolve.d.ts +3 -0
- package/dist/src/registry-resolve.js +231 -0
- package/dist/src/registry-search.d.ts +5 -0
- package/dist/src/registry-search.js +129 -0
- package/dist/src/registry-types.d.ts +55 -0
- package/dist/src/registry-types.js +1 -0
- 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-add.d.ts +4 -0
- package/dist/src/stash-add.js +59 -0
- package/dist/src/stash-ref.d.ts +7 -0
- package/dist/src/stash-ref.js +33 -0
- package/dist/src/stash-registry.d.ts +18 -0
- package/dist/src/stash-registry.js +221 -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 +8 -0
- package/dist/src/stash-search.js +484 -0
- package/dist/src/stash-show.d.ts +5 -0
- package/dist/src/stash-show.js +114 -0
- package/dist/src/stash-types.d.ts +217 -0
- package/dist/src/stash-types.js +1 -0
- package/dist/src/stash.d.ts +10 -63
- package/dist/src/stash.js +6 -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 +282 -46
- package/src/common.ts +58 -0
- package/src/config.ts +183 -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 +171 -27
- package/src/registry-install.ts +245 -0
- package/src/registry-resolve.ts +272 -0
- package/src/registry-search.ts +145 -0
- package/src/registry-types.ts +64 -0
- 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-add.ts +66 -0
- package/src/stash-ref.ts +41 -0
- package/src/stash-registry.ts +259 -0
- package/src/stash-resolve.ts +47 -0
- package/src/stash-search.ts +595 -0
- package/src/stash-show.ts +112 -0
- package/src/stash-types.ts +221 -0
- package/src/stash.ts +31 -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,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" || !
|
|
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
|
|
85
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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:
|
|
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
|
|
115
|
-
const pkgMeta = extractPackageMetadata(dirPath)
|
|
166
|
+
// Priority 1: package.json metadata
|
|
116
167
|
if (pkgMeta) {
|
|
117
|
-
if (pkgMeta.description && !entry.description)
|
|
118
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|