@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.
- package/AGENTS.md +13 -0
- package/LICENSE +21 -0
- package/__tests__/runtime-common.spec.ts +95 -0
- package/__tests__/runtime-config.spec.ts +124 -0
- package/__tests__/runtime-permissions.spec.ts +181 -0
- package/__tests__/runtime-test-helpers.ts +142 -0
- package/__tests__/session-runtime-config.spec.ts +156 -0
- package/__tests__/session-runtime-direct.spec.ts +141 -0
- package/__tests__/session-runtime-stream.spec.ts +181 -0
- package/package.json +59 -0
- package/src/AGENTS.md +38 -0
- package/src/adapter-config.ts +21 -0
- package/src/icon.ts +17 -0
- package/src/index.ts +11 -0
- package/src/models.ts +24 -0
- package/src/paths.ts +30 -0
- package/src/runtime/common/agent.ts +11 -0
- package/src/runtime/common/inline-config.ts +45 -0
- package/src/runtime/common/mcp.ts +47 -0
- package/src/runtime/common/model.ts +115 -0
- package/src/runtime/common/object-utils.ts +35 -0
- package/src/runtime/common/permission-node.ts +119 -0
- package/src/runtime/common/permissions.ts +73 -0
- package/src/runtime/common/prompt.ts +56 -0
- package/src/runtime/common/session-records.ts +61 -0
- package/src/runtime/common/tools.ts +129 -0
- package/src/runtime/common.ts +17 -0
- package/src/runtime/init.ts +55 -0
- package/src/runtime/session/child-env.ts +100 -0
- package/src/runtime/session/direct.ts +109 -0
- package/src/runtime/session/process.ts +55 -0
- package/src/runtime/session/shared.ts +72 -0
- package/src/runtime/session/skill-config.ts +84 -0
- package/src/runtime/session/stream.ts +198 -0
- package/src/runtime/session.ts +13 -0
- 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
|
+
}
|