Toolkit for building file-first OpenCode distributions.
Ship a locked-down OpenCode variant with your own provider, model allow-list, plugins, and agents. Users get a familiar CLI; you keep authority over what it can actually do.
- Consumers of the published
@opencode-distro/sdkpackage need Node 20+. The package ships as ESM underdist/; no bundler or transpiler is required at install time. - Contributors to this repository use Bun as the package manager and test runner. The library itself never depends on Bun at runtime.
- The example under
apps/acme-codetargets Node 20.6+ because its CLI usesimport.meta.resolve, which became unflagged in that version. The library itself has no such constraint.
bun install
bun run check # tsc --noEmit + bun test
bun run build # emits dist/ via tsc -p tsconfig.build.jsonThe repository publishes two packages: the SDK at the root and the file-first
builder under packages/cli. After bumping their versions and running
bun run check, publish both in dependency order from the repository root:
npm run publish:allpublish:all publishes @opencode-distro/sdk first, then
@opencode-distro/cli. The CLI command explicitly targets packages/cli;
npm publish --prefix packages/cli still selects the root package. Use
npm run publish:sdk or npm run publish:cli only when releasing one package
intentionally.
Start a new distribution with:
npm create opencode-distro@latest my-codeThe interactive scaffold asks for the distribution name and provider details,
then creates a file-first project using @opencode-distro/sdk and
@opencode-distro/cli. For automation, provide all prompts as flags:
npx create-opencode-distro my-code \
--name "My Code" \
--provider my-provider \
--api https://gateway.example/v1 \
--model my-modelThe normal authoring model is a config file plus conventional directories. A simple distro needs no custom TypeScript launcher:
acme-code/
opencode-distro.jsonc
agents/
reviewer.md
plugins/
server/
gateway.ts
tui/
banner.ts
providers/
acme.ts
commands/
doctor.ts
acme-code.cjs
package.json
The root-level agents/ and plugins/{server,tui}/ directories are discovered
automatically. Source-based layouts under src/agents/ and src/plugins/ are
also supported as a fallback for existing projects.
When a provider needs OAuth, token refresh, or request transforms, select a
provider integration in JSONC and implement it under providers/:
providers/acme.ts default-exports a function receiving the resolved provider
ID and provider metadata. The builder compiles it and generates a server-plugin
adapter, so the integration never repeats the provider ID:
import type { ProviderIntegration } from "@opencode-distro/sdk"
const acme: ProviderIntegration = async ({ providerId }) => ({
auth: { provider: providerId /* OAuth hooks */ },
})
export default acmeInstall @opencode-distro/cli as a development dependency, then make its
build command your package build:
{
"bin": { "acme-code": "acme-code.cjs" },
"files": ["dist", "README.md"],
"scripts": { "build": "opencode-distro build", "prepack": "npm run build" },
"dependencies": {
"opencode-ai": "1.18.4",
"@opencode-distro/sdk": "^0.1.0"
},
"devDependencies": { "@opencode-distro/cli": "^0.1.0" }
}opencode-distro build validates the build inputs, compiles root-level
TypeScript provider integrations, plugins, and commands with Bun, copies agents and the config into
dist/, and generates dist/<product.binary>.js. Package distributions use a
small root <binary>.cjs shim that imports that ESM launcher. The distribution
package must set "type": "module". The published package then
provides the branded command directly:
acme-code
acme-code login
acme-code doctor --verboseThe generated launcher resolves its own adjacent opencode-distro.jsonc; it
does not depend on the user's current working directory. Use the programmatic
runtime APIs only when a distro needs custom lifecycle behavior such as an auth
preflight or background update check.
Use launcher for the small amount of product behavior that the generated
launcher owns without a custom cli.ts:
"launcher": {
"localConfigEnv": "ACME_CODE_CONFIG_CONTENT",
"forbidArgs": ["--pure"]
}localConfigEnv replaces the default OPENCODE_DISTRO_CONFIG_CONTENT input.
forbidArgs rejects exact arguments before either a distro command or OpenCode
is dispatched.
Each flat file under commands/ becomes a branded top-level command:
commands/doctor.ts -> acme-code doctor
Commands run in the generated launcher before OpenCode starts. Their default export receives the command arguments and the distro's resolved, isolated directories, then returns an exit code:
import type { Command } from "@opencode-distro/sdk"
const doctor: Command = async ({ args, dirs }) => {
if (args.length > 0) {
console.error("Usage: acme-code doctor")
return 1
}
console.log(`Cache: ${dirs.cache}`)
return 0
}
export default doctorUnknown command names continue to OpenCode unchanged, so acme-code login
retains OpenCode's auth alias behavior. Command filenames must be flat, unique,
and contain only letters, numbers, _, or -.
Everything lives in a single distro definition. The library:
- Resolves and validates that definition (
resolveDefinition— pure, no I/O). - Materializes agents and plugins from disk (
materialize). - Enforces policy: allow-listed models, required autoupdate off, provider
rebuilt from the definition under
configured-only, local overrides dropped or warned about. - Generates a self-contained policy plugin artifact and writes it into the distro's isolated cache. The artifact carries the resolved definition embedded as JSON. It imports nothing.
- Spawns OpenCode with an isolated environment and the artifact added to
config.plugin.
prepareLaunch returns everything you'd need to inspect (argv, env,
dirs, config, tuiConfig, fingerprint, warnings, policyArtifact)
without touching disk. Use it in tests. Use launch in production,
or hand the prepared object to run when you need to inspect and
then spawn without re-running the pipeline (see
Prepare-then-run).
Distro definitions are declarative data. Use JSONC for the normal case: comments and trailing commas are allowed, and resource paths are resolved relative to the config file automatically.
// opencode-distro.jsonc
{
"name": "Acme Code",
"id": "acme-code",
"binary": "acme-code",
"workspace": {
"configDirectory": ".acme-code",
"externalSkills": false
},
"provider": {
"id": "acme-gateway",
"name": "Acme AI Gateway",
"adapter": "@ai-sdk/openai-compatible",
"api": "https://gateway.acme.example/v1",
"auth": { "headers": { "x-ai-client": "acme-code" } },
"models": [{ "id": "acme-router" }],
"default": "acme-router"
},
"launcher": { "environment": { "ACME_CODE": "1" } }
// Put plugins under ./plugins/{server,tui}/ and agents under ./agents/.
// Both directories are picked up automatically when omitted.
}Convention: when the JSONC omits plugins or agents, the loader probes
./plugins/ and ./agents/ next to the config file and uses them when present.
For source-based projects, it falls back to ./src/plugins/ and ./src/agents/.
Non-conventional layouts stay explicit:
"plugins": { "directory": "./custom/plugins/" },
"agents": { "files": ["./prompts/one.md", "./prompts/two.md"] }plugins.directory discovers server/*.{js,mjs,cjs,ts} and
tui/*.{js,mjs,cjs,ts}. agents.directory discovers *.md. Individual
resources can instead be listed under plugins.server, plugins.tui, or
agents.files. Any explicit resource key disables the corresponding probe.
Relative paths are anchored at the JSONC file; absolute paths and file:
URLs are also accepted.
provider.api can be selected at launch time without a custom launcher. Its
entire value may be ${env:NAME} or ${env:NAME:-default}; a non-empty
environment value wins, otherwise the optional default is used:
"provider": {
"id": "acme-gateway",
"name": "Acme AI Gateway",
"adapter": "@ai-sdk/openai-compatible",
"api": "${env:ACME_GATEWAY_API:-https://gateway.acme.example/v1}",
"models": [{ "id": "acme-router" }]
}Expansion is limited to provider.api; it is not general JSONC string
templating. If a placeholder has no default and its variable is unset or
empty, loading fails with an env-variable-required error. Expansion occurs
before validation and definition fingerprinting, so the policy artifact and
configured-only enforcement use the effective endpoint.
#!/usr/bin/env node
// src/cli.ts
import { fileURLToPath } from "node:url"
import { loadDefinition, launch } from "@opencode-distro/sdk"
const definition = await loadDefinition(
new URL("../../opencode-distro.jsonc", import.meta.url),
)
const opencode = fileURLToPath(import.meta.resolve("opencode-ai/bin/opencode.exe"))
process.exit(await launch({ definition, opencode }))For programmatic composition, defineDistro({...}) and
defineProviderProfile({...}) remain available as the lower-level TypeScript
API. Those APIs require resource paths as URL values because a JavaScript
object has no intrinsic file location; prefer JSONC when the definition is
static data.
apps/acme-code is a complete standalone Node/npm example. Since it depends
on the local library via file:../.., build the library first, then build
and run the example:
# From the repository root
bun install
bun run build # produces ./dist for the library
# Then, in the example
cd apps/acme-code
npm install
npm run build # produces apps/acme-code/dist
node ./dist/src/cli.js --helpThe example is a local reference, not a publishable CLI (file:../.. cannot
be published as-is). The launcher spawns the resolved opencode-ai binary
via node:child_process, forwards stdio, and returns the child's exit code.
For launchers that need to peek at the resolved environment before spawning
(installing PATH shims, deciding whether to run an auth preflight, checking
for updates), run runs a previously prepared configuration without
re-executing the preparation pipeline:
import { loadDefinition, prepareLaunch, run } from "@opencode-distro/sdk"
const definition = await loadDefinition(configUrl)
const prepared = await prepareLaunch({ definition, opencode, args })
// prepared.dirs.{config,data,cache,state,tmp} are always defined.
// prepared.env is the full environment map.
// prepared.argv is what will be spawned.
await maybeInstallBrowserShim(prepared.dirs.cache)
if (isFirstLaunch(args) && !(await hasAuth(prepared.dirs.data))) {
// ...run a preflight...
}
process.exit(await run(prepared))run(await prepareLaunch(opts)) is equivalent to
launch(opts), but does not resolve the definition or materialize
resources twice.
Use withArgs(prepared, args) to run a preflight command using the
same prepared environment. This is important for auth login: preparing a
second launch from an already-isolated environment would namespace its XDG
directories again and write credentials somewhere the normal launch cannot
read.
The launcher reads OPENCODE_DISTRO_CONFIG_CONTENT (JSON string) as the local
config, or accepts a localConfig object on launch / prepareLaunch.
Set localConfigEnv to use a product-branded environment variable instead.
What you can override:
modelandsmall_model: must be exactly<providerId>/<allowedId>where<allowedId>is inprovider.allow. Invalid values fall back to the profile default and emit alocal-model-invalidwarning.agent.<name>: replaces the entire agent atomically.nullremoves it (tombstone). Unknown tombstones emitunknown-agent-tombstone.- Anything else that isn't listed below.
What is always dropped, with a local-override-dropped warning:
plugin: the distro owns the plugin list.- Under
policy.providers: "configured-only":provider,enabled_providers,disabled_providers. The provider block is rebuilt from the definition; local values do not leak into it.
Warnings flow through onWarning?: (w: Warning) => void. Nothing is
written to stderr by default.
Ship agents as inline JSON on the definition or as markdown files with
frontmatter. Both end up under the agent.* key of the OpenCode config.
The frontmatter block is a restrictive YAML-like subset — scalars,
quoted strings, nested maps, comments. Block/flow sequences, inline maps,
block scalars (|/>), anchors, aliases, and tags are rejected with an
actionable error rather than silently coerced to strings. See
AGENTS.md for the full specification.
Inline:
export default defineDistro({
// ...
opencode: {
agent: {
review: { mode: "subagent", description: "Reviews code", prompt: "..." },
},
},
})Markdown:
---
description: Reviews code for quality and best practices
mode: subagent
temperature: 0.1
permission:
edit: deny
bash:
"*": ask
"git diff": allow
---
You are in code review mode. Focus on quality and best practices.export default defineDistro({
// ...
agents: {
directory: new URL("./agents/", import.meta.url),
// or files: [new URL("./agents/review.md", import.meta.url)],
},
})The filename (minus .md) is the agent name. Frontmatter becomes the agent
config; the body becomes prompt. Precedence, applied atomically per name:
- markdown file
- inline
opencode.agent.<name>on the definition localConfig.agent.<name>at launch time
An override replaces the previous value in full. null removes it. There is
no field-level merge.
XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_CACHE_HOME,XDG_STATE_HOMEare namespaced byproduct.idfor opencode itself. Every subprocess opencode spawns (bash tool, MCP servers, ...) receives the host XDG values instead, so tools that respect XDG (git, gh, fish, jj, gcloud) find the user's real config. The policy plugin'sshell.envhook is what does the restoration; the launcher stashes the originals underOPENCODE_DISTRO_HOST_XDG_*for the plugin to read.OPENCODE_DISABLE_PROJECT_CONFIG=1is always set.isolation.disableExternalSkills(defaulttrue) setsOPENCODE_DISABLE_EXTERNAL_SKILLS=1.isolation.managedConfigDirectoryName, when set, triggers an upward search fromcwdfor that directory and passes the match asOPENCODE_CONFIG_DIR. Without the setting,OPENCODE_CONFIG_DIRis stripped from the child env.isolation.isolateManagedConfigsetsOPENCODE_TEST_MANAGED_CONFIG_DIRunder the distro's config dir. Otherwise it is stripped from inherited env.policy.shellEnvadds static string entries to every subprocess OpenCode launches. These are part of the definition fingerprint and do not modify the launcher's own environment.
product.id, product.binary, and managedConfigDirectoryName must be
single path segments. .., /, \, and NUL are rejected.
resolveDefinition computes a SHA-256 fingerprint over the canonical
JSON form of the resolved definition. The fingerprint:
- names the generated policy artifact (
policy.<fingerprint>.js) and TUI config (tui.<fingerprint>.json); - is exported to the child as
OPENCODE_DISTRO_FINGERPRINT; - is checked inside the policy hook. If the CLI and the running artifact disagree, enforcement throws with a clear message.
It is a coherence diagnostic. It is not a security boundary.
startDeviceAuthorization, pollDeviceAuthorization, refreshTokens are
thin OAuth device-flow helpers. They are optional; the library does not force
any auth UX on you.
Only these values are exported:
defineDistro,defineProviderProfileloadDefinitionresolveDefinition,materializecreatePolicyPluginprepareLaunch,launch,run,withArgsstartDeviceAuthorization,pollDeviceAuthorization,refreshTokens
TypeScript type exports mirror those values. Anything else is internal and may change without notice.