agentikit 0.0.8 → 0.0.12

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 (112) hide show
  1. package/README.md +135 -117
  2. package/dist/index.d.ts +13 -3
  3. package/dist/index.js +7 -1
  4. package/dist/src/asset-spec.d.ts +2 -0
  5. package/dist/src/asset-spec.js +22 -3
  6. package/dist/src/asset-type-handler.d.ts +27 -0
  7. package/dist/src/asset-type-handler.js +33 -0
  8. package/dist/src/cli.js +335 -100
  9. package/dist/src/common.d.ts +6 -1
  10. package/dist/src/common.js +18 -4
  11. package/dist/src/config-cli.d.ts +9 -0
  12. package/dist/src/config-cli.js +473 -0
  13. package/dist/src/config.d.ts +25 -6
  14. package/dist/src/config.js +188 -28
  15. package/dist/src/db.d.ts +46 -0
  16. package/dist/src/db.js +299 -0
  17. package/dist/src/embedder.js +12 -7
  18. package/dist/src/github.d.ts +4 -0
  19. package/dist/src/github.js +19 -0
  20. package/dist/src/handlers/agent-handler.d.ts +2 -0
  21. package/dist/src/handlers/agent-handler.js +26 -0
  22. package/dist/src/handlers/command-handler.d.ts +2 -0
  23. package/dist/src/handlers/command-handler.js +23 -0
  24. package/dist/src/handlers/index.d.ts +6 -0
  25. package/dist/src/handlers/index.js +23 -0
  26. package/dist/src/handlers/knowledge-handler.d.ts +2 -0
  27. package/dist/src/handlers/knowledge-handler.js +56 -0
  28. package/dist/src/handlers/markdown-helpers.d.ts +7 -0
  29. package/dist/src/handlers/markdown-helpers.js +15 -0
  30. package/dist/src/handlers/script-handler.d.ts +2 -0
  31. package/dist/src/handlers/script-handler.js +78 -0
  32. package/dist/src/handlers/skill-handler.d.ts +2 -0
  33. package/dist/src/handlers/skill-handler.js +30 -0
  34. package/dist/src/handlers/tool-handler.d.ts +2 -0
  35. package/dist/src/handlers/tool-handler.js +58 -0
  36. package/dist/src/indexer.d.ts +1 -23
  37. package/dist/src/indexer.js +162 -155
  38. package/dist/src/init.d.ts +2 -2
  39. package/dist/src/init.js +21 -9
  40. package/dist/src/llm.js +4 -3
  41. package/dist/src/metadata.d.ts +1 -1
  42. package/dist/src/metadata.js +22 -64
  43. package/dist/src/origin-resolve.d.ts +19 -0
  44. package/dist/src/origin-resolve.js +53 -0
  45. package/dist/src/registry-install.d.ts +11 -0
  46. package/dist/src/registry-install.js +315 -0
  47. package/dist/src/registry-resolve.d.ts +3 -0
  48. package/dist/src/registry-resolve.js +299 -0
  49. package/dist/src/registry-search.d.ts +27 -0
  50. package/dist/src/registry-search.js +263 -0
  51. package/dist/src/registry-types.d.ts +62 -0
  52. package/dist/src/registry-types.js +1 -0
  53. package/dist/src/stash-add.d.ts +4 -0
  54. package/dist/src/stash-add.js +59 -0
  55. package/dist/src/stash-clone.d.ts +22 -0
  56. package/dist/src/stash-clone.js +83 -0
  57. package/dist/src/stash-ref.d.ts +27 -3
  58. package/dist/src/stash-ref.js +63 -24
  59. package/dist/src/stash-registry.d.ts +18 -0
  60. package/dist/src/stash-registry.js +221 -0
  61. package/dist/src/stash-resolve.js +3 -0
  62. package/dist/src/stash-search.d.ts +3 -1
  63. package/dist/src/stash-search.js +357 -138
  64. package/dist/src/stash-show.d.ts +1 -1
  65. package/dist/src/stash-show.js +28 -89
  66. package/dist/src/stash-source.d.ts +24 -0
  67. package/dist/src/stash-source.js +81 -0
  68. package/dist/src/stash-types.d.ts +175 -1
  69. package/dist/src/stash.d.ts +9 -1
  70. package/dist/src/stash.js +5 -0
  71. package/dist/src/tool-runner.d.ts +1 -1
  72. package/dist/src/tool-runner.js +18 -5
  73. package/package.json +7 -2
  74. package/src/asset-spec.ts +20 -4
  75. package/src/asset-type-handler.ts +77 -0
  76. package/src/cli.ts +354 -103
  77. package/src/common.ts +23 -5
  78. package/src/config-cli.ts +499 -0
  79. package/src/config.ts +218 -37
  80. package/src/db.ts +411 -0
  81. package/src/embedder.ts +22 -11
  82. package/src/github.ts +21 -0
  83. package/src/handlers/agent-handler.ts +32 -0
  84. package/src/handlers/command-handler.ts +29 -0
  85. package/src/handlers/index.ts +25 -0
  86. package/src/handlers/knowledge-handler.ts +62 -0
  87. package/src/handlers/markdown-helpers.ts +19 -0
  88. package/src/handlers/script-handler.ts +92 -0
  89. package/src/handlers/skill-handler.ts +37 -0
  90. package/src/handlers/tool-handler.ts +71 -0
  91. package/src/indexer.ts +208 -187
  92. package/src/init.ts +17 -9
  93. package/src/llm.ts +4 -3
  94. package/src/metadata.ts +21 -65
  95. package/src/origin-resolve.ts +67 -0
  96. package/src/registry-install.ts +361 -0
  97. package/src/registry-resolve.ts +341 -0
  98. package/src/registry-search.ts +335 -0
  99. package/src/registry-types.ts +72 -0
  100. package/src/stash-add.ts +63 -0
  101. package/src/stash-clone.ts +127 -0
  102. package/src/stash-ref.ts +84 -26
  103. package/src/stash-registry.ts +259 -0
  104. package/src/stash-resolve.ts +3 -0
  105. package/src/stash-search.ts +425 -155
  106. package/src/stash-show.ts +33 -82
  107. package/src/stash-source.ts +103 -0
  108. package/src/stash-types.ts +186 -1
  109. package/src/stash.ts +23 -0
  110. package/src/tool-runner.ts +18 -5
  111. package/dist/src/similarity.d.ts +0 -34
  112. package/src/similarity.ts +0 -271
package/src/init.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agentikit initialization logic.
3
3
  *
4
- * Creates the stash directory structure, sets the AGENTIKIT_STASH_DIR
4
+ * Creates the working stash directory structure, sets the AKM_STASH_DIR
5
5
  * environment variable, and ensures ripgrep is available.
6
6
  */
7
7
 
@@ -25,7 +25,7 @@ export interface InitResponse {
25
25
  }
26
26
  }
27
27
 
28
- export function agentikitInit(): InitResponse {
28
+ export async function agentikitInit(): Promise<InitResponse> {
29
29
  let stashDir: string
30
30
  if (IS_WINDOWS) {
31
31
  const userProfile = process.env.USERPROFILE?.trim()
@@ -59,7 +59,7 @@ export function agentikitInit(): InitResponse {
59
59
  let profileUpdated: string | undefined
60
60
 
61
61
  if (IS_WINDOWS) {
62
- const result = spawnSync("setx", ["AGENTIKIT_STASH_DIR", stashDir], {
62
+ const result = spawnSync("setx", ["AKM_STASH_DIR", stashDir], {
63
63
  encoding: "utf8",
64
64
  timeout: 10_000,
65
65
  })
@@ -76,22 +76,30 @@ export function agentikitInit(): InitResponse {
76
76
  profile = path.join(homeDir, ".profile")
77
77
  }
78
78
 
79
- const exportLine = `export AGENTIKIT_STASH_DIR="${stashDir}"`
79
+ const exportLine = `export AKM_STASH_DIR="${stashDir}"`
80
80
  const existing = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : ""
81
- if (!existing.includes("AGENTIKIT_STASH_DIR")) {
82
- fs.appendFileSync(profile, `\n# Agentikit stash directory\n${exportLine}\n`)
81
+ if (!existing.includes("AKM_STASH_DIR")) {
82
+ const updated = existing + `\n# Agentikit working stash directory\n${exportLine}\n`
83
+ const tmpPath = profile + `.tmp.${process.pid}`
84
+ try {
85
+ fs.writeFileSync(tmpPath, updated, "utf8")
86
+ fs.renameSync(tmpPath, profile)
87
+ } catch (err) {
88
+ try { fs.unlinkSync(tmpPath) } catch { /* ignore */ }
89
+ throw err
90
+ }
83
91
  envSet = true
84
92
  profileUpdated = profile
85
93
  }
86
94
  }
87
95
 
88
96
  // Create default config.json if it doesn't exist
89
- const configPath = getConfigPath(stashDir)
97
+ const configPath = getConfigPath()
90
98
  if (!fs.existsSync(configPath)) {
91
- saveConfig(DEFAULT_CONFIG, stashDir)
99
+ saveConfig(DEFAULT_CONFIG)
92
100
  }
93
101
 
94
- process.env.AGENTIKIT_STASH_DIR = stashDir
102
+ process.env.AKM_STASH_DIR = stashDir
95
103
 
96
104
  // Ensure ripgrep is available (install to stash/bin if needed)
97
105
  let ripgrep: InitResponse["ripgrep"]
package/src/llm.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { LlmConnectionConfig } from "./config"
2
+ import { fetchWithTimeout } from "./common"
2
3
  import type { StashEntry } from "./metadata"
3
4
 
4
5
  // ── OpenAI-compatible chat completions ──────────────────────────────────────
@@ -21,14 +22,14 @@ async function chatCompletion(
21
22
  headers["Authorization"] = `Bearer ${config.apiKey}`
22
23
  }
23
24
 
24
- const response = await fetch(config.endpoint, {
25
+ const response = await fetchWithTimeout(config.endpoint, {
25
26
  method: "POST",
26
27
  headers,
27
28
  body: JSON.stringify({
28
29
  model: config.model,
29
30
  messages,
30
- temperature: 0.3,
31
- max_tokens: 512,
31
+ temperature: config.temperature ?? 0.3,
32
+ max_tokens: config.maxTokens ?? 512,
32
33
  }),
33
34
  })
34
35
 
package/src/metadata.ts CHANGED
@@ -4,6 +4,7 @@ import { type AgentikitAssetType, isAssetType } from "./common"
4
4
  import { SCRIPT_EXTENSIONS, isRelevantAssetFile, deriveCanonicalAssetName } from "./asset-spec"
5
5
  import { parseFrontmatter, toStringOrUndefined } from "./frontmatter"
6
6
  import { parseMarkdownToc, type TocHeading } from "./markdown"
7
+ import { tryGetHandler } from "./asset-type-handler"
7
8
 
8
9
  // ── Schema ──────────────────────────────────────────────────────────────────
9
10
 
@@ -28,6 +29,7 @@ export interface StashEntry {
28
29
  source?: "package" | "frontmatter" | "comments" | "filename" | "manual" | "llm"
29
30
  aliases?: string[]
30
31
  toc?: TocHeading[]
32
+ usage?: string[]
31
33
  }
32
34
 
33
35
  export interface StashFile {
@@ -111,10 +113,25 @@ export function validateStashEntry(entry: unknown): StashEntry | null {
111
113
  )
112
114
  if (validated.length > 0) result.toc = validated
113
115
  }
116
+ const usage = normalizeNonEmptyStringList(e.usage)
117
+ if (usage) result.usage = usage
114
118
 
115
119
  return result
116
120
  }
117
121
 
122
+ function normalizeNonEmptyStringList(value: unknown): string[] | undefined {
123
+ if (typeof value === "string") {
124
+ const trimmed = value.trim()
125
+ return trimmed ? [trimmed] : undefined
126
+ }
127
+ if (!Array.isArray(value)) return undefined
128
+ const filtered = value
129
+ .filter((item): item is string => typeof item === "string")
130
+ .map((item) => item.trim())
131
+ .filter((item) => item.length > 0)
132
+ return filtered.length > 0 ? filtered : undefined
133
+ }
134
+
118
135
  // ── Metadata Generation ─────────────────────────────────────────────────────
119
136
 
120
137
  export function generateMetadata(
@@ -167,25 +184,10 @@ export function generateMetadata(
167
184
  }
168
185
  }
169
186
 
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
- }
179
- }
180
-
181
- // Priority 3: Code comments (for script files)
182
- if (SCRIPT_EXTENSIONS.has(ext) && ext !== ".md") {
183
- const commentDesc = extractDescriptionFromComments(file)
184
- if (commentDesc && !entry.description) {
185
- entry.description = commentDesc
186
- entry.source = "comments"
187
- entry.confidence = 0.7
188
- }
187
+ // Type-specific metadata extraction (e.g. TOC for knowledge, comments for tools/scripts)
188
+ const handler = tryGetHandler(assetType)
189
+ if (handler?.extractTypeMetadata) {
190
+ handler.extractTypeMetadata(entry, file, ext)
189
191
  }
190
192
 
191
193
  // Priority 4: Filename heuristics (fallback)
@@ -233,52 +235,6 @@ function buildAliases(name: string, tags: string[]): string[] {
233
235
  return Array.from(aliases)
234
236
  }
235
237
 
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
-
282
238
  export function extractDescriptionFromComments(filePath: string): string | null {
283
239
  let content: string
284
240
  try {
@@ -0,0 +1,67 @@
1
+ import path from "node:path"
2
+ import type { StashSource } from "./stash-source"
3
+ import { parseRegistryRef } from "./registry-resolve"
4
+
5
+ /**
6
+ * Given an origin string (from an AssetRef) and the full list of stash
7
+ * sources, return the subset of sources to search.
8
+ *
9
+ * Resolution order:
10
+ * 1. undefined → all sources (working → mounted → installed)
11
+ * 2. "local" → working stash only
12
+ * 3. exact match → installed source whose registryId matches verbatim
13
+ * 4. parsed match → parse origin as a registry ref, match by parsed ID
14
+ * 5. path match → mounted source whose path matches
15
+ * 6. empty → indicates a remote/uninstalled origin (caller decides)
16
+ */
17
+ export function resolveSourcesForOrigin(
18
+ origin: string | undefined,
19
+ allSources: StashSource[],
20
+ ): StashSource[] {
21
+ if (!origin) return allSources
22
+
23
+ if (origin === "local") {
24
+ return allSources.filter((s) => s.kind === "working")
25
+ }
26
+
27
+ // Exact registryId match (e.g. origin is "npm:@scope/pkg")
28
+ const byExactId = allSources.filter(
29
+ (s) => s.kind === "installed" && s.registryId === origin,
30
+ )
31
+ if (byExactId.length > 0) return byExactId
32
+
33
+ // Parse origin as a registry ref and match by parsed ID.
34
+ // Allows shorthand: "owner/repo" matches "github:owner/repo",
35
+ // "@scope/pkg" matches "npm:@scope/pkg".
36
+ try {
37
+ const parsed = parseRegistryRef(origin)
38
+ const byParsedId = allSources.filter(
39
+ (s) => s.kind === "installed" && s.registryId === parsed.id,
40
+ )
41
+ if (byParsedId.length > 0) return byParsedId
42
+ } catch {
43
+ // Not a valid registry ref — continue to path matching
44
+ }
45
+
46
+ // Mounted stash by resolved path
47
+ const resolvedOrigin = path.resolve(origin)
48
+ const byPath = allSources.filter(
49
+ (s) => s.kind === "mounted" && path.resolve(s.path) === resolvedOrigin,
50
+ )
51
+ if (byPath.length > 0) return byPath
52
+
53
+ // No match — origin may be remote/uninstalled
54
+ return []
55
+ }
56
+
57
+ /**
58
+ * Check whether an origin refers to something that could be fetched remotely
59
+ * (i.e. it looks like a registry ref but isn't installed locally).
60
+ */
61
+ export function isRemoteOrigin(
62
+ origin: string,
63
+ allSources: StashSource[],
64
+ ): boolean {
65
+ if (origin === "local") return false
66
+ return resolveSourcesForOrigin(origin, allSources).length === 0
67
+ }
@@ -0,0 +1,361 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import { fetchWithTimeout, isWithin, TYPE_DIRS } from "./common"
5
+ import { loadConfig, saveConfig, type AgentikitConfig } from "./config"
6
+ import { parseRegistryRef, resolveRegistryArtifact } from "./registry-resolve"
7
+ import type { ParsedGitRef, 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
+ if (parsed.source === "git") {
19
+ return installGitRegistryRef(parsed, options)
20
+ }
21
+ const resolved = await resolveRegistryArtifact(parsed)
22
+
23
+ const installedAt = (options?.now ?? new Date()).toISOString()
24
+ const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheRootDir()
25
+ const cacheDir = buildInstallCacheDir(cacheRootDir, resolved.source, resolved.id)
26
+ const archivePath = path.join(cacheDir, "artifact.tar.gz")
27
+ const extractedDir = path.join(cacheDir, "extracted")
28
+
29
+ fs.mkdirSync(cacheDir, { recursive: true })
30
+
31
+ await downloadArchive(resolved.artifactUrl, archivePath)
32
+ extractTarGzSecure(archivePath, extractedDir)
33
+
34
+ const provisionalKitRoot = detectStashRoot(extractedDir)
35
+ const installRoot = applyAgentikitIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot
36
+ const stashRoot = detectStashRoot(installRoot)
37
+
38
+ return {
39
+ id: resolved.id,
40
+ source: resolved.source,
41
+ ref: resolved.ref,
42
+ artifactUrl: resolved.artifactUrl,
43
+ resolvedVersion: resolved.resolvedVersion,
44
+ resolvedRevision: resolved.resolvedRevision,
45
+ installedAt,
46
+ cacheDir,
47
+ extractedDir,
48
+ stashRoot,
49
+ }
50
+ }
51
+
52
+ async function installGitRegistryRef(parsed: ParsedGitRef, options?: InstallRegistryRefOptions): Promise<RegistryInstallResult> {
53
+ const resolved = await resolveRegistryArtifact(parsed)
54
+ const installedAt = (options?.now ?? new Date()).toISOString()
55
+ const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheRootDir()
56
+ const cacheDir = buildInstallCacheDir(cacheRootDir, parsed.source, parsed.id)
57
+ const extractedDir = path.join(cacheDir, "extracted")
58
+
59
+ fs.mkdirSync(cacheDir, { recursive: true })
60
+ fs.rmSync(extractedDir, { recursive: true, force: true })
61
+ fs.mkdirSync(extractedDir, { recursive: true })
62
+
63
+ const includeConfig = findNearestAgentikitIncludeConfig(parsed.sourcePath, parsed.repoRoot)
64
+ if (includeConfig) {
65
+ copyIncludedPaths(includeConfig.baseDir, includeConfig.include, extractedDir)
66
+ } else {
67
+ copyDirectoryContents(parsed.sourcePath, extractedDir)
68
+ }
69
+
70
+ const stashRoot = detectStashRoot(extractedDir)
71
+
72
+ return {
73
+ id: resolved.id,
74
+ source: resolved.source,
75
+ ref: resolved.ref,
76
+ artifactUrl: resolved.artifactUrl,
77
+ resolvedVersion: resolved.resolvedVersion,
78
+ resolvedRevision: resolved.resolvedRevision,
79
+ installedAt,
80
+ cacheDir,
81
+ extractedDir,
82
+ stashRoot,
83
+ }
84
+ }
85
+
86
+ export function upsertInstalledRegistryEntry(entry: RegistryInstalledEntry): AgentikitConfig {
87
+ const current = loadConfig()
88
+ const currentInstalled = current.registry?.installed ?? []
89
+ const withoutExisting = currentInstalled.filter((item) => item.id !== entry.id)
90
+ const nextInstalled = [...withoutExisting, normalizeInstalledEntry(entry)]
91
+
92
+ const nextConfig: AgentikitConfig = {
93
+ ...current,
94
+ registry: { installed: nextInstalled },
95
+ }
96
+ saveConfig(nextConfig)
97
+ return nextConfig
98
+ }
99
+
100
+ export function removeInstalledRegistryEntry(id: string): AgentikitConfig {
101
+ const current = loadConfig()
102
+ const currentInstalled = current.registry?.installed ?? []
103
+ const nextInstalled = currentInstalled.filter((item) => item.id !== id)
104
+
105
+ const nextConfig: AgentikitConfig = {
106
+ ...current,
107
+ registry: nextInstalled.length > 0 ? { installed: nextInstalled } : undefined,
108
+ }
109
+ saveConfig(nextConfig)
110
+ return nextConfig
111
+ }
112
+
113
+ export function getRegistryCacheRootDir(): string {
114
+ const xdgCache = process.env.XDG_CACHE_HOME?.trim()
115
+ if (xdgCache) {
116
+ return path.join(path.resolve(xdgCache), "agentikit", "registry")
117
+ }
118
+ const home = process.env.HOME?.trim()
119
+ if (!home) {
120
+ throw new Error("Unable to determine cache directory. Set XDG_CACHE_HOME or HOME.")
121
+ }
122
+ return path.join(path.resolve(home), ".cache", "agentikit", "registry")
123
+ }
124
+
125
+ export function detectStashRoot(extractedDir: string): string {
126
+ const root = path.resolve(extractedDir)
127
+
128
+ const rootDotStash = path.join(root, ".stash")
129
+ if (isDirectory(rootDotStash)) {
130
+ return root
131
+ }
132
+
133
+ if (hasStashDirs(root)) {
134
+ return root
135
+ }
136
+
137
+ const opencodeDir = path.join(root, "opencode")
138
+ if (hasStashDirs(opencodeDir)) {
139
+ return opencodeDir
140
+ }
141
+
142
+ const shallowest = findShallowestDotStashRoot(root)
143
+ if (shallowest) return shallowest
144
+
145
+ return root
146
+ }
147
+
148
+ function buildInstallCacheDir(cacheRootDir: string, source: RegistrySource, id: string): string {
149
+ const slug = `${source}-${id.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "")}`
150
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
151
+ return path.join(cacheRootDir, slug || source, stamp)
152
+ }
153
+
154
+ function applyAgentikitIncludeConfig(
155
+ sourceRoot: string,
156
+ cacheDir: string,
157
+ searchRoot: string = sourceRoot,
158
+ ): string | undefined {
159
+ const includeConfig = findNearestAgentikitIncludeConfig(sourceRoot, searchRoot)
160
+ if (!includeConfig) return undefined
161
+
162
+ const selectedDir = path.join(cacheDir, "selected")
163
+ fs.rmSync(selectedDir, { recursive: true, force: true })
164
+ fs.mkdirSync(selectedDir, { recursive: true })
165
+ copyIncludedPaths(includeConfig.baseDir, includeConfig.include, selectedDir)
166
+ return selectedDir
167
+ }
168
+
169
+ async function downloadArchive(url: string, destination: string): Promise<void> {
170
+ const response = await fetchWithTimeout(url, undefined, 120_000)
171
+ if (!response.ok) {
172
+ throw new Error(`Failed to download archive (${response.status}) from ${url}`)
173
+ }
174
+ // Stream response to disk instead of buffering the entire archive in memory.
175
+ // Uses Bun.write which handles Response streaming natively.
176
+ const BunRuntime: { write(path: string, body: Response): Promise<number> } =
177
+ (globalThis as Record<string, unknown>).Bun as typeof BunRuntime
178
+ if (BunRuntime?.write) {
179
+ await BunRuntime.write(destination, response)
180
+ } else {
181
+ // Fallback for non-Bun environments (e.g., tests)
182
+ const arrayBuffer = await response.arrayBuffer()
183
+ fs.writeFileSync(destination, Buffer.from(arrayBuffer))
184
+ }
185
+ }
186
+
187
+ function extractTarGzSecure(archivePath: string, destinationDir: string): void {
188
+ const listResult = spawnSync("tar", ["tzf", archivePath], { encoding: "utf8" })
189
+ if (listResult.status !== 0) {
190
+ const err = listResult.stderr?.trim() || listResult.error?.message || "unknown error"
191
+ throw new Error(`Failed to inspect archive ${archivePath}: ${err}`)
192
+ }
193
+
194
+ validateTarEntries(listResult.stdout)
195
+
196
+ fs.rmSync(destinationDir, { recursive: true, force: true })
197
+ fs.mkdirSync(destinationDir, { recursive: true })
198
+
199
+ const extractResult = spawnSync("tar", ["xzf", archivePath, "--strip-components=1", "-C", destinationDir], {
200
+ encoding: "utf8",
201
+ })
202
+ if (extractResult.status !== 0) {
203
+ const err = extractResult.stderr?.trim() || extractResult.error?.message || "unknown error"
204
+ throw new Error(`Failed to extract archive ${archivePath}: ${err}`)
205
+ }
206
+ }
207
+
208
+ function validateTarEntries(listOutput: string): void {
209
+ const lines = listOutput.split(/\r?\n/).filter(Boolean)
210
+ for (const rawLine of lines) {
211
+ const entry = rawLine.trim()
212
+ if (!entry || entry.includes("\0")) {
213
+ throw new Error(`Archive contains an invalid entry: ${JSON.stringify(rawLine)}`)
214
+ }
215
+ if (entry.startsWith("/")) {
216
+ throw new Error(`Archive contains an absolute path entry: ${entry}`)
217
+ }
218
+
219
+ const normalized = path.posix.normalize(entry)
220
+ if (normalized === ".." || normalized.startsWith("../")) {
221
+ throw new Error(`Archive contains a path traversal entry: ${entry}`)
222
+ }
223
+
224
+ const parts = normalized.split("/").filter(Boolean)
225
+ const stripped = parts.slice(1).join("/")
226
+ if (!stripped) continue
227
+ const normalizedStripped = path.posix.normalize(stripped)
228
+ if (normalizedStripped === ".." || normalizedStripped.startsWith("../") || path.posix.isAbsolute(normalizedStripped)) {
229
+ throw new Error(`Archive contains an unsafe entry after strip-components: ${entry}`)
230
+ }
231
+ }
232
+ }
233
+
234
+ function isDirectory(target: string): boolean {
235
+ try {
236
+ return fs.statSync(target).isDirectory()
237
+ } catch {
238
+ return false
239
+ }
240
+ }
241
+
242
+ function readAgentikitIncludeConfigAtDir(dirPath: string): { baseDir: string; include: string[] } | undefined {
243
+ const packageJsonPath = path.join(dirPath, "package.json")
244
+ if (!fs.existsSync(packageJsonPath)) return undefined
245
+
246
+ let pkg: unknown
247
+ try {
248
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
249
+ } catch {
250
+ return undefined
251
+ }
252
+ if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg)) return undefined
253
+
254
+ const agentikit = (pkg as Record<string, unknown>).agentikit
255
+ if (typeof agentikit !== "object" || agentikit === null || Array.isArray(agentikit)) return undefined
256
+
257
+ const include = (agentikit as Record<string, unknown>).include
258
+ if (!Array.isArray(include)) return undefined
259
+
260
+ const parsedInclude = include
261
+ .filter((value): value is string => typeof value === "string")
262
+ .map((value) => value.trim())
263
+ .filter(Boolean)
264
+
265
+ return parsedInclude.length > 0 ? { baseDir: dirPath, include: parsedInclude } : undefined
266
+ }
267
+
268
+ function findNearestAgentikitIncludeConfig(
269
+ startDir: string,
270
+ stopDir: string,
271
+ ): { baseDir: string; include: string[] } | undefined {
272
+ let current = path.resolve(startDir)
273
+ const boundary = path.resolve(stopDir)
274
+
275
+ while (isWithin(current, boundary)) {
276
+ const config = readAgentikitIncludeConfigAtDir(current)
277
+ if (config) return config
278
+ if (current === boundary) break
279
+ const parent = path.dirname(current)
280
+ if (parent === current) break
281
+ current = parent
282
+ }
283
+
284
+ return undefined
285
+ }
286
+
287
+ function copyIncludedPaths(baseDir: string, include: string[], destinationDir: string): void {
288
+ for (const entry of include) {
289
+ const resolvedSource = path.resolve(baseDir, entry)
290
+ if (!isWithin(resolvedSource, baseDir)) {
291
+ throw new Error(`Path in agentikit.include escapes the package root: ${entry}`)
292
+ }
293
+ if (!fs.existsSync(resolvedSource)) {
294
+ throw new Error(`Path in agentikit.include does not exist: ${entry}`)
295
+ }
296
+ if (path.basename(resolvedSource) === ".git") {
297
+ continue
298
+ }
299
+ const relativePath = path.relative(baseDir, resolvedSource)
300
+ if (!relativePath || relativePath === ".") {
301
+ copyDirectoryContents(baseDir, destinationDir)
302
+ continue
303
+ }
304
+ copyPath(resolvedSource, path.join(destinationDir, relativePath))
305
+ }
306
+ }
307
+
308
+ function copyDirectoryContents(sourceDir: string, destinationDir: string): void {
309
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
310
+ if (entry.name === ".git") continue
311
+ copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name))
312
+ }
313
+ }
314
+
315
+ function copyPath(sourcePath: string, destinationPath: string): void {
316
+ const stat = fs.statSync(sourcePath)
317
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true })
318
+ if (stat.isDirectory()) {
319
+ fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true })
320
+ return
321
+ }
322
+ fs.copyFileSync(sourcePath, destinationPath)
323
+ }
324
+
325
+ function hasStashDirs(dirPath: string): boolean {
326
+ if (!isDirectory(dirPath)) return false
327
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
328
+ return entries.some((entry) => entry.isDirectory() && REGISTRY_STASH_DIR_NAMES.has(entry.name))
329
+ }
330
+
331
+ function findShallowestDotStashRoot(root: string): string | undefined {
332
+ const queue: string[] = [root]
333
+ while (queue.length > 0) {
334
+ const current = queue.shift()!
335
+ const dotStash = path.join(current, ".stash")
336
+ if (isDirectory(dotStash)) {
337
+ return current
338
+ }
339
+ let children: fs.Dirent[]
340
+ try {
341
+ children = fs.readdirSync(current, { withFileTypes: true })
342
+ } catch {
343
+ continue
344
+ }
345
+ for (const child of children) {
346
+ if (!child.isDirectory()) continue
347
+ if (child.name === ".git" || child.name === "node_modules") continue
348
+ queue.push(path.join(current, child.name))
349
+ }
350
+ }
351
+ return undefined
352
+ }
353
+
354
+ function normalizeInstalledEntry(entry: RegistryInstalledEntry): RegistryInstalledEntry {
355
+ return {
356
+ ...entry,
357
+ stashRoot: path.resolve(entry.stashRoot),
358
+ cacheDir: path.resolve(entry.cacheDir),
359
+ }
360
+ }
361
+