Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/docs/reference/clients.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions docs/src/content/docs/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.<name>.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 |
Expand All @@ -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 <name> --yes`.
Ordinary `allagents update` reconciles installed, still-declared profiles;
repeat `--profile <name>` to select only installed profiles. Removing a
Expand Down
198 changes: 198 additions & 0 deletions src/core/profile/adapters/opencode.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> | undefined {
const selected = serializeProfileMcpServers(input, 'opencode');
if (selected === null) return undefined;
const mcp: Record<string, unknown> = {};
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<void> {
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(),
);
6 changes: 6 additions & 0 deletions src/core/profile/adapters/registry.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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<Partial<Record<ClientType, ProfileAdapter>>> =
Object.freeze({
pi: piProfileAdapter,
omp: ompProfileAdapter,
opencode: openCodeProfileAdapter,
});

export function getProfileAdapter(client: ClientType): ProfileAdapter | null {
return PROFILE_ADAPTERS[client] ?? null;
}

export { OmpProfileAdapter, ompProfileAdapter } from './omp.js';
export {
OpenCodeProfileAdapter,
openCodeProfileAdapter,
} from './opencode.js';
export { PiProfileAdapter, piProfileAdapter } from './pi.js';
38 changes: 36 additions & 2 deletions src/models/workspace-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof ProfileClientSchema>;

Expand Down
6 changes: 5 additions & 1 deletion tests/unit/core/profile/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});
});
Loading