Skip to content

Commit c734e19

Browse files
anandgupta42claude
andauthored
fix: bundle skills, dbt-tools, and altimate-setup in shipped npm binary (#316)
* fix: bundle skills, dbt-tools, and `altimate-setup` in shipped npm binary Skills, `altimate-setup`, and `@altimateai/dbt-tools` were missing from the shipped npm binary — causing 100% skill unavailability on Homebrew/AUR/Docker and `altimate-dbt` not found for all npm users. Changes: - Embed skills in binary at build time via `OPENCODE_BUILTIN_SKILLS` define (mirrors existing `OPENCODE_MIGRATIONS` pattern), with filesystem-first fallback to preserve `@references` resolution for npm users - Move `altimate-setup` from `packages/opencode/.opencode/skills/` to root `.opencode/skills/` (consolidate split-brain) - Bundle dbt-tools binary+dist in npm package via shared `copyAssets()` in `publish.ts`, with postinstall symlinking and Windows `.cmd` shim - Export `ALTIMATE_BIN_DIR` from both `bin/altimate` and `bin/altimate-code` wrappers; prepend to PATH in `bash.ts` for agent tool discovery - Handle `builtin:` location prefix in skill tool to avoid `Ripgrep.files()` and `pathToFileURL()` crashes on synthetic paths - Add dbt-tools build step to `release.yml` publish job - Add 10 bundle-completeness assertions in `build-integrity.test.ts` Closes #315 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review findings - Fail-fast on zero embedded skills in release builds (`Script.release` guard) - Surface `altimate-dbt` setup failures with `console.warn` instead of silently swallowing errors - Use exact PATH-segment matching (`split(sep).some(e => e === binDir)`) instead of substring `includes()` to avoid false-positive dedup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: avoid empty PATH segment when PATH is unset When `basePath` is empty, `${binDir}${sep}${basePath}` produces a trailing separator that makes cwd searchable on POSIX — a subtle security risk. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4cee25f commit c734e19

File tree

13 files changed

+1247
-250
lines changed

13 files changed

+1247
-250
lines changed

.github/workflows/release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ jobs:
131131
- name: Install dependencies
132132
run: bun install
133133

134+
- name: Build dbt-tools (bundled with CLI)
135+
run: bun run build
136+
working-directory: packages/dbt-tools
137+
134138
- name: Free disk space for artifact download + npm publish
135139
run: |
136140
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
File renamed without changes.

bun.lock

Lines changed: 978 additions & 206 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/bin/altimate

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ function run(target) {
3333
// Search from BOTH the binary's location AND the wrapper script's location
3434
// to cover npm flat installs, pnpm isolated stores, and hoisted monorepos.
3535
const env = { ...process.env }
36+
37+
// Export bin directory so the compiled binary can add it to PATH when
38+
// spawning bash commands. This makes bundled tools (e.g. altimate-dbt)
39+
// available to agents without manual PATH configuration.
40+
env.ALTIMATE_BIN_DIR = scriptDir
41+
3642
try {
3743
const resolvedTarget = fs.realpathSync(target)
3844
const targetDir = path.dirname(path.dirname(resolvedTarget))

packages/opencode/bin/altimate-code

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ function run(target) {
3333
// Search from BOTH the binary's location AND the wrapper script's location
3434
// to cover npm flat installs, pnpm isolated stores, and hoisted monorepos.
3535
const env = { ...process.env }
36+
37+
// Export bin directory so the compiled binary can add it to PATH when
38+
// spawning bash commands. This makes bundled tools (e.g. altimate-dbt)
39+
// available to agents without manual PATH configuration.
40+
env.ALTIMATE_BIN_DIR = scriptDir
41+
3642
try {
3743
const resolvedTarget = fs.realpathSync(target)
3844
const targetDir = path.dirname(path.dirname(resolvedTarget))

packages/opencode/script/build.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ const migrations = await Promise.all(
6464
)
6565
console.log(`Loaded ${migrations.length} migrations`)
6666

67+
// Load builtin skills from .opencode/skills/ directory for embedding in binary.
68+
// This ensures skills are available in ALL distribution channels (npm, Homebrew, AUR, Docker)
69+
// without relying on postinstall filesystem copies.
70+
const skillsRoot = path.resolve(dir, "../../.opencode/skills")
71+
const skillEntries = fs.existsSync(skillsRoot)
72+
? (await fs.promises.readdir(skillsRoot, { withFileTypes: true })).filter((e) => e.isDirectory())
73+
: []
74+
75+
const builtinSkills: { name: string; content: string }[] = []
76+
for (const entry of skillEntries) {
77+
const skillFile = path.join(skillsRoot, entry.name, "SKILL.md")
78+
if (!fs.existsSync(skillFile)) continue
79+
const content = await Bun.file(skillFile).text()
80+
builtinSkills.push({ name: entry.name, content })
81+
}
82+
console.log(`Loaded ${builtinSkills.length} builtin skills`)
83+
if (Script.release && builtinSkills.length === 0) {
84+
console.error("No builtin skills were loaded from ../../.opencode/skills; aborting release build.")
85+
process.exit(1)
86+
}
87+
6788
const singleFlag = process.argv.includes("--single")
6889
const baselineFlag = process.argv.includes("--baseline")
6990
const skipInstall = process.argv.includes("--skip-install")
@@ -242,6 +263,7 @@ for (const item of targets) {
242263
// ALTIMATE_ENGINE_VERSION removed — Python engine eliminated
243264
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
244265
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
266+
OPENCODE_BUILTIN_SKILLS: JSON.stringify(builtinSkills),
245267
OPENCODE_CHANGELOG: JSON.stringify(changelog),
246268
OPENCODE_WORKER_PATH: workerPath,
247269
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,

packages/opencode/script/postinstall.mjs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,46 @@ function copyDirRecursive(src, dst) {
128128
}
129129
}
130130

131+
/**
132+
* Link bundled dbt-tools binary into the package's bin/ directory so it's
133+
* available alongside the main CLI binary. The wrapper script exports
134+
* ALTIMATE_BIN_DIR pointing to this directory.
135+
*/
136+
function setupDbtTools() {
137+
try {
138+
const dbtBinSrc = path.join(__dirname, "dbt-tools", "bin", "altimate-dbt")
139+
if (!fs.existsSync(dbtBinSrc)) {
140+
console.warn(`Bundled altimate-dbt entrypoint missing: ${dbtBinSrc}`)
141+
return
142+
}
143+
144+
const binDir = path.join(__dirname, "bin")
145+
if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true })
146+
147+
const target = path.join(binDir, "altimate-dbt")
148+
if (fs.existsSync(target)) fs.unlinkSync(target)
149+
150+
// Prefer symlink (preserves original relative imports), fall back to
151+
// writing a new wrapper with the correct path from bin/ → dbt-tools/dist/.
152+
try {
153+
fs.symlinkSync(dbtBinSrc, target)
154+
} catch {
155+
// Direct copy would break the `import("../dist/index.js")` resolution
156+
// since the script moves from dbt-tools/bin/ → bin/. Write a wrapper instead.
157+
fs.writeFileSync(target, '#!/usr/bin/env node\nimport("../dbt-tools/dist/index.js")\n')
158+
}
159+
fs.chmodSync(target, 0o755)
160+
161+
// Windows: create .cmd shim since cmd.exe doesn't understand shebangs
162+
if (os.platform() === "win32") {
163+
const cmdTarget = path.join(binDir, "altimate-dbt.cmd")
164+
fs.writeFileSync(cmdTarget, '@echo off\r\nnode "%~dp0\\..\\dbt-tools\\dist\\index.js" %*\r\n')
165+
}
166+
} catch (error) {
167+
console.warn("Failed to setup bundled altimate-dbt:", error)
168+
}
169+
}
170+
131171
/**
132172
* Copy bundled skills to ~/.altimate/builtin/ on every install/upgrade.
133173
* The entire directory is wiped and replaced so each release is the single
@@ -178,6 +218,7 @@ async function main() {
178218
// No postinstall setup needed
179219
if (version) writeUpgradeMarker(version)
180220
copySkillsToAltimate()
221+
setupDbtTools()
181222
return
182223
}
183224

@@ -196,6 +237,7 @@ async function main() {
196237
// The CLI picks up the marker and shows the welcome box on first run.
197238
if (version) writeUpgradeMarker(version)
198239
copySkillsToAltimate()
240+
setupDbtTools()
199241
} catch (error) {
200242
console.error("Failed to setup altimate-code binary:", error.message)
201243
process.exit(1)

packages/opencode/script/publish.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,29 @@ for (const filepath of new Bun.Glob("**/package.json").scanSync({ cwd: "./dist"
4343
console.log("binaries", binaries)
4444
const version = Object.values(binaries)[0]
4545

46+
// Build dbt-tools so we can bundle it alongside the CLI
47+
console.log("Building dbt-tools...")
48+
await $`bun run build`.cwd("../dbt-tools")
49+
console.log("dbt-tools built successfully")
50+
51+
/**
52+
* Copy common assets (bin, skills, dbt-tools, postinstall, license, changelog)
53+
* into a target dist directory. Shared by scoped and unscoped packages.
54+
*/
55+
async function copyAssets(targetDir: string) {
56+
await $`cp -r ./bin ${targetDir}/bin`
57+
await $`cp -r ../../.opencode/skills ${targetDir}/skills`
58+
await $`cp ./script/postinstall.mjs ${targetDir}/postinstall.mjs`
59+
// Bundle dbt-tools: copy its bin wrapper + built dist
60+
await $`mkdir -p ${targetDir}/dbt-tools/bin`
61+
await $`cp ../dbt-tools/bin/altimate-dbt ${targetDir}/dbt-tools/bin/altimate-dbt`
62+
await $`cp -r ../dbt-tools/dist ${targetDir}/dbt-tools/dist`
63+
await Bun.file(`${targetDir}/LICENSE`).write(await Bun.file("../../LICENSE").text())
64+
await Bun.file(`${targetDir}/CHANGELOG.md`).write(await Bun.file("../../CHANGELOG.md").text())
65+
}
66+
4667
await $`mkdir -p ./dist/${pkg.name}`
47-
await $`cp -r ./bin ./dist/${pkg.name}/bin`
48-
await $`cp -r ../../.opencode/skills ./dist/${pkg.name}/skills`
49-
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
68+
await copyAssets(`./dist/${pkg.name}`)
5069
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
5170
await Bun.file(`./dist/${pkg.name}/CHANGELOG.md`).write(await Bun.file("../../CHANGELOG.md").text())
5271

@@ -88,11 +107,7 @@ const unscopedName = "altimate-code"
88107
const unscopedDir = `./dist/${unscopedName}`
89108
try {
90109
await $`mkdir -p ${unscopedDir}`
91-
await $`cp -r ./bin ${unscopedDir}/bin`
92-
await $`cp -r ../../.opencode/skills ${unscopedDir}/skills`
93-
await $`cp ./script/postinstall.mjs ${unscopedDir}/postinstall.mjs`
94-
await Bun.file(`${unscopedDir}/LICENSE`).write(await Bun.file("../../LICENSE").text())
95-
await Bun.file(`${unscopedDir}/CHANGELOG.md`).write(await Bun.file("../../CHANGELOG.md").text())
110+
await copyAssets(unscopedDir)
96111
await Bun.file(`${unscopedDir}/README.md`).write(await Bun.file("../../README.md").text())
97112
await Bun.file(`${unscopedDir}/package.json`).write(
98113
JSON.stringify(

packages/opencode/src/skill/skill.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import z from "zod"
22
import path from "path"
33
import os from "os"
4+
import matter from "gray-matter"
45
import { Config } from "../config/config"
56
import { Instance } from "../project/instance"
67
import { NamedError } from "@opencode-ai/util/error"
@@ -17,6 +18,13 @@ import { pathToFileURL } from "url"
1718
import type { Agent } from "@/agent/agent"
1819
import { PermissionNext } from "@/permission/next"
1920

21+
// Builtin skills embedded at build time — available in ALL distribution channels
22+
// (npm, Homebrew, AUR, Docker) without relying on postinstall filesystem copies.
23+
// Falls back to filesystem scan in dev mode when the global is undefined.
24+
declare const OPENCODE_BUILTIN_SKILLS:
25+
| { name: string; content: string }[]
26+
| undefined
27+
2028
export namespace Skill {
2129
const log = Log.create({ service: "skill" })
2230
export const Info = z.object({
@@ -104,10 +112,13 @@ export namespace Skill {
104112
})
105113
}
106114

107-
// altimate_change start - scan ~/.altimate/builtin/ for release-managed builtin skills
108-
// This path is fully owned by postinstall (wipe-and-replace on every release).
109-
// Kept separate from user-editable skill dirs so users never accidentally modify builtins.
115+
// Load builtin skills — prefer filesystem (supports @references), fall back
116+
// to binary-embedded data (works without postinstall for Homebrew/AUR/Docker).
110117
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
118+
let loadedFromFs = false
119+
120+
// Try filesystem first — postinstall copies skills (including references/) to
121+
// ~/.altimate/builtin/. Filesystem paths are required for @references resolution.
111122
const builtinDir = path.join(Global.Path.home, ".altimate", "builtin")
112123
if (await Filesystem.isDir(builtinDir)) {
113124
const matches = await Glob.scan(SKILL_PATTERN, {
@@ -116,10 +127,34 @@ export namespace Skill {
116127
include: "file",
117128
symlink: true,
118129
})
119-
await Promise.all(matches.map(addSkill))
130+
if (matches.length > 0) {
131+
await Promise.all(matches.map(addSkill))
132+
loadedFromFs = true
133+
}
134+
}
135+
136+
// Fallback: load from binary-embedded data when filesystem is unavailable
137+
// (e.g. Homebrew, AUR, Docker installs that skip npm postinstall).
138+
// Note: @references won't resolve for embedded skills, but core functionality works.
139+
if (!loadedFromFs && typeof OPENCODE_BUILTIN_SKILLS !== "undefined") {
140+
for (const entry of OPENCODE_BUILTIN_SKILLS) {
141+
try {
142+
const md = matter(entry.content)
143+
const meta = Info.pick({ name: true, description: true }).safeParse(md.data)
144+
if (!meta.success) continue
145+
skills[meta.data.name] = {
146+
name: meta.data.name,
147+
description: meta.data.description,
148+
location: `builtin:${entry.name}/SKILL.md`,
149+
content: md.content,
150+
}
151+
} catch (err) {
152+
log.error("failed to parse embedded skill", { skill: entry.name, err })
153+
}
154+
}
155+
log.info("loaded embedded builtin skills", { count: OPENCODE_BUILTIN_SKILLS.length })
120156
}
121157
}
122-
// altimate_change end
123158

124159
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
125160
// Load global (home) first, then project-level (so project-level overwrites)
@@ -224,7 +259,7 @@ export namespace Skill {
224259
` <skill>`,
225260
` <name>${skill.name}</name>`,
226261
` <description>${skill.description}</description>`,
227-
` <location>${pathToFileURL(skill.location).href}</location>`,
262+
` <location>${skill.location.startsWith("builtin:") ? skill.location : pathToFileURL(skill.location).href}</location>`,
228263
` </skill>`,
229264
]),
230265
"</available_skills>",

packages/opencode/src/tool/bash.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,24 @@ export const BashTool = Tool.define("bash", async () => {
164164
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
165165
{ env: {} },
166166
)
167+
168+
// Merge process.env + shell plugin env, then prepend bundled tools dir.
169+
// shellEnv.env may contain PATH additions from user's shell profile.
170+
const mergedEnv: Record<string, string | undefined> = { ...process.env, ...shellEnv.env }
171+
const binDir = process.env.ALTIMATE_BIN_DIR
172+
if (binDir) {
173+
const sep = process.platform === "win32" ? ";" : ":"
174+
const basePath = mergedEnv.PATH ?? mergedEnv.Path ?? ""
175+
const pathEntries = basePath.split(sep).filter(Boolean)
176+
if (!pathEntries.some((entry) => entry === binDir)) {
177+
mergedEnv.PATH = basePath ? `${binDir}${sep}${basePath}` : binDir
178+
}
179+
}
180+
167181
const proc = spawn(params.command, {
168182
shell,
169183
cwd,
170-
env: {
171-
...process.env,
172-
...shellEnv.env,
173-
},
184+
env: mergedEnv,
174185
stdio: ["ignore", "pipe", "pipe"],
175186
detached: process.platform !== "win32",
176187
windowsHide: process.platform === "win32",

0 commit comments

Comments
 (0)