diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d84539..0f337b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - Pi profile MCP materialization with an explicitly declared, usable profile-scoped `pi-mcp-adapter`, plus native OMP named-profile marketplace lifecycle and revision verification. +- OpenCode global profiles using additive `OPENCODE_CONFIG` and + `OPENCODE_CONFIG_DIR` overrides, file-installed skills and commands, strict + settings, MCP serialization, generated launchers, and ownership-safe cleanup. ## [1.0.0] - 2026-03-13 diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index cdf2464..32eecfe 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -22,6 +22,27 @@ inventory of each runtime's native extension APIs. | Replit | `.agents/skills/` | `AGENTS.md` | No | No | No | | Kimi | `.agents/skills/` | `AGENTS.md` | No | No | No | +### OpenCode + +OpenCode global profiles use an AllAgents-owned configuration directory and set +both `OPENCODE_CONFIG` and `OPENCODE_CONFIG_DIR` in the generated launcher. +These are additive override layers: normal global configuration and +current-project discovery still apply. They do not isolate OpenCode credentials, +cache, data, or state. + +Profile plugins use file installation for skills and commands. OpenCode's CLI +can install a plugin but does not expose the complete inspect, targeted update, +and remove lifecycle required for ownership-safe native installation, so +`install: native` fails before mutation. Strict profile settings and local or +remote MCP declarations share the generated `opencode.json`; portable +`${ENV_VAR}` references become OpenCode `{env:ENV_VAR}` references and are +resolved only by the runtime. + +Profile cleanup removes unchanged managed files and OpenCode's exact +runtime-generated `.gitignore`. A modified `.gitignore` or any unrelated file +retains the root and reports a partial removal instead of recursively deleting +unknown content. + ## Provider-Specific Clients These clients use their own skills directory. As above, Hooks lists diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index cc79efb..ec6533b 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -107,15 +107,27 @@ profiles: args: [/absolute/path/to/server.mjs] env: API_TOKEN: ${API_TOKEN} + + oc-review: + clients: + - name: opencode + launcher: opencode-review + settings: + model: anthropic/claude-sonnet-4-5 + share: disabled + autoupdate: false + plugins: + - source: ./review-tools + install: file ``` | Field | Required | Description | |-------|----------|-------------| | `profiles..clients` | Yes | One or more object-form profile clients | -| `clients[].name` | Yes | Supported profile client; currently `pi` or `omp` | -| `clients[].install` | No | Default plugin mode, `file` by default | +| `clients[].name` | Yes | Supported profile client; currently `pi`, `omp`, or `opencode` | +| `clients[].install` | No | Default plugin mode, `file` by default; OpenCode rejects `native` because its CLI lacks a complete inspect/update/remove lifecycle | | `clients[].launcher` | No | Safe command basename written to the configured user bin directory | -| `clients[].settings` | No | Strict client settings object; Pi and OMP currently accept no settings | +| `clients[].settings` | No | Strict client settings object; Pi and OMP accept no settings, while OpenCode accepts its documented scalar profile settings | | `profiles..plugins` | No | Profile plugin declarations; defaults to an empty list | | `plugins[].source` | Yes | npm, GitHub, marketplace, or local source supported by the selected adapter | | `plugins[].ref` | No | Requested Git ref for a GitHub source | @@ -134,6 +146,14 @@ headers accept exact `${ENV_VAR}` references only, and credential-bearing command arguments must use the same exact form. Resolved secret values are never written to plans, launchers, profile state, or generated configuration. +OpenCode profile settings accept `model`, `small_model`, `default_agent`, +`username`, `share`, `autoupdate`, `snapshot`, `subagent_depth`, `logLevel`, +`disabled_providers`, and `enabled_providers`. All other keys fail validation. +The launcher sets both OpenCode configuration override variables. These layers +still merge with normal global and project configuration; they are not a strict +runtime sandbox. `${ENV_VAR}` MCP references are serialized to OpenCode's +runtime `{env:ENV_VAR}` syntax without resolving the value. + Install profiles explicitly with `allagents profile install --yes`. Ordinary `allagents update` reconciles installed, still-declared profiles; repeat `--profile ` to select only installed profiles. Removing a diff --git a/src/core/profile/adapters/opencode.ts b/src/core/profile/adapters/opencode.ts new file mode 100644 index 0000000..04211de --- /dev/null +++ b/src/core/profile/adapters/opencode.ts @@ -0,0 +1,198 @@ +import { join, resolve } from 'node:path'; +import { + OpenCodeProfileSettingsSchema, + ProfileMcpServerConfigSchema, + ProfileNameSchema, +} from '../../../models/workspace-config.js'; +import type { FileOnlyProfileAdapter } from '../types.js'; +import { removeManagedFile, sha256Fingerprint } from '../files.js'; +import type { + ProfileClientContext, + ProfileContextOptions, + ProfilePlannedFile, + ProfileSerializationInput, +} from '../types.js'; +import { serializeProfileMcpServers } from './mcp.js'; + +const OPENCODE_SCHEMA_URL = 'https://opencode.ai/config.json'; +const FILE_MAPPING = Object.freeze({ + commandsPath: 'commands/', + skillsPath: 'skills/', + agentFile: 'AGENTS.md', +}); +const GENERATED_GITIGNORE = + 'node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore'; +const CAPABILITIES = Object.freeze({ + nativeInstall: false, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: true, + status: true, + cleanup: true, + recursiveRootCleanup: false, +}); +const SECRET_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +function assertOpenCodeContext(context: ProfileClientContext): void { + const expectedConfig = join(context.root, 'opencode.json'); + if ( + context.client !== 'opencode' || + context.operationContext.client !== 'opencode' || + context.operationContext.nativeScope !== `profile:${context.profileName}` || + resolve(context.root) !== context.root || + context.operationContext.env?.OPENCODE_CONFIG !== expectedConfig || + context.operationContext.env?.OPENCODE_CONFIG_DIR !== context.root + ) { + throw new Error( + 'OpenCode profile adapter received a mismatched or non-absolute context', + ); + } +} + +function openCodeReference(value: string): string { + return value.replace(SECRET_REFERENCE, '{env:$1}'); +} + +function serializeOpenCodeMcp( + input: ProfileSerializationInput, +): Readonly> | undefined { + const selected = serializeProfileMcpServers(input, 'opencode'); + if (selected === null) return undefined; + const mcp: Record = {}; + for (const [name, value] of Object.entries(selected)) { + const server = ProfileMcpServerConfigSchema.parse(value); + if ('url' in server) { + mcp[name] = { + type: 'remote', + url: openCodeReference(server.url), + ...(server.headers && { + headers: Object.fromEntries( + Object.entries(server.headers).map(([key, value]) => [ + key, + openCodeReference(value), + ]), + ), + }), + }; + continue; + } + mcp[name] = { + type: 'local', + command: [server.command, ...(server.args ?? []).map(openCodeReference)], + ...(server.env && { + environment: Object.fromEntries( + Object.entries(server.env).map(([key, value]) => [ + key, + openCodeReference(value), + ]), + ), + }), + }; + } + return Object.freeze(mcp); +} + +export class OpenCodeProfileAdapter implements FileOnlyProfileAdapter { + readonly client = 'opencode' as const; + readonly capabilities = CAPABILITIES; + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext { + ProfileNameSchema.parse(profileName); + const homeDir = resolve(options.homeDir); + const workspaceDirectory = resolve(options.workspaceDirectory); + const root = join( + homeDir, + '.allagents', + 'profiles', + profileName, + 'clients', + 'opencode', + 'config', + ); + const configPath = join(root, 'opencode.json'); + const selectedEnvironment = Object.freeze({ + OPENCODE_CONFIG: configPath, + OPENCODE_CONFIG_DIR: root, + OPENCODE_CONFIG_CONTENT: undefined, + }); + const operationContext = Object.freeze({ + client: this.client, + scope: 'user' as const, + nativeScope: `profile:${profileName}`, + root, + cwd: workspaceDirectory, + env: Object.freeze({ + ...options.environment, + ...selectedEnvironment, + }), + roots: Object.freeze({ config: root }), + }); + return Object.freeze({ + profileName, + client: this.client, + mechanism: 'configuration-override', + root, + operationContext, + fileMapping: FILE_MAPPING, + launcher: Object.freeze({ + command: 'opencode', + args: Object.freeze([] as string[]), + env: selectedEnvironment, + }), + }); + } + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertOpenCodeContext(context); + const settings = OpenCodeProfileSettingsSchema.parse(input.settings ?? {}); + const mcp = serializeOpenCodeMcp(input); + if (Object.keys(settings).length === 0 && mcp === undefined) return null; + return Object.freeze({ + key: 'opencode:config', + client: this.client, + kind: 'settings' as const, + path: join(context.root, 'opencode.json'), + content: `${JSON.stringify( + { + $schema: OPENCODE_SCHEMA_URL, + ...settings, + ...(mcp && { mcp }), + }, + null, + 2, + )}\n`, + mode: 0o600, + }); + } + + serializeMcp( + context: ProfileClientContext, + _input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertOpenCodeContext(context); + // OpenCode stores settings and MCP declarations in one configuration file. + return null; + } + + async prepareRootCleanup(context: ProfileClientContext): Promise { + assertOpenCodeContext(context); + await removeManagedFile({ + root: context.root, + path: join(context.root, '.gitignore'), + ownership: 'managed', + expectedFingerprint: sha256Fingerprint(GENERATED_GITIGNORE), + }); + } +} + +export const openCodeProfileAdapter: FileOnlyProfileAdapter = Object.freeze( + new OpenCodeProfileAdapter(), +); diff --git a/src/core/profile/adapters/registry.ts b/src/core/profile/adapters/registry.ts index f3d6cca..48c1152 100644 --- a/src/core/profile/adapters/registry.ts +++ b/src/core/profile/adapters/registry.ts @@ -1,12 +1,14 @@ import type { ClientType } from '../../../models/workspace-config.js'; import type { ProfileAdapter } from '../types.js'; import { ompProfileAdapter } from './omp.js'; +import { openCodeProfileAdapter } from './opencode.js'; import { piProfileAdapter } from './pi.js'; const PROFILE_ADAPTERS: Readonly>> = Object.freeze({ pi: piProfileAdapter, omp: ompProfileAdapter, + opencode: openCodeProfileAdapter, }); export function getProfileAdapter(client: ClientType): ProfileAdapter | null { @@ -14,4 +16,8 @@ export function getProfileAdapter(client: ClientType): ProfileAdapter | null { } export { OmpProfileAdapter, ompProfileAdapter } from './omp.js'; +export { + OpenCodeProfileAdapter, + openCodeProfileAdapter, +} from './opencode.js'; export { PiProfileAdapter, piProfileAdapter } from './pi.js'; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 07cc9ca..8e978ad 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -446,6 +446,26 @@ export function getLauncherCollisionKey(name: string): string { const EmptyProfileSettingsSchema = z.object({}).strict(); +export const OpenCodeProfileSettingsSchema = z + .object({ + model: z.string().min(1).optional(), + small_model: z.string().min(1).optional(), + default_agent: z.string().min(1).optional(), + username: z.string().min(1).optional(), + share: z.enum(['manual', 'auto', 'disabled']).optional(), + autoupdate: z.union([z.boolean(), z.literal('notify')]).optional(), + snapshot: z.boolean().optional(), + subagent_depth: z.number().int().nonnegative().optional(), + logLevel: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), + disabled_providers: z.array(z.string().min(1)).optional(), + enabled_providers: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export type OpenCodeProfileSettings = z.infer< + typeof OpenCodeProfileSettingsSchema +>; + /** * Profile clients deliberately use object form only. Unsupported clients still * parse with empty settings so orchestration can report an adapter capability @@ -456,9 +476,23 @@ export const ProfileClientSchema = z name: ClientTypeSchema, install: InstallModeSchema.default('file'), launcher: ProfileNameSchema.optional(), - settings: EmptyProfileSettingsSchema.default({}), + settings: z.record(z.unknown()).default({}), }) - .strict(); + .strict() + .superRefine((client, context) => { + const settingsSchema = + client.name === 'opencode' + ? OpenCodeProfileSettingsSchema + : EmptyProfileSettingsSchema; + const result = settingsSchema.safeParse(client.settings); + if (result.success) return; + for (const issue of result.error.issues) { + context.addIssue({ + ...issue, + path: ['settings', ...issue.path], + }); + } + }); export type ProfileClient = z.infer; diff --git a/tests/unit/core/profile/adapters.test.ts b/tests/unit/core/profile/adapters.test.ts index 8a22218..dcafae1 100644 --- a/tests/unit/core/profile/adapters.test.ts +++ b/tests/unit/core/profile/adapters.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { OmpProfileAdapter } from '../../../../src/core/profile/adapters/omp.js'; +import { OpenCodeProfileAdapter } from '../../../../src/core/profile/adapters/opencode.js'; import { PiProfileAdapter } from '../../../../src/core/profile/adapters/pi.js'; import { getProfileAdapter } from '../../../../src/core/profile/adapters/registry.js'; import type { @@ -452,9 +453,12 @@ describe('OMP profile adapter', () => { }); describe('profile adapter registry', () => { - test('returns only complete Pi and OMP adapters', () => { + test('returns only complete Pi, OMP, and OpenCode adapters', () => { expect(getProfileAdapter('pi')).toBeInstanceOf(PiProfileAdapter); expect(getProfileAdapter('omp')).toBeInstanceOf(OmpProfileAdapter); + expect(getProfileAdapter('opencode')).toBeInstanceOf( + OpenCodeProfileAdapter, + ); expect(getProfileAdapter('claude')).toBeNull(); }); }); diff --git a/tests/unit/core/profile/opencode.test.ts b/tests/unit/core/profile/opencode.test.ts new file mode 100644 index 0000000..b6cc0fa --- /dev/null +++ b/tests/unit/core/profile/opencode.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { OpenCodeProfileAdapter } from '../../../../src/core/profile/adapters/opencode.js'; +import { isNativeProfileAdapter } from '../../../../src/core/profile/types.js'; + +describe('OpenCode profile adapter', () => { + it('selects additive configuration overrides without changing the workspace', () => { + const adapter = new OpenCodeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + environment: { + OPENCODE_CONFIG: '/ambient/opencode.json', + OPENCODE_CONFIG_CONTENT: '{"share":"auto"}', + }, + }); + const root = join( + '/home/test', + '.allagents', + 'profiles', + 'review', + 'clients', + 'opencode', + 'config', + ); + + expect(context).toMatchObject({ + client: 'opencode', + mechanism: 'configuration-override', + root, + operationContext: { + root, + cwd: '/work/project', + nativeScope: 'profile:review', + env: { + OPENCODE_CONFIG: join(root, 'opencode.json'), + OPENCODE_CONFIG_DIR: root, + OPENCODE_CONFIG_CONTENT: undefined, + }, + }, + fileMapping: { + commandsPath: 'commands/', + skillsPath: 'skills/', + agentFile: 'AGENTS.md', + }, + launcher: { + command: 'opencode', + args: [], + env: { + OPENCODE_CONFIG: join(root, 'opencode.json'), + OPENCODE_CONFIG_DIR: root, + OPENCODE_CONFIG_CONTENT: undefined, + }, + }, + }); + }); + + it('serializes strict settings and selected MCP servers into one owned config', () => { + const adapter = new OpenCodeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + const planned = adapter.serializeSettings(context, { + plugins: [], + settings: { + model: 'anthropic/claude-sonnet-4-5', + share: 'disabled', + }, + mcpServers: { + local: { + command: 'local-mcp', + args: ['--token', '${LOCAL_TOKEN}'], + env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, + clients: ['opencode'], + }, + remote: { + type: 'http', + url: 'https://mcp.example.test', + headers: { Authorization: '${REMOTE_TOKEN}' }, + }, + ignored: { + command: 'other-mcp', + clients: ['pi'], + }, + }, + }); + + expect(planned?.path).toBe(join(context.root, 'opencode.json')); + expect(JSON.parse(planned?.content ?? '{}')).toEqual({ + $schema: 'https://opencode.ai/config.json', + model: 'anthropic/claude-sonnet-4-5', + share: 'disabled', + mcp: { + local: { + type: 'local', + command: ['local-mcp', '--token', '{env:LOCAL_TOKEN}'], + environment: { LOCAL_TOKEN: '{env:LOCAL_TOKEN}' }, + }, + remote: { + type: 'remote', + url: 'https://mcp.example.test', + headers: { Authorization: '{env:REMOTE_TOKEN}' }, + }, + }, + }); + expect(adapter.serializeMcp(context, { plugins: [] })).toBeNull(); + }); + + it('is structurally file-only without a fake native lifecycle', () => { + const adapter = new OpenCodeProfileAdapter(); + expect(adapter.capabilities.nativeInstall).toBe(false); + expect(isNativeProfileAdapter(adapter)).toBe(false); + expect('nativeClient' in adapter).toBe(false); + expect('resolveNativeSource' in adapter).toBe(false); + expect('resolveNativeMetadata' in adapter).toBe(false); + expect('discloseNativeCommands' in adapter).toBe(false); + }); + + it('removes only the exact runtime-generated gitignore during root cleanup', async () => { + const home = await mkdtemp(join(tmpdir(), 'allagents-opencode-profile-')); + try { + const adapter = new OpenCodeProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: home, + workspaceDirectory: home, + }); + await mkdir(context.root, { recursive: true }); + const generatedPath = join(context.root, '.gitignore'); + const generated = + 'node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore'; + await writeFile(generatedPath, generated, 'utf8'); + await adapter.prepareRootCleanup?.(context); + await expect(stat(generatedPath)).rejects.toThrow(); + + await writeFile(generatedPath, `${generated}\nuser-entry`, 'utf8'); + await adapter.prepareRootCleanup?.(context); + expect(await readFile(generatedPath, 'utf8')).toContain('user-entry'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index ba58074..d77bb6d 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -200,6 +200,54 @@ describe('profile workspace declarations', () => { ).toBe(false); }); + it('accepts only documented OpenCode profile settings', () => { + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [ + { + name: 'opencode', + settings: { + model: 'anthropic/claude-sonnet-4-5', + small_model: 'anthropic/claude-haiku-4-5', + share: 'disabled', + autoupdate: 'notify', + snapshot: false, + subagent_depth: 2, + logLevel: 'WARN', + enabled_providers: ['anthropic'], + }, + }, + ], + }), + ); + expect(result.profiles?.research?.clients[0]?.settings).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + small_model: 'anthropic/claude-haiku-4-5', + share: 'disabled', + autoupdate: 'notify', + snapshot: false, + subagent_depth: 2, + logLevel: 'WARN', + enabled_providers: ['anthropic'], + }); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [ + { name: 'opencode', settings: { configPath: '/tmp/opencode' } }, + ], + }), + ).success, + ).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'opencode', settings: { share: 'always' } }], + }), + ).success, + ).toBe(false); + }); + it('rejects unknown and machine-generated fields throughout declarations', () => { for (const profile of [ { clients: [{ name: 'pi', scope: 'user' }] },