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/common.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { TYPE_DIRS } from "./asset-spec"
|
|
4
|
+
|
|
5
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type AgentikitAssetType = "tool" | "skill" | "command" | "agent" | "knowledge"
|
|
8
|
+
|
|
9
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export const IS_WINDOWS = process.platform === "win32"
|
|
12
|
+
export { SCRIPT_EXTENSIONS, TYPE_DIRS } from "./asset-spec"
|
|
13
|
+
|
|
14
|
+
// ── Validators ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function isAssetType(type: string): type is AgentikitAssetType {
|
|
17
|
+
return type in TYPE_DIRS
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function resolveStashDir(): string {
|
|
23
|
+
const raw = process.env.AGENTIKIT_STASH_DIR?.trim()
|
|
24
|
+
if (!raw) {
|
|
25
|
+
throw new Error("AGENTIKIT_STASH_DIR is not set. Set it to your Agentikit stash path.")
|
|
26
|
+
}
|
|
27
|
+
const stashDir = path.resolve(raw)
|
|
28
|
+
let stat: fs.Stats
|
|
29
|
+
try {
|
|
30
|
+
stat = fs.statSync(stashDir)
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`Unable to read AGENTIKIT_STASH_DIR at "${stashDir}".`)
|
|
33
|
+
}
|
|
34
|
+
if (!stat.isDirectory()) {
|
|
35
|
+
throw new Error(`AGENTIKIT_STASH_DIR must point to a directory: "${stashDir}".`)
|
|
36
|
+
}
|
|
37
|
+
return stashDir
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toPosix(input: string): string {
|
|
41
|
+
return input.replace(/\\/g, "/")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasErrnoCode(error: unknown, code: string): boolean {
|
|
45
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false
|
|
46
|
+
return (error as Record<string, unknown>).code === code
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isWithin(candidate: string, root: string): boolean {
|
|
50
|
+
const normalizedRoot = normalizeFsPathForComparison(path.resolve(root))
|
|
51
|
+
const normalizedCandidate = normalizeFsPathForComparison(path.resolve(candidate))
|
|
52
|
+
const rel = path.relative(normalizedRoot, normalizedCandidate)
|
|
53
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeFsPathForComparison(value: string): string {
|
|
57
|
+
return process.platform === "win32" ? value.toLowerCase() : value
|
|
58
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { resolveStashDir } from "./common"
|
|
4
|
+
import type { RegistryInstalledEntry, RegistrySource } from "./registry-types"
|
|
5
|
+
|
|
6
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface EmbeddingConnectionConfig {
|
|
9
|
+
/** OpenAI-compatible embeddings endpoint (e.g. "http://localhost:11434/v1/embeddings") */
|
|
10
|
+
endpoint: string
|
|
11
|
+
/** Model name to use for embeddings (e.g. "nomic-embed-text") */
|
|
12
|
+
model: string
|
|
13
|
+
/** Optional API key for authenticated endpoints */
|
|
14
|
+
apiKey?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LlmConnectionConfig {
|
|
18
|
+
/** OpenAI-compatible chat completions endpoint (e.g. "http://localhost:11434/v1/chat/completions") */
|
|
19
|
+
endpoint: string
|
|
20
|
+
/** Model name to use (e.g. "llama3.2") */
|
|
21
|
+
model: string
|
|
22
|
+
/** Optional API key for authenticated endpoints */
|
|
23
|
+
apiKey?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentikitConfig {
|
|
27
|
+
/** Whether semantic search is enabled. Default: true */
|
|
28
|
+
semanticSearch: boolean
|
|
29
|
+
/** Additional stash directories to search alongside the primary one */
|
|
30
|
+
additionalStashDirs: string[]
|
|
31
|
+
/** OpenAI-compatible embedding endpoint config. If not set, uses local @xenova/transformers */
|
|
32
|
+
embedding?: EmbeddingConnectionConfig
|
|
33
|
+
/** OpenAI-compatible LLM endpoint config for metadata generation. If not set, uses heuristic generation */
|
|
34
|
+
llm?: LlmConnectionConfig
|
|
35
|
+
/** Installed registry sources and local cache metadata */
|
|
36
|
+
registry?: RegistryConfig
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RegistryConfig {
|
|
40
|
+
installed: RegistryInstalledEntry[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Defaults ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_CONFIG: AgentikitConfig = {
|
|
46
|
+
semanticSearch: true,
|
|
47
|
+
additionalStashDirs: [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function getConfigPath(stashDir: string): string {
|
|
53
|
+
return path.join(stashDir, "config.json")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Load / Save / Update ────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function loadConfig(stashDir?: string): AgentikitConfig {
|
|
59
|
+
const dir = stashDir ?? resolveStashDir()
|
|
60
|
+
const configPath = getConfigPath(dir)
|
|
61
|
+
|
|
62
|
+
let raw: Record<string, unknown>
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(fs.readFileSync(configPath, "utf8"))
|
|
65
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
66
|
+
return { ...DEFAULT_CONFIG }
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
return { ...DEFAULT_CONFIG }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return pickKnownKeys(raw)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function saveConfig(config: AgentikitConfig, stashDir?: string): void {
|
|
76
|
+
const dir = stashDir ?? resolveStashDir()
|
|
77
|
+
const configPath = getConfigPath(dir)
|
|
78
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function updateConfig(
|
|
82
|
+
partial: Partial<AgentikitConfig>,
|
|
83
|
+
stashDir?: string,
|
|
84
|
+
): AgentikitConfig {
|
|
85
|
+
const dir = stashDir ?? resolveStashDir()
|
|
86
|
+
const current = loadConfig(dir)
|
|
87
|
+
const merged: AgentikitConfig = { ...current, ...partial }
|
|
88
|
+
saveConfig(merged, dir)
|
|
89
|
+
return merged
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function pickKnownKeys(raw: Record<string, unknown>): AgentikitConfig {
|
|
95
|
+
const config: AgentikitConfig = { ...DEFAULT_CONFIG }
|
|
96
|
+
|
|
97
|
+
if (typeof raw.semanticSearch === "boolean") {
|
|
98
|
+
config.semanticSearch = raw.semanticSearch
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (Array.isArray(raw.additionalStashDirs)) {
|
|
102
|
+
config.additionalStashDirs = raw.additionalStashDirs.filter(
|
|
103
|
+
(d): d is string => typeof d === "string",
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const embedding = parseConnectionConfig(raw.embedding)
|
|
108
|
+
if (embedding) config.embedding = embedding
|
|
109
|
+
|
|
110
|
+
const llm = parseConnectionConfig(raw.llm)
|
|
111
|
+
if (llm) config.llm = llm
|
|
112
|
+
|
|
113
|
+
const registry = parseRegistryConfig(raw.registry)
|
|
114
|
+
if (registry) config.registry = registry
|
|
115
|
+
|
|
116
|
+
return config
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseConnectionConfig(
|
|
120
|
+
value: unknown,
|
|
121
|
+
): EmbeddingConnectionConfig | LlmConnectionConfig | undefined {
|
|
122
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
123
|
+
const obj = value as Record<string, unknown>
|
|
124
|
+
if (typeof obj.endpoint !== "string" || !obj.endpoint) return undefined
|
|
125
|
+
if (typeof obj.model !== "string" || !obj.model) return undefined
|
|
126
|
+
const result: { endpoint: string; model: string; apiKey?: string } = {
|
|
127
|
+
endpoint: obj.endpoint,
|
|
128
|
+
model: obj.model,
|
|
129
|
+
}
|
|
130
|
+
if (typeof obj.apiKey === "string" && obj.apiKey) {
|
|
131
|
+
result.apiKey = obj.apiKey
|
|
132
|
+
}
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseRegistryConfig(value: unknown): RegistryConfig | undefined {
|
|
137
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
138
|
+
const obj = value as Record<string, unknown>
|
|
139
|
+
if (!Array.isArray(obj.installed)) return undefined
|
|
140
|
+
|
|
141
|
+
const installed = obj.installed
|
|
142
|
+
.map((entry) => parseRegistryInstalledEntry(entry))
|
|
143
|
+
.filter((entry): entry is RegistryInstalledEntry => entry !== undefined)
|
|
144
|
+
|
|
145
|
+
return { installed }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseRegistryInstalledEntry(value: unknown): RegistryInstalledEntry | undefined {
|
|
149
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
150
|
+
const obj = value as Record<string, unknown>
|
|
151
|
+
|
|
152
|
+
const id = asNonEmptyString(obj.id)
|
|
153
|
+
const source = asRegistrySource(obj.source)
|
|
154
|
+
const ref = asNonEmptyString(obj.ref)
|
|
155
|
+
const artifactUrl = asNonEmptyString(obj.artifactUrl)
|
|
156
|
+
const stashRoot = asNonEmptyString(obj.stashRoot)
|
|
157
|
+
const cacheDir = asNonEmptyString(obj.cacheDir)
|
|
158
|
+
const installedAt = asNonEmptyString(obj.installedAt)
|
|
159
|
+
if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt) return undefined
|
|
160
|
+
|
|
161
|
+
const entry: RegistryInstalledEntry = {
|
|
162
|
+
id,
|
|
163
|
+
source,
|
|
164
|
+
ref,
|
|
165
|
+
artifactUrl,
|
|
166
|
+
stashRoot,
|
|
167
|
+
cacheDir,
|
|
168
|
+
installedAt,
|
|
169
|
+
}
|
|
170
|
+
const resolvedVersion = asNonEmptyString(obj.resolvedVersion)
|
|
171
|
+
if (resolvedVersion) entry.resolvedVersion = resolvedVersion
|
|
172
|
+
const resolvedRevision = asNonEmptyString(obj.resolvedRevision)
|
|
173
|
+
if (resolvedRevision) entry.resolvedRevision = resolvedRevision
|
|
174
|
+
return entry
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
178
|
+
return typeof value === "string" && value ? value : undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function asRegistrySource(value: unknown): RegistrySource | undefined {
|
|
182
|
+
return value === "npm" || value === "github" ? value : undefined
|
|
183
|
+
}
|
package/src/embedder.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { EmbeddingConnectionConfig } from "./config"
|
|
2
|
+
|
|
3
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type EmbeddingVector = number[]
|
|
6
|
+
|
|
7
|
+
// ── Singleton local embedder ────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
let localEmbedder: any
|
|
10
|
+
|
|
11
|
+
async function getLocalEmbedder(): Promise<any> {
|
|
12
|
+
if (!localEmbedder) {
|
|
13
|
+
let pipeline: any
|
|
14
|
+
try {
|
|
15
|
+
const mod = await import("@xenova/transformers")
|
|
16
|
+
pipeline = mod.pipeline
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"Semantic search requires @xenova/transformers. Install it with: npm install @xenova/transformers",
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
localEmbedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
|
|
23
|
+
}
|
|
24
|
+
return localEmbedder
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function embedLocal(text: string): Promise<EmbeddingVector> {
|
|
28
|
+
const model = await getLocalEmbedder()
|
|
29
|
+
const result = await model(text, { pooling: "mean", normalize: true })
|
|
30
|
+
return Array.from(result.data) as number[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── OpenAI-compatible remote embedder ───────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async function embedRemote(
|
|
36
|
+
text: string,
|
|
37
|
+
config: EmbeddingConnectionConfig,
|
|
38
|
+
): Promise<EmbeddingVector> {
|
|
39
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
40
|
+
if (config.apiKey) {
|
|
41
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(config.endpoint, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers,
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
input: text,
|
|
49
|
+
model: config.model,
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const body = await response.text().catch(() => "")
|
|
55
|
+
throw new Error(`Embedding request failed (${response.status}): ${body}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const json = (await response.json()) as {
|
|
59
|
+
data: Array<{ embedding: number[] }>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!json.data?.[0]?.embedding) {
|
|
63
|
+
throw new Error("Unexpected embedding response format: missing data[0].embedding")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return json.data[0].embedding
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate an embedding for the given text.
|
|
73
|
+
* If embeddingConfig is provided, uses the configured OpenAI-compatible endpoint.
|
|
74
|
+
* Otherwise falls back to local @xenova/transformers.
|
|
75
|
+
*/
|
|
76
|
+
export async function embed(
|
|
77
|
+
text: string,
|
|
78
|
+
embeddingConfig?: EmbeddingConnectionConfig,
|
|
79
|
+
): Promise<EmbeddingVector> {
|
|
80
|
+
if (embeddingConfig) {
|
|
81
|
+
return embedRemote(text, embeddingConfig)
|
|
82
|
+
}
|
|
83
|
+
return embedLocal(text)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Similarity ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export function cosineSimilarity(a: EmbeddingVector, b: EmbeddingVector): number {
|
|
89
|
+
const len = Math.min(a.length, b.length)
|
|
90
|
+
if (len === 0) return 0
|
|
91
|
+
let dot = 0
|
|
92
|
+
for (let i = 0; i < len; i++) {
|
|
93
|
+
dot += a[i] * b[i]
|
|
94
|
+
}
|
|
95
|
+
return dot
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Availability check ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export async function isEmbeddingAvailable(
|
|
101
|
+
embeddingConfig?: EmbeddingConnectionConfig,
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
if (embeddingConfig) {
|
|
104
|
+
try {
|
|
105
|
+
await embedRemote("test", embeddingConfig)
|
|
106
|
+
return true
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await getLocalEmbedder()
|
|
113
|
+
return true
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared frontmatter parsing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single, canonical YAML-subset frontmatter parser used by both
|
|
5
|
+
* the stash open logic and the metadata generator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse YAML-subset frontmatter from a Markdown (or similar) string.
|
|
10
|
+
*
|
|
11
|
+
* Returns the parsed key-value data and the remaining body content.
|
|
12
|
+
*/
|
|
13
|
+
export function parseFrontmatter(raw: string): {
|
|
14
|
+
data: Record<string, unknown>
|
|
15
|
+
content: string
|
|
16
|
+
frontmatter: string | null
|
|
17
|
+
bodyStartLine: number
|
|
18
|
+
} {
|
|
19
|
+
const parsedBlock = parseFrontmatterBlock(raw)
|
|
20
|
+
if (!parsedBlock) {
|
|
21
|
+
return { data: {}, content: raw, frontmatter: null, bodyStartLine: 1 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const data: Record<string, unknown> = {}
|
|
25
|
+
let currentKey: string | null = null
|
|
26
|
+
let nested: Record<string, unknown> | null = null
|
|
27
|
+
|
|
28
|
+
for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
|
|
29
|
+
const indented = line.match(/^ (\w[\w-]*):\s*(.+)$/)
|
|
30
|
+
if (indented && currentKey && nested) {
|
|
31
|
+
nested[indented[1]] = parseYamlScalar(indented[2].trim())
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const top = line.match(/^(\w[\w-]*):\s*(.*)$/)
|
|
36
|
+
if (!top) {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
currentKey = top[1]
|
|
41
|
+
const value = top[2].trim()
|
|
42
|
+
if (value === "") {
|
|
43
|
+
nested = {}
|
|
44
|
+
data[currentKey] = nested
|
|
45
|
+
} else {
|
|
46
|
+
nested = null
|
|
47
|
+
data[currentKey] = parseYamlScalar(value)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
data,
|
|
52
|
+
content: parsedBlock.content,
|
|
53
|
+
frontmatter: parsedBlock.frontmatter,
|
|
54
|
+
bodyStartLine: parsedBlock.bodyStartLine,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseFrontmatterBlock(
|
|
59
|
+
raw: string,
|
|
60
|
+
): { frontmatter: string; content: string; bodyStartLine: number } | null {
|
|
61
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
62
|
+
if (!match) return null
|
|
63
|
+
return {
|
|
64
|
+
frontmatter: match[1],
|
|
65
|
+
content: match[2],
|
|
66
|
+
bodyStartLine: countLines(raw.slice(0, match[0].length - match[2].length)) + 1,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countLines(text: string): number {
|
|
71
|
+
if (text.length === 0) return 0
|
|
72
|
+
return text.split(/\r?\n/).length - 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse a simple YAML scalar value (string, boolean, or number).
|
|
77
|
+
*/
|
|
78
|
+
export function parseYamlScalar(value: string): unknown {
|
|
79
|
+
if (value === "") return ""
|
|
80
|
+
if (value === "true") return true
|
|
81
|
+
if (value === "false") return false
|
|
82
|
+
const asNumber = Number(value)
|
|
83
|
+
if (!Number.isNaN(asNumber)) return asNumber
|
|
84
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
85
|
+
return value.slice(1, -1)
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Coerce an unknown value to a trimmed string, or return undefined if empty/non-string.
|
|
92
|
+
*/
|
|
93
|
+
export function toStringOrUndefined(value: unknown): string | undefined {
|
|
94
|
+
return typeof value === "string" && value.trim() ? value : undefined
|
|
95
|
+
}
|