@vibe-forge/adapter-opencode 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AGENTS.md +13 -0
  2. package/LICENSE +21 -0
  3. package/__tests__/runtime-common.spec.ts +95 -0
  4. package/__tests__/runtime-config.spec.ts +124 -0
  5. package/__tests__/runtime-permissions.spec.ts +181 -0
  6. package/__tests__/runtime-test-helpers.ts +142 -0
  7. package/__tests__/session-runtime-config.spec.ts +156 -0
  8. package/__tests__/session-runtime-direct.spec.ts +141 -0
  9. package/__tests__/session-runtime-stream.spec.ts +181 -0
  10. package/package.json +59 -0
  11. package/src/AGENTS.md +38 -0
  12. package/src/adapter-config.ts +21 -0
  13. package/src/icon.ts +17 -0
  14. package/src/index.ts +11 -0
  15. package/src/models.ts +24 -0
  16. package/src/paths.ts +30 -0
  17. package/src/runtime/common/agent.ts +11 -0
  18. package/src/runtime/common/inline-config.ts +45 -0
  19. package/src/runtime/common/mcp.ts +47 -0
  20. package/src/runtime/common/model.ts +115 -0
  21. package/src/runtime/common/object-utils.ts +35 -0
  22. package/src/runtime/common/permission-node.ts +119 -0
  23. package/src/runtime/common/permissions.ts +73 -0
  24. package/src/runtime/common/prompt.ts +56 -0
  25. package/src/runtime/common/session-records.ts +61 -0
  26. package/src/runtime/common/tools.ts +129 -0
  27. package/src/runtime/common.ts +17 -0
  28. package/src/runtime/init.ts +55 -0
  29. package/src/runtime/session/child-env.ts +100 -0
  30. package/src/runtime/session/direct.ts +109 -0
  31. package/src/runtime/session/process.ts +55 -0
  32. package/src/runtime/session/shared.ts +72 -0
  33. package/src/runtime/session/skill-config.ts +84 -0
  34. package/src/runtime/session/stream.ts +198 -0
  35. package/src/runtime/session.ts +13 -0
  36. package/src/schema.ts +1 -0
@@ -0,0 +1,129 @@
1
+ import type { AdapterQueryOptions } from '@vibe-forge/core/adapter'
2
+
3
+ export const DEFAULT_OPENCODE_TOOLS = [
4
+ 'bash',
5
+ 'edit',
6
+ 'glob',
7
+ 'grep',
8
+ 'patch',
9
+ 'write',
10
+ 'read',
11
+ 'list',
12
+ 'lsp',
13
+ 'skill',
14
+ 'todoread',
15
+ 'todowrite',
16
+ 'webfetch',
17
+ 'websearch',
18
+ 'question'
19
+ ]
20
+
21
+ export const LEGACY_TOOL_PERMISSION_ALIASES: Record<string, string> = {
22
+ agent: 'task',
23
+ bash: 'bash',
24
+ edit: 'edit',
25
+ fetch: 'webfetch',
26
+ glob: 'glob',
27
+ grep: 'grep',
28
+ list: 'list',
29
+ ls: 'list',
30
+ lsp: 'lsp',
31
+ patch: 'edit',
32
+ read: 'read',
33
+ skill: 'skill',
34
+ task: 'task',
35
+ todoread: 'todoread',
36
+ todowrite: 'todowrite',
37
+ view: 'read',
38
+ webfetch: 'webfetch',
39
+ websearch: 'websearch',
40
+ write: 'edit'
41
+ }
42
+
43
+ const OPEN_CODE_TOOL_ALIASES: Record<string, string> = {
44
+ bash: 'bash',
45
+ edit: 'edit',
46
+ fetch: 'webfetch',
47
+ glob: 'glob',
48
+ grep: 'grep',
49
+ list: 'list',
50
+ ls: 'list',
51
+ lsp: 'lsp',
52
+ patch: 'patch',
53
+ question: 'question',
54
+ read: 'read',
55
+ skill: 'skill',
56
+ todoread: 'todoread',
57
+ todowrite: 'todowrite',
58
+ view: 'read',
59
+ webfetch: 'webfetch',
60
+ websearch: 'websearch',
61
+ write: 'write'
62
+ }
63
+
64
+ const PERMISSION_ONLY_TOOL_KEYS = new Set(['agent', 'task'])
65
+ const MANAGED_FLAGS_WITH_VALUE = new Set([
66
+ '--agent', '--attach', '--file', '--format', '--model', '--port', '--session', '--title', '-f', '-m', '-s'
67
+ ])
68
+ const MANAGED_FLAGS = new Set(['--continue', '--fork', '--share', '-c'])
69
+
70
+ export const buildToolConfig = (tools: AdapterQueryOptions['tools']) => {
71
+ const result: Record<string, boolean> = {}
72
+
73
+ for (const [list, enabled] of [[tools?.exclude ?? [], false], [tools?.include ?? [], true]] as const) {
74
+ for (const name of list) {
75
+ const key = name.trim()
76
+ if (key === '' || key === '*' || PERMISSION_ONLY_TOOL_KEYS.has(key)) continue
77
+ result[OPEN_CODE_TOOL_ALIASES[key] ?? key] = enabled
78
+ }
79
+ }
80
+
81
+ return Object.keys(result).length > 0 ? result : undefined
82
+ }
83
+
84
+ export const sanitizeOpenCodeExtraOptions = (extraOptions?: string[]) => {
85
+ const sanitized: string[] = []
86
+
87
+ for (let index = 0; index < (extraOptions ?? []).length; index += 1) {
88
+ const current = extraOptions?.[index]
89
+ if (current == null) continue
90
+ if (MANAGED_FLAGS_WITH_VALUE.has(current)) {
91
+ index += 1
92
+ continue
93
+ }
94
+ if (!MANAGED_FLAGS.has(current)) {
95
+ sanitized.push(current)
96
+ }
97
+ }
98
+
99
+ return sanitized
100
+ }
101
+
102
+ export const buildOpenCodeRunArgs = (params: {
103
+ prompt?: string
104
+ files: string[]
105
+ model?: string
106
+ agent?: string
107
+ share?: boolean
108
+ title?: string
109
+ opencodeSessionId?: string
110
+ extraOptions?: string[]
111
+ }) => {
112
+ const args = ['run', '--format', 'default']
113
+
114
+ if (params.opencodeSessionId) {
115
+ args.push('--session', params.opencodeSessionId)
116
+ } else if (params.title) {
117
+ args.push('--title', params.title)
118
+ }
119
+
120
+ if (params.model) args.push('--model', params.model)
121
+ if (params.agent) args.push('--agent', params.agent)
122
+ if (params.share) args.push('--share')
123
+
124
+ for (const file of params.files) args.push('--file', file)
125
+ args.push(...sanitizeOpenCodeExtraOptions(params.extraOptions))
126
+ if (params.prompt != null && params.prompt !== '') args.push(params.prompt)
127
+
128
+ return args
129
+ }
@@ -0,0 +1,17 @@
1
+ export { resolveOpenCodeAgent } from './common/agent'
2
+ export { buildInlineConfigContent } from './common/inline-config'
3
+ export { mapMcpServersToOpenCode } from './common/mcp'
4
+ export { resolveOpenCodeModel } from './common/model'
5
+ export { buildOpenCodeSessionTitle, normalizeOpenCodePrompt, resolveLocalAttachmentPath } from './common/prompt'
6
+ export { buildToolPermissionConfig, mapPermissionModeToOpenCode } from './common/permissions'
7
+ export {
8
+ extractOpenCodeSessionRecords,
9
+ selectOpenCodeSessionByTitle,
10
+ type OpenCodeSessionRecord
11
+ } from './common/session-records'
12
+ export {
13
+ buildOpenCodeRunArgs,
14
+ buildToolConfig,
15
+ DEFAULT_OPENCODE_TOOLS,
16
+ sanitizeOpenCodeExtraOptions
17
+ } from './common/tools'
@@ -0,0 +1,55 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { access, lstat, mkdir, symlink } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import process from 'node:process'
5
+ import { promisify } from 'node:util'
6
+
7
+ import type { AdapterCtx } from '@vibe-forge/core/adapter'
8
+
9
+ import { resolveOpenCodeBinaryPath } from '#~/paths.js'
10
+
11
+ const execFileAsync = promisify(execFile)
12
+
13
+ const ensureSymlink = async (sourcePath: string, targetPath: string) => {
14
+ try {
15
+ await access(sourcePath)
16
+ } catch {
17
+ return
18
+ }
19
+
20
+ try {
21
+ await lstat(targetPath)
22
+ return
23
+ } catch {
24
+ }
25
+
26
+ await mkdir(dirname(targetPath), { recursive: true })
27
+ await symlink(sourcePath, targetPath)
28
+ }
29
+
30
+ export const initOpenCodeAdapter = async (ctx: AdapterCtx) => {
31
+ const binaryPath = resolveOpenCodeBinaryPath(ctx.env)
32
+
33
+ try {
34
+ await execFileAsync(binaryPath, ['--version'])
35
+ } catch {
36
+ }
37
+
38
+ const realHome = process.env.__VF_PROJECT_REAL_HOME__
39
+ const aiHome = process.env.HOME
40
+
41
+ if (!realHome || !aiHome) return
42
+
43
+ await ensureSymlink(
44
+ join(realHome, '.config', 'opencode'),
45
+ join(aiHome, '.config', 'opencode')
46
+ )
47
+ await ensureSymlink(
48
+ join(realHome, '.local', 'share', 'opencode', 'auth.json'),
49
+ join(aiHome, '.local', 'share', 'opencode', 'auth.json')
50
+ )
51
+ await ensureSymlink(
52
+ join(realHome, '.local', 'share', 'opencode', 'mcp-auth.json'),
53
+ join(aiHome, '.local', 'share', 'opencode', 'mcp-auth.json')
54
+ )
55
+ }
@@ -0,0 +1,100 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import type { Config, ModelServiceConfig } from '@vibe-forge/core'
6
+ import type { AdapterCtx, AdapterQueryOptions } from '@vibe-forge/core/adapter'
7
+
8
+ import { buildInlineConfigContent, resolveOpenCodeModel } from '../common'
9
+ import { asPlainRecord } from '../common/object-utils'
10
+ import { ensureOpenCodeConfigDir } from './skill-config'
11
+ import { toProcessEnv, type OpenCodeAdapterConfig } from './shared'
12
+
13
+ const resolveMergedModelServices = (ctx: AdapterCtx) => ({
14
+ ...(ctx.configs[0]?.modelServices ?? {}),
15
+ ...(ctx.configs[1]?.modelServices ?? {})
16
+ }) as Record<string, ModelServiceConfig>
17
+
18
+ const resolveMergedMcpServers = (ctx: AdapterCtx) => ({
19
+ ...(ctx.configs[0]?.mcpServers ?? {}),
20
+ ...(ctx.configs[1]?.mcpServers ?? {})
21
+ }) as Config['mcpServers']
22
+
23
+ const resolveMcpServerSelection = (
24
+ ctx: AdapterCtx,
25
+ selection: AdapterQueryOptions['mcpServers']
26
+ ) => {
27
+ const include = selection?.include ?? Array.from(new Set([
28
+ ...(ctx.configs[0]?.defaultIncludeMcpServers ?? []),
29
+ ...(ctx.configs[1]?.defaultIncludeMcpServers ?? [])
30
+ ]))
31
+ const exclude = selection?.exclude ?? Array.from(new Set([
32
+ ...(ctx.configs[0]?.defaultExcludeMcpServers ?? []),
33
+ ...(ctx.configs[1]?.defaultExcludeMcpServers ?? [])
34
+ ]))
35
+
36
+ return include.length > 0 || exclude.length > 0
37
+ ? {
38
+ include: include.length > 0 ? include : undefined,
39
+ exclude: exclude.length > 0 ? exclude : undefined
40
+ }
41
+ : undefined
42
+ }
43
+
44
+ const parseEnvConfigContent = (env: AdapterCtx['env']) => {
45
+ const raw = env.OPENCODE_CONFIG_CONTENT
46
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
47
+
48
+ try {
49
+ const parsed = JSON.parse(raw)
50
+ return asPlainRecord(parsed)
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ export const buildChildEnv = async (params: {
57
+ ctx: AdapterCtx
58
+ options: AdapterQueryOptions
59
+ adapterConfig: OpenCodeAdapterConfig
60
+ systemPromptFile?: string
61
+ }) => {
62
+ const configDir = await ensureOpenCodeConfigDir({ ctx: params.ctx, options: params.options })
63
+ const { cliModel, providerConfig } = resolveOpenCodeModel(
64
+ params.options.model,
65
+ resolveMergedModelServices(params.ctx)
66
+ )
67
+
68
+ return {
69
+ cliModel,
70
+ env: toProcessEnv({
71
+ ...process.env,
72
+ ...params.ctx.env,
73
+ OPENCODE_DISABLE_AUTOUPDATE: params.ctx.env.OPENCODE_DISABLE_AUTOUPDATE ?? 'true',
74
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(buildInlineConfigContent({
75
+ adapterConfigContent: params.adapterConfig.configContent,
76
+ envConfigContent: parseEnvConfigContent(params.ctx.env),
77
+ permissionMode: params.options.permissionMode,
78
+ tools: params.options.tools,
79
+ mcpServers: resolveMcpServerSelection(params.ctx, params.options.mcpServers),
80
+ availableMcpServers: resolveMergedMcpServers(params.ctx),
81
+ systemPromptFile: params.systemPromptFile,
82
+ providerConfig
83
+ })),
84
+ ...(configDir != null ? { OPENCODE_CONFIG_DIR: configDir } : {})
85
+ })
86
+ }
87
+ }
88
+
89
+ export const ensureSystemPromptFile = async (
90
+ ctx: AdapterCtx,
91
+ options: AdapterQueryOptions
92
+ ) => {
93
+ if (options.systemPrompt == null || options.systemPrompt.trim() === '') return undefined
94
+
95
+ const promptDir = resolve(ctx.cwd, '.ai', '.mock', '.opencode-adapter', options.sessionId, 'instructions')
96
+ const promptPath = resolve(promptDir, 'system.md')
97
+ await mkdir(promptDir, { recursive: true })
98
+ await writeFile(promptPath, options.systemPrompt)
99
+ return promptPath
100
+ }
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import type { AdapterCtx, AdapterOutputEvent, AdapterQueryOptions, AdapterSession } from '@vibe-forge/core/adapter'
4
+
5
+ import {
6
+ buildOpenCodeRunArgs,
7
+ buildOpenCodeSessionTitle,
8
+ resolveOpenCodeAgent
9
+ } from '../common'
10
+ import { resolveOpenCodeBinaryPath } from '../../paths'
11
+ import { buildChildEnv, ensureSystemPromptFile } from './child-env'
12
+ import { findOpenCodeSessionId } from './process'
13
+ import { getErrorMessage, resolveAdapterConfig, toAdapterErrorData } from './shared'
14
+
15
+ export const createDirectOpenCodeSession = async (
16
+ ctx: AdapterCtx,
17
+ options: AdapterQueryOptions
18
+ ): Promise<AdapterSession> => {
19
+ const adapterConfig = resolveAdapterConfig(ctx)
20
+ const agent = resolveOpenCodeAgent({
21
+ agent: adapterConfig.agent,
22
+ planAgent: adapterConfig.planAgent,
23
+ permissionMode: options.permissionMode
24
+ })
25
+ const binaryPath = resolveOpenCodeBinaryPath(ctx.env)
26
+ const title = buildOpenCodeSessionTitle(options.sessionId, adapterConfig.titlePrefix)
27
+ const cachedSession = options.type === 'resume' ? await ctx.cache.get('adapter.opencode.session') : undefined
28
+ const systemPromptFile = await ensureSystemPromptFile(ctx, options)
29
+ const { cliModel, env } = await buildChildEnv({ ctx, options, adapterConfig, systemPromptFile })
30
+ const opencodeSessionId = options.type === 'resume'
31
+ ? await findOpenCodeSessionId({
32
+ binaryPath,
33
+ cwd: ctx.cwd,
34
+ env,
35
+ title,
36
+ maxCount: adapterConfig.sessionListMaxCount ?? 50,
37
+ logger: ctx.logger
38
+ }) ?? cachedSession?.opencodeSessionId
39
+ : undefined
40
+
41
+ if (options.type === 'create') await ctx.cache.set('adapter.opencode.session', { title })
42
+
43
+ const proc = spawn(binaryPath, buildOpenCodeRunArgs({
44
+ prompt: options.description?.trim() !== '' ? options.description?.trim() : undefined,
45
+ files: [],
46
+ model: cliModel,
47
+ agent,
48
+ share: adapterConfig.share,
49
+ title,
50
+ opencodeSessionId,
51
+ extraOptions: options.extraOptions
52
+ }), {
53
+ cwd: ctx.cwd,
54
+ env: env as Record<string, string>,
55
+ stdio: 'inherit'
56
+ })
57
+
58
+ let finished = false
59
+ let didEmitFatalError = false
60
+ const emitEvent = (event: AdapterOutputEvent) => {
61
+ if (event.type === 'error' && event.data.fatal !== false) {
62
+ didEmitFatalError = true
63
+ }
64
+ options.onEvent(event)
65
+ }
66
+ const emitExitOnce = (data: { exitCode: number; stderr?: string }) => {
67
+ if (finished) return
68
+ finished = true
69
+ emitEvent({ type: 'exit', data })
70
+ }
71
+
72
+ proc.on('error', (error) => {
73
+ emitEvent({ type: 'error', data: toAdapterErrorData(error) })
74
+ emitExitOnce({ exitCode: 1, stderr: getErrorMessage(error) })
75
+ })
76
+ proc.on('exit', (code) => {
77
+ void (async () => {
78
+ if ((code ?? 0) === 0) {
79
+ const resolvedSessionId = await findOpenCodeSessionId({
80
+ binaryPath,
81
+ cwd: ctx.cwd,
82
+ env,
83
+ title,
84
+ maxCount: adapterConfig.sessionListMaxCount ?? 50,
85
+ logger: ctx.logger
86
+ })
87
+ if (resolvedSessionId) {
88
+ await ctx.cache.set('adapter.opencode.session', { opencodeSessionId: resolvedSessionId, title })
89
+ }
90
+ } else if (!didEmitFatalError) {
91
+ emitEvent({
92
+ type: 'error',
93
+ data: toAdapterErrorData(`Process exited with code ${code ?? 1}`, {
94
+ details: { exitCode: code ?? 1 }
95
+ })
96
+ })
97
+ }
98
+ emitExitOnce({ exitCode: code ?? 0 })
99
+ })()
100
+ })
101
+
102
+ return {
103
+ kill: () => proc.kill(),
104
+ emit: () => {
105
+ ctx.logger.warn('emit() is not supported in direct mode for opencode')
106
+ },
107
+ pid: proc.pid
108
+ }
109
+ }
@@ -0,0 +1,55 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import type { AdapterCtx } from '@vibe-forge/core/adapter'
4
+
5
+ import { extractOpenCodeSessionRecords, selectOpenCodeSessionByTitle } from '../common'
6
+ import { execFileAsync, type OpenCodeRunResult } from './shared'
7
+
8
+ export const findOpenCodeSessionId = async (params: {
9
+ binaryPath: string
10
+ cwd: string
11
+ env: Record<string, string | null | undefined>
12
+ title: string
13
+ maxCount: number
14
+ logger: AdapterCtx['logger']
15
+ }) => {
16
+ try {
17
+ const { stdout } = await execFileAsync(
18
+ params.binaryPath,
19
+ ['session', 'list', '--format', 'json', '--max-count', String(params.maxCount)],
20
+ { cwd: params.cwd, env: params.env as Record<string, string>, maxBuffer: 1024 * 1024 * 8 }
21
+ )
22
+ return selectOpenCodeSessionByTitle(extractOpenCodeSessionRecords(stdout), params.title)?.id
23
+ } catch (err) {
24
+ params.logger.debug('Failed to resolve OpenCode session id from session list', { err })
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export const runOpenCodeCommand = (params: {
30
+ binaryPath: string
31
+ args: string[]
32
+ cwd: string
33
+ env: Record<string, string | null | undefined>
34
+ onStart?: (pid?: number) => void
35
+ }) => new Promise<OpenCodeRunResult>((resolveResult, reject) => {
36
+ const proc = spawn(params.binaryPath, params.args, {
37
+ cwd: params.cwd,
38
+ env: params.env as Record<string, string>,
39
+ stdio: 'pipe'
40
+ })
41
+
42
+ params.onStart?.(proc.pid)
43
+ const stdoutChunks: string[] = []
44
+ const stderrChunks: string[] = []
45
+ proc.stdout.on('data', chunk => stdoutChunks.push(String(chunk)))
46
+ proc.stderr.on('data', chunk => stderrChunks.push(String(chunk)))
47
+ proc.on('error', reject)
48
+ proc.on('exit', code => {
49
+ resolveResult({
50
+ exitCode: code ?? 0,
51
+ stdout: stdoutChunks.join(''),
52
+ stderr: stderrChunks.join('')
53
+ })
54
+ })
55
+ })
@@ -0,0 +1,72 @@
1
+ import { execFile } from 'node:child_process'
2
+
3
+ import type { ChatMessage } from '@vibe-forge/core'
4
+ import type { AdapterCtx } from '@vibe-forge/core/adapter'
5
+ import { uuid } from '@vibe-forge/core/utils/uuid'
6
+
7
+ export interface OpenCodeAdapterConfig {
8
+ agent?: string
9
+ planAgent?: string | false
10
+ titlePrefix?: string
11
+ share?: boolean
12
+ sessionListMaxCount?: number
13
+ configContent?: Record<string, unknown>
14
+ }
15
+
16
+ export interface OpenCodeRunResult {
17
+ exitCode: number
18
+ stdout: string
19
+ stderr: string
20
+ }
21
+
22
+ export const execFileAsync = (
23
+ file: string,
24
+ args: string[],
25
+ options: { cwd: string; env: Record<string, string>; maxBuffer: number }
26
+ ) => new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
27
+ execFile(file, args, options, (error, stdout, stderr) => {
28
+ if (error) {
29
+ reject(error)
30
+ return
31
+ }
32
+ resolve({ stdout: String(stdout), stderr: String(stderr) })
33
+ })
34
+ })
35
+
36
+ export const stripAnsi = (value: string) => value.replaceAll(/\u001B\[[0-9;]*[A-Za-z]/g, '')
37
+
38
+ export const createAssistantMessage = (content: string, model?: string): ChatMessage => ({
39
+ id: uuid(),
40
+ role: 'assistant',
41
+ content,
42
+ createdAt: Date.now(),
43
+ ...(model != null ? { model } : {})
44
+ })
45
+
46
+ export const getErrorMessage = (error: unknown) => (
47
+ error instanceof Error ? error.message : String(error ?? 'OpenCode session failed unexpectedly')
48
+ )
49
+
50
+ export const toAdapterErrorData = (
51
+ error: unknown,
52
+ overrides: Partial<{ message: string; code: string; details: unknown; fatal: boolean }> = {}
53
+ ) => ({
54
+ message: overrides.message ?? getErrorMessage(error),
55
+ ...(overrides.code != null ? { code: overrides.code } : {}),
56
+ ...(overrides.details !== undefined ? { details: overrides.details } : {}),
57
+ fatal: overrides.fatal ?? true
58
+ })
59
+
60
+ export const toProcessEnv = (env: Record<string, string | null | undefined>) => (
61
+ Object.fromEntries(
62
+ Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
63
+ )
64
+ )
65
+
66
+ export const resolveAdapterConfig = (ctx: AdapterCtx): OpenCodeAdapterConfig => {
67
+ const [config, userConfig] = ctx.configs
68
+ return {
69
+ ...(config?.adapters?.opencode ?? {}),
70
+ ...(userConfig?.adapters?.opencode ?? {})
71
+ }
72
+ }
@@ -0,0 +1,84 @@
1
+ import { lstat, mkdir, readdir, rm, symlink } from 'node:fs/promises'
2
+ import { basename, dirname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import type { AdapterCtx, AdapterQueryOptions } from '@vibe-forge/core/adapter'
6
+ import { DefinitionLoader } from '@vibe-forge/core/utils/definition-loader'
7
+
8
+ const filterResolvedSkills = async (
9
+ cwd: string,
10
+ selection: AdapterQueryOptions['skills']
11
+ ) => {
12
+ const loader = new DefinitionLoader(cwd)
13
+ const allSkills = await loader.loadDefaultSkills()
14
+ const include = selection?.include != null && selection.include.length > 0
15
+ ? new Set(selection.include)
16
+ : undefined
17
+ const exclude = new Set(selection?.exclude ?? [])
18
+ const result = new Map<string, string>()
19
+
20
+ for (const skill of allSkills) {
21
+ const name = basename(dirname(skill.path))
22
+ if ((include != null && !include.has(name)) || exclude.has(name) || result.has(name)) continue
23
+ result.set(name, dirname(skill.path))
24
+ }
25
+
26
+ return result
27
+ }
28
+
29
+ const ensureSymlinkTarget = async (sourcePath: string, targetPath: string) => {
30
+ try {
31
+ const existing = await lstat(targetPath)
32
+ if (existing.isSymbolicLink() || existing.isDirectory() || existing.isFile()) {
33
+ await rm(targetPath, { recursive: true, force: true })
34
+ }
35
+ } catch {
36
+ }
37
+
38
+ await mkdir(dirname(targetPath), { recursive: true })
39
+ await symlink(sourcePath, targetPath)
40
+ }
41
+
42
+ const mirrorDirectoryEntries = async (sourceDir: string, targetDir: string) => {
43
+ try {
44
+ const entries = await readdir(sourceDir, { withFileTypes: true })
45
+ await mkdir(targetDir, { recursive: true })
46
+ for (const entry of entries) {
47
+ await ensureSymlinkTarget(resolve(sourceDir, entry.name), resolve(targetDir, entry.name))
48
+ }
49
+ } catch {
50
+ }
51
+ }
52
+
53
+ export const ensureOpenCodeConfigDir = async (params: {
54
+ ctx: AdapterCtx
55
+ options: AdapterQueryOptions
56
+ }) => {
57
+ const baseConfigDir = params.ctx.env.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR ?? undefined
58
+ const resolvedSkills = await filterResolvedSkills(params.ctx.cwd, params.options.skills)
59
+ if (baseConfigDir == null && resolvedSkills.size === 0) return undefined
60
+
61
+ const configDir = resolve(
62
+ params.ctx.cwd,
63
+ '.ai',
64
+ '.mock',
65
+ '.opencode-adapter',
66
+ params.options.sessionId,
67
+ 'config-dir'
68
+ )
69
+ await rm(configDir, { recursive: true, force: true })
70
+ await mkdir(configDir, { recursive: true })
71
+
72
+ if (baseConfigDir) {
73
+ for (const folderName of ['agents', 'commands', 'modes', 'plugins']) {
74
+ await ensureSymlinkTarget(resolve(baseConfigDir, folderName), resolve(configDir, folderName)).catch(() => undefined)
75
+ }
76
+ await mirrorDirectoryEntries(resolve(baseConfigDir, 'skills'), resolve(configDir, 'skills'))
77
+ }
78
+
79
+ for (const [name, sourceDir] of resolvedSkills.entries()) {
80
+ await ensureSymlinkTarget(sourceDir, resolve(configDir, 'skills', name))
81
+ }
82
+
83
+ return configDir
84
+ }