Automatic project configuration sync for the OpenCode AI Plugin System.
This plugin eliminates manual configuration for OpenCode plugins. Instead of hand-maintaining JIRA workflow statuses, Tempo accounts, agent defaults, and pricing data in every project, the config plugin makes a single webhook call at startup, receives all remote defaults, deep-merges them with the local configuration (local always wins), and exposes the result as process.env.OPENCODE_PROJECT_CONFIG for all downstream plugins.
No files are written -- the merged config lives entirely in memory, consistent with how shell-env handles environment variables. For architectural details see the arc42 documentation.
shell-env (init)
--> process.env populated from .env files
config (init)
1. Read local config cascade (Global + Project opencode-project.json)
2. Plugin discovery via discoverPlugins()
3. POST webhook call to n8n
4. Schema validation per section (Ajv + plugin-owned JSON Schemas)
5. Deep-merge: remote as base, local wins
6. --> process.env.OPENCODE_PROJECT_CONFIG = JSON string
consumer plugins (init)
--> JSON.parse(process.env.OPENCODE_PROJECT_CONFIG)
--> Fallback: read local opencode-project.json directly
# Global installation (recommended)
cd ~/.config/opencode
npm install @techdivision/opencode-plugin-configThen link all plugins:
opencode-link allThe plugin must be listed after shell-env and before all consumer plugins in .opencode/package.json:
{
"dependencies": {
"@techdivision/opencode-plugin-shell-env": "^1.2.0",
"@techdivision/opencode-plugin-config": "^0.1.0",
"@techdivision/opencode-plugin-time-tracking": "^1.4.0",
"@techdivision/opencode-plugins": "github:techdivision/opencode-plugins"
}
}Order: shell-env (1st) --> config (2nd) --> all other plugins (3rd+)
The plugin reads its own settings from the config section:
{
"config": {
"sync_url": "https://n8n.example.com/webhook/oc-config-sync",
"sync_token": "optional-bearer-token"
}
}Or via environment variables (loaded by shell-env from .env):
OC_CONFIG_SYNC_URL=https://n8n.example.com/webhook/oc-config-sync
OC_CONFIG_SYNC_TOKEN=optional-bearer-token
OPENCODE_USER_EMAIL=user@example.comResolution order: config.sync_url in opencode-project.json --> {env:VAR} placeholder --> process.env.OC_CONFIG_SYNC_URL fallback --> skip webhook.
Layer 1 (Base): ~/.config/opencode/opencode-project.json Global defaults
Layer 2 (Override): <project>/.opencode/opencode-project.json Project overrides
──────────────────────────────────────────
= "Local Config" (deep-merge, Layer 2 wins)
Layer 3 (Remote): Webhook response Remote defaults
──────────────────────────────────────────
= "Final Config" (deep-merge: remote as base, local wins)
Merge rule: deepmerge(remote, local) -- local always wins.
- Both values are objects --> merge recursively
- Local has a value --> local wins (regardless of type)
- Only remote has a value --> remote value is adopted (new defaults)
- Arrays --> local array replaces remote array completely
Protected top-level fields ($schema, version) are never overwritten by the webhook.
The minimum a project needs in <project>/.opencode/opencode-project.json:
{
"jira": {
"project": "MYPROJ",
"base_url": "https://your-instance.atlassian.net"
},
"time_tracking": {
"valid_projects": ["MYPROJ"]
}
}The webhook uses these seed values (e.g. jira.project) to look up all remaining configuration from JIRA API and Google Sheets automatically.
POST {sync_url}
Content-Type: application/json
Authorization: Bearer {sync_token} (optional)
{
"plugin_version": "0.1.0",
"email": "user@example.com",
"plugins": ["config", "time-tracking", "jira", "shell-env"],
"config": {
"jira": {
"project": "MYPROJ",
"base_url": "https://your-instance.atlassian.net"
},
"time_tracking": {
"valid_projects": ["MYPROJ"]
}
}
}| Field | Source | Description |
|---|---|---|
plugin_version |
plugin.json version |
Config plugin version for compatibility check |
email |
process.env.OPENCODE_USER_EMAIL |
Identifies the user (Google Sheet, JIRA lookups) |
plugins |
discoverPlugins() |
List of all discovered plugin names |
config |
Merged local config (Global + Project) | Entire local config as seed for the webhook |
{
"version": "0.1.0",
"config": {
"jira": {
"project": "MYPROJ",
"workflow": { "status": { "open": { "name": "Open", "id": "1" } } },
"tempo": { "account_field_id": "customfield_10039", "accounts": {} }
},
"time_tracking": {
"agent_defaults": { "@implementation": { "issue_key": "MYPROJ-5" } },
"pricing": { "periods": [] }
}
}
}| Field | Description |
|---|---|
version |
Minimum plugin version this config was generated for |
config |
Remote config sections, keys match plugin names (_ instead of -) |
Version compatibility: If response.version > plugin_version, the response is discarded with a warning ("config was generated for a newer plugin version, please update").
| Situation | Behavior |
|---|---|
sync_url not configured |
Webhook skipped, local config applies |
OPENCODE_USER_EMAIL not set |
Webhook skipped, local config applies |
| Webhook unreachable (timeout 5s) | Local config applies, warning logged |
| Webhook returns HTTP 4xx/5xx | Local config applies, warning logged |
| Response validation fails | Local config applies, warning logged |
| Version incompatibility | Local config applies, warning logged |
| No local config present | Empty object {}, only remote if available |
In all error cases the plugin continues without blocking. The local config is always written to process.env.OPENCODE_PROJECT_CONFIG -- even without a webhook response.
Consumer plugins should read from process.env first, with a file fallback:
// Config from process.env (set by config plugin)
// Fallback to local file if config plugin is not installed
function loadConfig(sectionKey: string): Record<string, unknown> {
const envConfig = process.env.OPENCODE_PROJECT_CONFIG
if (envConfig) {
try {
const parsed = JSON.parse(envConfig)
if (parsed[sectionKey]) return parsed[sectionKey]
} catch { /* fallback */ }
}
try {
const raw = fs.readFileSync('.opencode/opencode-project.json', 'utf-8')
return JSON.parse(raw)[sectionKey] ?? {}
} catch { return {} }
}
// Usage
const myConfig = loadConfig('time_tracking')This change is backwards-compatible: if the config plugin is not installed, the fallback reads the local file directly.
Each plugin can declare its own JSON Schema for its config section via configSchema in plugin.json. The config plugin validates each webhook response section against the corresponding plugin's schema using Ajv.
Validation happens per section, not all-or-nothing. If the jira section fails validation, time_tracking data can still be used.
Example plugin.json with schema declaration:
{
"name": "time-tracking",
"description": "Automatic time tracking plugin for OpenCode.",
"category": "standard",
"version": "1.4.0",
"configSchema": "schemas/config.schema.json"
}| Situation | Behavior |
|---|---|
| Section has schema + validates | Section included in merge |
| Section has schema + fails validation | Section skipped, warning logged |
| Section has no schema | Included without validation |
The configSchema field is optional and backwards-compatible. Plugins without it continue to work -- their sections are merged without schema validation.
For details on the validation architecture, see arc42 Konzepte (8.10).
git clone https://github.com/techdivision/opencode-plugin-config.git
cd opencode-plugin-config
npm install
npm link # For local developmentopencode-plugin-config/
package.json
plugin.json
tsconfig.json
schemas/
config.schema.json Own config schema (for "config" section)
src/
config.ts Entry point (plugin function)
services/
ConfigLoader.ts Reads + merges local config cascade
ConfigSyncer.ts Webhook call
ConfigMerger.ts Deep-merge with local precedence
SchemaValidator.ts Ajv-based schema validation per section
types/
PluginConfig.ts { sync_url, sync_token }
SyncPayload.ts Webhook request type
SyncResponse.ts Webhook response type
n8n/
workflow.json Exported n8n workflow
README.md n8n setup instructions
Detailed architecture docs live alongside the source:
| Document | Content |
|---|---|
| Feature: feat-cfg | Feature description and scope |
| Bausteinsicht (5.5) | Building block view of the config plugin |
| Laufzeitsicht (6.6) | Runtime scenario: init sequence |
| Konzepte (8.8, 8.9, 8.10) | Config cascade, webhook sync, schema validation |
| ADR-009 to ADR-012 | Architecture Decision Records |
MIT