Skip to content

Repository files navigation

OpenCode Distro

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.

Runtime requirements

  • Consumers of the published @opencode-distro/sdk package need Node 20+. The package ships as ESM under dist/; 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-code targets Node 20.6+ because its CLI uses import.meta.resolve, which became unflagged in that version. The library itself has no such constraint.

Development

bun install
bun run check   # tsc --noEmit + bun test
bun run build   # emits dist/ via tsc -p tsconfig.build.json

Publishing

The 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:all

publish: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.

Create A Distro

Start a new distribution with:

npm create opencode-distro@latest my-code

The 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-model

File-First Distro

The 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/:

"provider": {
  "id": "acme",
  "name": "Acme",
  "adapter": "@ai-sdk/openai-compatible",
  "api": "https://gateway.acme.example/v1",
  "integration": "acme",
  "models": [{ "id": "acme-model" }]
}

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 acme

Install @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 --verbose

The 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.

Commands

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 doctor

Unknown 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 -.

The one-file mental model

Everything lives in a single distro definition. The library:

  1. Resolves and validates that definition (resolveDefinition — pure, no I/O).
  2. Materializes agents and plugins from disk (materialize).
  3. Enforces policy: allow-listed models, required autoupdate off, provider rebuilt from the definition under configured-only, local overrides dropped or warned about.
  4. 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.
  5. 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).

Create a distro

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.

Runtime provider endpoint

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 --help

The 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.

Prepare-then-run

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.

Local overrides

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:

  • model and small_model: must be exactly <providerId>/<allowedId> where <allowedId> is in provider.allow. Invalid values fall back to the profile default and emit a local-model-invalid warning.
  • agent.<name>: replaces the entire agent atomically. null removes it (tombstone). Unknown tombstones emit unknown-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.

Agents

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:

  1. markdown file
  2. inline opencode.agent.<name> on the definition
  3. localConfig.agent.<name> at launch time

An override replaces the previous value in full. null removes it. There is no field-level merge.

Isolation

  • XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, XDG_STATE_HOME are namespaced by product.id for 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's shell.env hook is what does the restoration; the launcher stashes the originals under OPENCODE_DISTRO_HOST_XDG_* for the plugin to read.
  • OPENCODE_DISABLE_PROJECT_CONFIG=1 is always set.
  • isolation.disableExternalSkills (default true) sets OPENCODE_DISABLE_EXTERNAL_SKILLS=1.
  • isolation.managedConfigDirectoryName, when set, triggers an upward search from cwd for that directory and passes the match as OPENCODE_CONFIG_DIR. Without the setting, OPENCODE_CONFIG_DIR is stripped from the child env.
  • isolation.isolateManagedConfig sets OPENCODE_TEST_MANAGED_CONFIG_DIR under the distro's config dir. Otherwise it is stripped from inherited env.
  • policy.shellEnv adds 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.

Fingerprint

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.

Auth helpers

startDeviceAuthorization, pollDeviceAuthorization, refreshTokens are thin OAuth device-flow helpers. They are optional; the library does not force any auth UX on you.

Public API

Only these values are exported:

  • defineDistro, defineProviderProfile
  • loadDefinition
  • resolveDefinition, materialize
  • createPolicyPlugin
  • prepareLaunch, launch, run, withArgs
  • startDeviceAuthorization, pollDeviceAuthorization, refreshTokens

TypeScript type exports mirror those values. Anything else is internal and may change without notice.

About

A library to make custom opencode distributions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages