@moxxy/plugin-provider-acp 0.0.0-stage → 0.45.0
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/LICENSE +21 -0
- package/dist/acp-session.d.ts +201 -0
- package/dist/acp-session.d.ts.map +1 -0
- package/dist/acp-session.js +812 -0
- package/dist/acp-session.js.map +1 -0
- package/dist/agent-provider.d.ts +71 -0
- package/dist/agent-provider.d.ts.map +1 -0
- package/dist/agent-provider.js +203 -0
- package/dist/agent-provider.js.map +1 -0
- package/dist/agents.d.ts +23 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +21 -0
- package/dist/agents.js.map +1 -0
- package/dist/constants.d.ts +31 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +31 -0
- package/dist/constants.js.map +1 -0
- package/dist/executable.d.ts +47 -0
- package/dist/executable.d.ts.map +1 -0
- package/dist/executable.js +106 -0
- package/dist/executable.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +48 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +180 -0
- package/dist/login.js.map +1 -0
- package/dist/opencode-api.d.ts +32 -0
- package/dist/opencode-api.d.ts.map +1 -0
- package/dist/opencode-api.js +86 -0
- package/dist/opencode-api.js.map +1 -0
- package/dist/opencode-helpers.d.ts +49 -0
- package/dist/opencode-helpers.d.ts.map +1 -0
- package/dist/opencode-helpers.js +175 -0
- package/dist/opencode-helpers.js.map +1 -0
- package/dist/protocol.d.ts +143 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +7 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rules.d.ts +47 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +84 -0
- package/dist/rules.js.map +1 -0
- package/dist/translate.d.ts +50 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +290 -0
- package/dist/translate.js.map +1 -0
- package/package.json +81 -3
- package/src/acp-session.ts +892 -0
- package/src/agent-cli.test.ts +76 -0
- package/src/agent-provider.test.ts +1137 -0
- package/src/agent-provider.ts +234 -0
- package/src/agents.ts +37 -0
- package/src/constants.ts +36 -0
- package/src/event-coverage.test.ts +65 -0
- package/src/executable.test.ts +89 -0
- package/src/executable.ts +143 -0
- package/src/fake-opencode.testkit.ts +755 -0
- package/src/fixtures/acp-session-updates.json +19 -0
- package/src/index.test.ts +69 -0
- package/src/index.ts +83 -0
- package/src/login.test.ts +155 -0
- package/src/login.ts +222 -0
- package/src/opencode-api.ts +105 -0
- package/src/opencode-helpers.test.ts +76 -0
- package/src/opencode-helpers.ts +195 -0
- package/src/protocol.ts +104 -0
- package/src/rules.test.ts +107 -0
- package/src/rules.ts +102 -0
- package/src/translate.test.ts +253 -0
- package/src/translate.ts +318 -0
- package/README.md +0 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "Agent Client Protocol schema, SessionUpdate (agentclientprotocol.com/protocol/schema, read 2026-10-15); OpenCode 1.19 sends agent_message_chunk, agent_thought_chunk, tool_call, tool_call_update, plan, available_commands_update, config_option_update and usage_update",
|
|
3
|
+
"updates": [
|
|
4
|
+
"agent_message_chunk",
|
|
5
|
+
"agent_thought_chunk",
|
|
6
|
+
"available_commands_update",
|
|
7
|
+
"compaction_summary_chunk",
|
|
8
|
+
"compaction_update",
|
|
9
|
+
"config_option_update",
|
|
10
|
+
"current_mode_update",
|
|
11
|
+
"notice",
|
|
12
|
+
"plan",
|
|
13
|
+
"session_info_update",
|
|
14
|
+
"tool_call",
|
|
15
|
+
"tool_call_update",
|
|
16
|
+
"usage_update",
|
|
17
|
+
"user_message_chunk"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** The plugin entry: OpenCode is ready once installed, offers its own models, and ends with the conversation. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { collectTurn } from '@moxxy/core';
|
|
4
|
+
import { assertDefined } from '@moxxy/sdk';
|
|
5
|
+
import { createFakeSession } from '@moxxy/testing';
|
|
6
|
+
import { defaultModePlugin } from '@moxxy/mode-default';
|
|
7
|
+
import { AcpAgentProvider, acpSessions, acpAgentPlugin, opencodeProviderDef, __setOpenCodeCommandRunner } from './index.js';
|
|
8
|
+
import { FakeOpenCode } from './fake-opencode.testkit.js';
|
|
9
|
+
|
|
10
|
+
const resolveHost = { cwd: '/work/app' } as never;
|
|
11
|
+
|
|
12
|
+
describe('the OpenCode plugin', () => {
|
|
13
|
+
it('offers the models the person OpenCode can run once it is switched on', async () => {
|
|
14
|
+
__setOpenCodeCommandRunner(async (_executable, args) =>
|
|
15
|
+
args[0] === 'models'
|
|
16
|
+
? { code: 0, stdout: 'opencode/big-pickle\nopenai/gpt-6-luna\n', stderr: '' }
|
|
17
|
+
: { code: 0, stdout: '● OpenAI oauth\n└ 1 credentials\n', stderr: '' },
|
|
18
|
+
);
|
|
19
|
+
const resolve = opencodeProviderDef.resolveCredentials;
|
|
20
|
+
assertDefined(resolve, 'resolveCredentials');
|
|
21
|
+
|
|
22
|
+
const config = await resolve({ providerConfig: { model: 'openai/gpt-6-luna' }, host: resolveHost } as never);
|
|
23
|
+
const client = opencodeProviderDef.createClient(config);
|
|
24
|
+
|
|
25
|
+
expect(config).toMatchObject({ cwd: '/work/app', executable: 'opencode', models: ['opencode/big-pickle', 'openai/gpt-6-luna'] });
|
|
26
|
+
expect(client.models.map((model) => model.id)).toEqual(['openai/gpt-6-luna', 'opencode/big-pickle']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('lists the models the person OpenCode offers without switching it on', async () => {
|
|
30
|
+
__setOpenCodeCommandRunner(async (_executable, args) =>
|
|
31
|
+
args[0] === 'models' ? { code: 0, stdout: 'opencode/big-pickle\nopenai/gpt-6-luna\n', stderr: '' } : { code: 1, stdout: '', stderr: '' },
|
|
32
|
+
);
|
|
33
|
+
const models = opencodeProviderDef.agentCli?.models;
|
|
34
|
+
assertDefined(models, 'agentCli.models');
|
|
35
|
+
|
|
36
|
+
const offered = await models();
|
|
37
|
+
|
|
38
|
+
expect(offered.map((model) => model.id)).toEqual(['opencode/big-pickle', 'openai/gpt-6-luna']);
|
|
39
|
+
expect(offered.every((model) => model.contextWindow === 200_000)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('offers only the free model OpenCode keeps before it is switched on, not a promotional one that comes and goes', () => {
|
|
43
|
+
expect(opencodeProviderDef.models.map((model) => model.id)).toEqual(['opencode/big-pickle']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('will not switch on when OpenCode is not installed', async () => {
|
|
47
|
+
__setOpenCodeCommandRunner(async () => ({ code: 1, stdout: '', stderr: '', error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }));
|
|
48
|
+
const resolve = opencodeProviderDef.resolveCredentials;
|
|
49
|
+
assertDefined(resolve, 'resolveCredentials');
|
|
50
|
+
|
|
51
|
+
await expect(resolve({ providerConfig: {}, host: resolveHost } as never)).rejects.toThrow(/Install OpenCode/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('closes the conversation OpenCode when the conversation shuts down', async () => {
|
|
55
|
+
const opencode = new FakeOpenCode(async (turn) => turn.say('hi'));
|
|
56
|
+
const provider = new AcpAgentProvider({ startAgent: opencode.start });
|
|
57
|
+
const session = createFakeSession({ provider, plugins: [defaultModePlugin] });
|
|
58
|
+
await collectTurn(session, 'hello');
|
|
59
|
+
expect(acpSessions().size).toBe(1);
|
|
60
|
+
|
|
61
|
+
const onShutdown = acpAgentPlugin.hooks?.onShutdown;
|
|
62
|
+
assertDefined(onShutdown, 'the plugin onShutdown hook');
|
|
63
|
+
await onShutdown(session.appContext());
|
|
64
|
+
|
|
65
|
+
expect(opencode.last.closed).toBe(true);
|
|
66
|
+
expect(acpSessions().size).toBe(0);
|
|
67
|
+
await session.close();
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineProvider, definePlugin } from '@moxxy/sdk';
|
|
2
|
+
import { OPENCODE_CLI, checkAgentCli } from '@moxxy/agent-connection-kit';
|
|
3
|
+
import { OPENCODE_FREE_MODELS, OPENCODE_PROVIDER_ID, OPENCODE_SERVICE_NAME } from './constants.js';
|
|
4
|
+
import { OPENCODE_AGENT } from './agents.js';
|
|
5
|
+
import { acpModel, acpSessions, createAcpAgentClient, type AcpAgentProviderConfig } from './agent-provider.js';
|
|
6
|
+
import { authError, checkOpenCode, listOpenCodeModels, opencodeLogin, opencodeLogout, opencodeStatus, openCodeSignIn, readOpenCodeRules, resolveOpenCodeExecutable } from './login.js';
|
|
7
|
+
import { openCodeOwnAutoApprove } from './rules.js';
|
|
8
|
+
|
|
9
|
+
export const opencodeProviderDef = defineProvider({
|
|
10
|
+
name: OPENCODE_PROVIDER_ID,
|
|
11
|
+
agentName: OPENCODE_AGENT.label,
|
|
12
|
+
models: OPENCODE_FREE_MODELS.map(acpModel),
|
|
13
|
+
agentCli: {
|
|
14
|
+
check: (opts) => {
|
|
15
|
+
const command = resolveOpenCodeExecutable();
|
|
16
|
+
return checkAgentCli(OPENCODE_CLI, { command, refresh: opts?.refresh === true }, { signIn: () => openCodeSignIn(command) });
|
|
17
|
+
},
|
|
18
|
+
models: async () => (await listOpenCodeModels(resolveOpenCodeExecutable())).map(acpModel),
|
|
19
|
+
},
|
|
20
|
+
ownAutoApprove: ({ cwd }) => openCodeOwnAutoApprove(cwd, readOpenCodeRules(resolveOpenCodeExecutable())),
|
|
21
|
+
createClient: (config) => createAcpAgentClient(providerConfigFrom(config)),
|
|
22
|
+
async resolveCredentials({ providerConfig, host }) {
|
|
23
|
+
const config = { ...providerConfig, cwd: host.cwd };
|
|
24
|
+
const executable = resolveOpenCodeExecutable(config);
|
|
25
|
+
const status = await checkOpenCode(executable);
|
|
26
|
+
if (status.state !== 'signed-in') throw authError(status);
|
|
27
|
+
// The picker offers exactly what the person's OpenCode can run.
|
|
28
|
+
return { ...config, executable, models: await listOpenCodeModels(executable) };
|
|
29
|
+
},
|
|
30
|
+
// No validateKey: readiness is reported by the installed OpenCode.
|
|
31
|
+
auth: {
|
|
32
|
+
kind: 'oauth',
|
|
33
|
+
serviceName: OPENCODE_SERVICE_NAME,
|
|
34
|
+
login: opencodeLogin,
|
|
35
|
+
logout: opencodeLogout,
|
|
36
|
+
status: opencodeStatus,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const acpAgentPlugin = definePlugin({
|
|
41
|
+
name: '@moxxy/plugin-provider-acp',
|
|
42
|
+
version: '0.0.0',
|
|
43
|
+
providers: [opencodeProviderDef],
|
|
44
|
+
hooks: {
|
|
45
|
+
// The conversation's agent ends with it; its session stays saved by the agent, to load.
|
|
46
|
+
onShutdown: (ctx) => acpSessions().close(String(ctx.sessionId)),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export default acpAgentPlugin;
|
|
51
|
+
|
|
52
|
+
function providerConfigFrom(config: Readonly<Record<string, unknown>>): AcpAgentProviderConfig {
|
|
53
|
+
const text = (key: string) => (typeof config[key] === 'string' && config[key] !== '' ? (config[key] as string) : undefined);
|
|
54
|
+
const executable = text('executable');
|
|
55
|
+
const model = text('model');
|
|
56
|
+
const defaultModel = text('defaultModel');
|
|
57
|
+
const cwd = text('cwd');
|
|
58
|
+
const models = Array.isArray(config.models) ? config.models.filter((id): id is string => typeof id === 'string' && id !== '') : [];
|
|
59
|
+
return {
|
|
60
|
+
...(executable ? { executable } : {}),
|
|
61
|
+
...(model ? { model } : {}),
|
|
62
|
+
...(defaultModel ? { defaultModel } : {}),
|
|
63
|
+
...(cwd ? { cwd } : {}),
|
|
64
|
+
...(models.length > 0 ? { models } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { OPENCODE_PROVIDER_ID, OPENCODE_SERVICE_NAME, OPENCODE_EXECUTABLE_ENV, OPENCODE_DEFAULT_MODEL, OPENCODE_FREE_MODELS } from './constants.js';
|
|
69
|
+
export { OPENCODE_AGENT, type AcpAgentSpec } from './agents.js';
|
|
70
|
+
export { AcpAgentProvider, acpModel, acpSessions, createAcpAgentClient, type AcpAgentProviderConfig } from './agent-provider.js';
|
|
71
|
+
export type { AcpConnection, StartAcpAgent } from './acp-session.js';
|
|
72
|
+
export { opencodeTarget, spawnOpenCodeAcp } from './executable.js';
|
|
73
|
+
export {
|
|
74
|
+
__setOpenCodeCommandRunner,
|
|
75
|
+
checkOpenCode,
|
|
76
|
+
listOpenCodeModels,
|
|
77
|
+
opencodeLogin,
|
|
78
|
+
opencodeLogout,
|
|
79
|
+
opencodeStatus,
|
|
80
|
+
resolveOpenCodeExecutable,
|
|
81
|
+
type OpenCodeState,
|
|
82
|
+
type OpenCodeStatus,
|
|
83
|
+
} from './login.js';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Sign-in, status and models come from the person's own `opencode` CLI; the CLI is the boundary stood in for here. */
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { MoxxyError, type ProviderAuthContext } from '@moxxy/sdk';
|
|
4
|
+
import {
|
|
5
|
+
__setOpenCodeCommandRunner,
|
|
6
|
+
checkOpenCode,
|
|
7
|
+
listOpenCodeModels,
|
|
8
|
+
opencodeLogin,
|
|
9
|
+
opencodeStatus,
|
|
10
|
+
readOpenCodeMcpServers,
|
|
11
|
+
readOpenCodeRules,
|
|
12
|
+
resolveOpenCodeExecutable,
|
|
13
|
+
type OpenCodeCommandResult,
|
|
14
|
+
} from './login.js';
|
|
15
|
+
|
|
16
|
+
const AUTH_LIST_ONE = '\u001b[0m\n┌ Credentials \u001b[90m~/.local/share/opencode/auth.json\n│\n● OpenAI \u001b[90moauth\n│\n└ 1 credentials\n';
|
|
17
|
+
const AUTH_LIST_NONE = '\u001b[0m\n┌ Credentials \u001b[90m~/.local/share/opencode/auth.json\n│\n└ 0 credentials\n';
|
|
18
|
+
const MODELS = 'opencode/big-pickle\nopencode/space-bunny-free\nopenai/gpt-6-luna\n';
|
|
19
|
+
|
|
20
|
+
const calls: Array<{ executable: string; args: readonly string[]; inherit: boolean; cwd?: string }> = [];
|
|
21
|
+
function answer(results: Record<string, OpenCodeCommandResult>): void {
|
|
22
|
+
__setOpenCodeCommandRunner(async (executable, args, options = {}) => {
|
|
23
|
+
calls.push({ executable, args, inherit: options.inherit === true, ...(options.cwd ? { cwd: options.cwd } : {}) });
|
|
24
|
+
const result = results[args.join(' ')];
|
|
25
|
+
if (!result) throw new Error(`unexpected command: ${args.join(' ')}`);
|
|
26
|
+
return result;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const ok = (stdout: string): OpenCodeCommandResult => ({ code: 0, stdout, stderr: '' });
|
|
30
|
+
const missing: OpenCodeCommandResult = { code: 1, stdout: '', stderr: '', error: Object.assign(new Error('spawn opencode ENOENT'), { code: 'ENOENT' }) };
|
|
31
|
+
const ctx = (over: Partial<ProviderAuthContext> = {}): ProviderAuthContext =>
|
|
32
|
+
({ vault: {} as never, headless: false, write: () => undefined, ...over }) as ProviderAuthContext;
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
calls.length = 0;
|
|
36
|
+
delete process.env.OPENCODE_EXECUTABLE;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('OpenCode readiness', () => {
|
|
40
|
+
it('is ready once installed, naming the providers it is signed in to', async () => {
|
|
41
|
+
answer({ 'auth list': ok(AUTH_LIST_ONE) });
|
|
42
|
+
|
|
43
|
+
expect(await checkOpenCode('opencode')).toEqual({
|
|
44
|
+
state: 'signed-in',
|
|
45
|
+
executable: 'opencode',
|
|
46
|
+
accountId: 'OpenAI',
|
|
47
|
+
message: 'OpenCode is ready, signed in to OpenAI.',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is ready without any sign-in, since its free models need none', async () => {
|
|
52
|
+
answer({ 'auth list': ok(AUTH_LIST_NONE) });
|
|
53
|
+
|
|
54
|
+
const status = await checkOpenCode('opencode');
|
|
55
|
+
|
|
56
|
+
expect(status.state).toBe('signed-in');
|
|
57
|
+
expect(status.accountId).toBeUndefined();
|
|
58
|
+
expect(status.message).toContain('opencode auth login');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('says how to install OpenCode when it is missing', async () => {
|
|
62
|
+
answer({ 'auth list': missing });
|
|
63
|
+
|
|
64
|
+
expect(await checkOpenCode('opencode')).toMatchObject({ state: 'missing', message: expect.stringContaining('OPENCODE_EXECUTABLE') });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('finds OpenCode through the provider config, then OPENCODE_EXECUTABLE, then PATH', () => {
|
|
68
|
+
process.env.OPENCODE_EXECUTABLE = '/opt/opencode';
|
|
69
|
+
expect(resolveOpenCodeExecutable({ executable: '/custom/opencode' })).toBe('/custom/opencode');
|
|
70
|
+
expect(resolveOpenCodeExecutable({})).toBe('/opt/opencode');
|
|
71
|
+
delete process.env.OPENCODE_EXECUTABLE;
|
|
72
|
+
expect(resolveOpenCodeExecutable({})).toBe('opencode');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reports the status for the status surfaces', async () => {
|
|
76
|
+
answer({ 'auth list': ok(AUTH_LIST_ONE) });
|
|
77
|
+
|
|
78
|
+
expect(await opencodeStatus(ctx())).toEqual({ accountId: 'OpenAI', authState: 'signed-in', message: 'OpenCode is ready, signed in to OpenAI.' });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('OpenCode models', () => {
|
|
83
|
+
it('lists the models the person OpenCode offers', async () => {
|
|
84
|
+
answer({ models: ok(`${MODELS}\n`) });
|
|
85
|
+
|
|
86
|
+
expect(await listOpenCodeModels('opencode')).toEqual(['opencode/big-pickle', 'opencode/space-bunny-free', 'openai/gpt-6-luna']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('lists none when OpenCode cannot say', async () => {
|
|
90
|
+
answer({ models: { code: 1, stdout: '', stderr: 'boom' } });
|
|
91
|
+
|
|
92
|
+
expect(await listOpenCodeModels('opencode')).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('signing OpenCode in to a model provider', () => {
|
|
97
|
+
it('hands the terminal to `opencode auth login`, where OpenCode asks which provider', async () => {
|
|
98
|
+
answer({ 'auth list': ok(AUTH_LIST_ONE), 'auth login': ok('') });
|
|
99
|
+
|
|
100
|
+
const result = await opencodeLogin(ctx());
|
|
101
|
+
|
|
102
|
+
expect(calls.find((call) => call.args.join(' ') === 'auth login')).toMatchObject({ inherit: true });
|
|
103
|
+
expect(result).toEqual({ accountId: 'OpenAI' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('asks a window without a terminal to have the person run it in a terminal', async () => {
|
|
107
|
+
answer({ 'auth list': ok(AUTH_LIST_NONE) });
|
|
108
|
+
|
|
109
|
+
await expect(opencodeLogin(ctx({ noOpen: true }))).rejects.toThrow(MoxxyError);
|
|
110
|
+
await expect(opencodeLogin(ctx({ noOpen: true }))).rejects.toMatchObject({ hint: expect.stringContaining('opencode auth login') });
|
|
111
|
+
expect(calls.some((call) => call.args.join(' ') === 'auth login')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('how the person OpenCode runs', () => {
|
|
116
|
+
it('reads an agent rules in the conversation folder, where project config applies', async () => {
|
|
117
|
+
const agent = { name: 'build', mode: 'primary', permission: [{ permission: '*', action: 'allow', pattern: '*' }, { permission: 'bash', pattern: 'git push *', action: 'deny' }] };
|
|
118
|
+
answer({ 'debug agent build': ok(JSON.stringify(agent, null, 2)) });
|
|
119
|
+
|
|
120
|
+
const rules = await readOpenCodeRules('opencode')({ cwd: '/work/app', agent: 'build' });
|
|
121
|
+
|
|
122
|
+
expect(rules).toEqual([
|
|
123
|
+
{ permission: '*', pattern: '*', action: 'allow' },
|
|
124
|
+
{ permission: 'bash', pattern: 'git push *', action: 'deny' },
|
|
125
|
+
]);
|
|
126
|
+
expect(calls[0]).toMatchObject({ args: ['debug', 'agent', 'build'], cwd: '/work/app' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('cannot say when OpenCode fails or prints something else', async () => {
|
|
130
|
+
answer({ 'debug agent build': { code: 1, stdout: '', stderr: 'Error: Configuration is invalid' }, 'debug agent plan': ok('not json') });
|
|
131
|
+
|
|
132
|
+
expect(await readOpenCodeRules('opencode')({ cwd: '/w', agent: 'build' })).toBeUndefined();
|
|
133
|
+
expect(await readOpenCodeRules('opencode')({ cwd: '/w', agent: 'plan' })).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("the person's own OpenCode MCP servers", () => {
|
|
138
|
+
it('are the switched-on servers of the config OpenCode resolves where the turn runs', async () => {
|
|
139
|
+
const config = { mcp: { vault: { type: 'local', command: ['node', 'x.mjs'] }, off: { type: 'local', command: ['node', 'y.mjs'], enabled: false } } };
|
|
140
|
+
answer({ 'debug config': ok(JSON.stringify(config, null, 2)) });
|
|
141
|
+
|
|
142
|
+
const servers = await readOpenCodeMcpServers('opencode')({ cwd: '/work/app' });
|
|
143
|
+
|
|
144
|
+
expect(servers).toEqual(['vault']);
|
|
145
|
+
expect(calls[0]).toMatchObject({ args: ['debug', 'config'], cwd: '/work/app' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('cannot say when OpenCode fails or prints something else', async () => {
|
|
149
|
+
answer({ 'debug config': { code: 1, stdout: '', stderr: 'Error: Configuration is invalid' } });
|
|
150
|
+
expect(await readOpenCodeMcpServers('opencode')({ cwd: '/w' })).toBeUndefined();
|
|
151
|
+
|
|
152
|
+
answer({ 'debug config': ok('not json') });
|
|
153
|
+
expect(await readOpenCodeMcpServers('opencode')({ cwd: '/w' })).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { AgentSignIn } from '@moxxy/agent-connection-kit';
|
|
2
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
3
|
+
import { spawnExecutableTarget } from '@moxxy/sdk/server';
|
|
4
|
+
import type { ProviderAuthContext, ProviderOAuthResult, ProviderOAuthStatus } from '@moxxy/sdk';
|
|
5
|
+
import {
|
|
6
|
+
OPENCODE_DEBUG_AGENT_ARGS,
|
|
7
|
+
OPENCODE_DEBUG_CONFIG_ARGS,
|
|
8
|
+
OPENCODE_AUTH_LIST_ARGS,
|
|
9
|
+
OPENCODE_AUTH_LOGIN_ARGS,
|
|
10
|
+
OPENCODE_AUTH_LOGOUT_ARGS,
|
|
11
|
+
OPENCODE_EXECUTABLE_ENV,
|
|
12
|
+
OPENCODE_MODELS_ARGS,
|
|
13
|
+
OPENCODE_PROVIDER_ID,
|
|
14
|
+
} from './constants.js';
|
|
15
|
+
import { opencodeTarget } from './executable.js';
|
|
16
|
+
import type { OpenCodeRule, ReadAgentMcpServers, ReadAgentRules } from './rules.js';
|
|
17
|
+
|
|
18
|
+
export type OpenCodeState = 'signed-in' | 'missing' | 'error';
|
|
19
|
+
|
|
20
|
+
export interface OpenCodeStatus {
|
|
21
|
+
readonly state: OpenCodeState;
|
|
22
|
+
readonly executable: string;
|
|
23
|
+
/** The model providers OpenCode is signed in to (`OpenAI, Anthropic`). */
|
|
24
|
+
readonly accountId?: string;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OpenCodeCommandResult {
|
|
29
|
+
readonly code: number;
|
|
30
|
+
readonly stdout: string;
|
|
31
|
+
readonly stderr: string;
|
|
32
|
+
readonly error?: NodeJS.ErrnoException;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CommandOptions {
|
|
36
|
+
/** Hand OpenCode the terminal (its interactive provider picker). */
|
|
37
|
+
readonly inherit?: boolean;
|
|
38
|
+
/** Where OpenCode runs, so the project's own config applies. */
|
|
39
|
+
readonly cwd?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type CommandRunner = (executable: string, args: readonly string[], options?: CommandOptions) => Promise<OpenCodeCommandResult>;
|
|
43
|
+
|
|
44
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
45
|
+
const CREDENTIAL = /^●\s+(.+?)\s+\S+$/;
|
|
46
|
+
const MODEL_ID = /^[\w.@-]+\/\S+$/;
|
|
47
|
+
const LOGIN_HINT = 'Run `opencode auth login` in a terminal to sign OpenCode in to a model provider.';
|
|
48
|
+
|
|
49
|
+
let runCommandImpl: CommandRunner = runCommand;
|
|
50
|
+
/** Test seam. */
|
|
51
|
+
export function __setOpenCodeCommandRunner(runner: CommandRunner): void {
|
|
52
|
+
runCommandImpl = runner;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveOpenCodeExecutable(config: Readonly<Record<string, unknown>> = {}): string {
|
|
56
|
+
const configured = config.executable;
|
|
57
|
+
if (typeof configured === 'string' && configured.trim()) return configured;
|
|
58
|
+
return process.env[OPENCODE_EXECUTABLE_ENV]?.trim() || 'opencode';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the person's OpenCode can work, read from `opencode auth list`.
|
|
63
|
+
* Installed is enough: OpenCode's free models need no sign-in, and the
|
|
64
|
+
* providers it is signed in to are named. Moxxy never touches its tokens.
|
|
65
|
+
*/
|
|
66
|
+
export async function checkOpenCode(executable = resolveOpenCodeExecutable()): Promise<OpenCodeStatus> {
|
|
67
|
+
const result = await runCommandImpl(executable, OPENCODE_AUTH_LIST_ARGS);
|
|
68
|
+
if (result.error?.code === 'ENOENT') {
|
|
69
|
+
return { state: 'missing', executable, message: `OpenCode not found: ${executable}. Install OpenCode or set ${OPENCODE_EXECUTABLE_ENV}.` };
|
|
70
|
+
}
|
|
71
|
+
if (result.error || result.code !== 0) {
|
|
72
|
+
const detail = result.error?.message ?? (result.stderr.trim() || `exit code ${result.code}`);
|
|
73
|
+
return { state: 'error', executable, message: `Could not run OpenCode: ${detail}` };
|
|
74
|
+
}
|
|
75
|
+
const providers = result.stdout
|
|
76
|
+
.replace(ANSI, '')
|
|
77
|
+
.split('\n')
|
|
78
|
+
.flatMap((line) => {
|
|
79
|
+
const match = CREDENTIAL.exec(line.trim());
|
|
80
|
+
return match?.[1] ? [match[1]] : [];
|
|
81
|
+
});
|
|
82
|
+
if (providers.length === 0) {
|
|
83
|
+
return { state: 'signed-in', executable, message: `OpenCode is ready with its free models. ${LOGIN_HINT}` };
|
|
84
|
+
}
|
|
85
|
+
const accountId = providers.join(', ');
|
|
86
|
+
return { state: 'signed-in', executable, accountId, message: `OpenCode is ready, signed in to ${accountId}.` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Who OpenCode is signed in to, for the connection's row; its free models need no sign-in. */
|
|
90
|
+
export async function openCodeSignIn(executable = resolveOpenCodeExecutable()): Promise<AgentSignIn> {
|
|
91
|
+
const status = await checkOpenCode(executable);
|
|
92
|
+
return { signedIn: status.state === 'signed-in', ...(status.accountId ? { account: status.accountId } : {}) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The models the person's OpenCode offers (`opencode models`); none when it cannot say. */
|
|
96
|
+
export async function listOpenCodeModels(executable = resolveOpenCodeExecutable()): Promise<string[]> {
|
|
97
|
+
const result = await runCommandImpl(executable, OPENCODE_MODELS_ARGS);
|
|
98
|
+
if (result.error || result.code !== 0) return [];
|
|
99
|
+
return result.stdout
|
|
100
|
+
.replace(ANSI, '')
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((line) => line.trim())
|
|
103
|
+
.filter((line) => MODEL_ID.test(line));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The rules of one of the person's OpenCode agents (`opencode debug agent <name>`), read where the conversation runs. */
|
|
107
|
+
export function readOpenCodeRules(executable = resolveOpenCodeExecutable()): ReadAgentRules {
|
|
108
|
+
return async ({ cwd, agent }) => {
|
|
109
|
+
const result = await runCommandImpl(executable, [...OPENCODE_DEBUG_AGENT_ARGS, agent], { cwd });
|
|
110
|
+
if (result.error || result.code !== 0) return undefined;
|
|
111
|
+
try {
|
|
112
|
+
const { permission } = JSON.parse(result.stdout) as { permission?: unknown };
|
|
113
|
+
return Array.isArray(permission) ? permission.filter(isRule) : undefined;
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The person's own MCP servers that are on, from the config OpenCode resolves in `cwd` (`opencode debug config`). */
|
|
121
|
+
export function readOpenCodeMcpServers(executable = resolveOpenCodeExecutable()): ReadAgentMcpServers {
|
|
122
|
+
return async ({ cwd }) => {
|
|
123
|
+
const result = await runCommandImpl(executable, [...OPENCODE_DEBUG_CONFIG_ARGS], { cwd });
|
|
124
|
+
if (result.error || result.code !== 0) return undefined;
|
|
125
|
+
try {
|
|
126
|
+
const { mcp } = JSON.parse(result.stdout) as { mcp?: Record<string, { enabled?: unknown } | null> };
|
|
127
|
+
return Object.entries(mcp ?? {}).flatMap(([name, server]) => (server?.enabled === false ? [] : [name]));
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isRule(value: unknown): value is OpenCodeRule {
|
|
135
|
+
const rule = value as Partial<OpenCodeRule> | null;
|
|
136
|
+
return (
|
|
137
|
+
typeof rule === 'object' && rule !== null && typeof rule.permission === 'string' && typeof rule.pattern === 'string'
|
|
138
|
+
&& (rule.action === 'allow' || rule.action === 'ask' || rule.action === 'deny')
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function opencodeLogin(ctx: ProviderAuthContext): Promise<ProviderOAuthResult> {
|
|
143
|
+
const executable = resolveOpenCodeExecutable(ctx.providerConfig);
|
|
144
|
+
const before = await checkOpenCode(executable);
|
|
145
|
+
if (before.state !== 'signed-in') throw authError(before);
|
|
146
|
+
// OpenCode asks which provider and how in its own terminal picker; a window cannot answer it.
|
|
147
|
+
if (ctx.noOpen) {
|
|
148
|
+
throw new MoxxyError({
|
|
149
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
150
|
+
message: 'OpenCode signs in to model providers in a terminal.',
|
|
151
|
+
hint: LOGIN_HINT,
|
|
152
|
+
context: { provider: OPENCODE_PROVIDER_ID },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const login = await runCommandImpl(executable, OPENCODE_AUTH_LOGIN_ARGS, { inherit: true });
|
|
156
|
+
if (login.error) throw authError({ state: login.error.code === 'ENOENT' ? 'missing' : 'error', executable, message: login.error.message });
|
|
157
|
+
if (login.code !== 0) {
|
|
158
|
+
throw new MoxxyError({
|
|
159
|
+
code: 'AUTH_DENIED',
|
|
160
|
+
message: `OpenCode login failed with exit code ${login.code}.`,
|
|
161
|
+
hint: 'Run `opencode auth login` directly for more detail, then retry.',
|
|
162
|
+
context: { provider: OPENCODE_PROVIDER_ID },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const after = await checkOpenCode(executable);
|
|
166
|
+
if (after.state !== 'signed-in') throw authError(after);
|
|
167
|
+
return { accountId: after.accountId ?? null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function opencodeLogout(ctx: ProviderAuthContext): Promise<boolean> {
|
|
171
|
+
const executable = resolveOpenCodeExecutable(ctx.providerConfig);
|
|
172
|
+
const before = await checkOpenCode(executable);
|
|
173
|
+
if (before.state !== 'signed-in' || before.accountId === undefined) return false;
|
|
174
|
+
if (ctx.noOpen) {
|
|
175
|
+
throw new MoxxyError({
|
|
176
|
+
code: 'AUTH_INVALID',
|
|
177
|
+
message: 'OpenCode signs out of model providers in a terminal.',
|
|
178
|
+
hint: 'Run `opencode auth logout` in a terminal.',
|
|
179
|
+
context: { provider: OPENCODE_PROVIDER_ID },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const result = await runCommandImpl(executable, OPENCODE_AUTH_LOGOUT_ARGS, { inherit: true });
|
|
183
|
+
if (result.error || result.code !== 0) {
|
|
184
|
+
throw new MoxxyError({ code: 'AUTH_INVALID', message: `OpenCode logout failed${result.stderr ? `: ${result.stderr}` : '.'}` });
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function opencodeStatus(ctx: ProviderAuthContext): Promise<ProviderOAuthStatus | null> {
|
|
190
|
+
const status = await checkOpenCode(resolveOpenCodeExecutable(ctx.providerConfig));
|
|
191
|
+
return { accountId: status.accountId ?? null, authState: status.state, message: status.message };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function authError(status: OpenCodeStatus): MoxxyError {
|
|
195
|
+
return new MoxxyError({
|
|
196
|
+
code: 'AUTH_INVALID',
|
|
197
|
+
message: status.message,
|
|
198
|
+
context: { provider: OPENCODE_PROVIDER_ID, executable: status.executable, state: status.state },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function runCommand(executable: string, args: readonly string[], options: CommandOptions = {}): Promise<OpenCodeCommandResult> {
|
|
203
|
+
let target;
|
|
204
|
+
try {
|
|
205
|
+
target = opencodeTarget(executable);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const error = Object.assign(err instanceof Error ? err : new Error(String(err)), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
|
208
|
+
return { code: 1, stdout: '', stderr: '', error };
|
|
209
|
+
}
|
|
210
|
+
return await new Promise((resolve) => {
|
|
211
|
+
const where = options.cwd ? { cwd: options.cwd } : {};
|
|
212
|
+
const child = spawnExecutableTarget(target, [...args], options.inherit ? { ...where, stdio: 'inherit' } : { ...where, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
213
|
+
let stdout = '';
|
|
214
|
+
let stderr = '';
|
|
215
|
+
child.stdout?.setEncoding('utf8');
|
|
216
|
+
child.stderr?.setEncoding('utf8');
|
|
217
|
+
child.stdout?.on('data', (chunk: string) => (stdout += chunk));
|
|
218
|
+
child.stderr?.on('data', (chunk: string) => (stderr += chunk));
|
|
219
|
+
child.once('error', (error: NodeJS.ErrnoException) => resolve({ code: 1, stdout, stderr, error }));
|
|
220
|
+
child.once('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode's own HTTP API, which `opencode acp` serves beside ACP. ACP does
|
|
3
|
+
* not report what a subagent does nor when a subagent working in the
|
|
4
|
+
* background ends, and has no way to stop one; this API has both: its event
|
|
5
|
+
* stream (`GET /event`) and `POST /session/{id}/abort`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Where the API of one running `opencode acp` is, and the credentials Moxxy started it with. */
|
|
9
|
+
export interface OpenCodeApi {
|
|
10
|
+
readonly url: string;
|
|
11
|
+
/** The `Authorization` header value. */
|
|
12
|
+
readonly authorization: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One event of OpenCode's stream (`session.status`, `message.part.updated`, …). */
|
|
16
|
+
export interface OpenCodeEvent {
|
|
17
|
+
readonly type: string;
|
|
18
|
+
readonly properties: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenCodeEventWatch {
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How long to wait before connecting again after the stream failed or ended. */
|
|
26
|
+
const RECONNECT_MS = 250;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read OpenCode's event stream for the project `directory` until closed,
|
|
30
|
+
* connecting again whenever it ends (OpenCode serves the API a moment after it
|
|
31
|
+
* starts). `onConnected` is told each time the stream is up.
|
|
32
|
+
*/
|
|
33
|
+
export function watchOpenCodeEvents(
|
|
34
|
+
api: OpenCodeApi,
|
|
35
|
+
directory: string,
|
|
36
|
+
onEvent: (event: OpenCodeEvent) => void,
|
|
37
|
+
opts: { readonly onConnected?: () => void; readonly onDisconnected?: () => void } = {},
|
|
38
|
+
): OpenCodeEventWatch {
|
|
39
|
+
const stop = new AbortController();
|
|
40
|
+
void (async () => {
|
|
41
|
+
while (!stop.signal.aborted) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`${api.url}/event?directory=${encodeURIComponent(directory)}`, {
|
|
44
|
+
headers: { Authorization: api.authorization, Accept: 'text/event-stream' },
|
|
45
|
+
signal: stop.signal,
|
|
46
|
+
});
|
|
47
|
+
if (response.ok && response.body) {
|
|
48
|
+
opts.onConnected?.();
|
|
49
|
+
await readEvents(response.body, onEvent);
|
|
50
|
+
opts.onDisconnected?.();
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Not up yet, or gone: try again below while the watch is open.
|
|
54
|
+
}
|
|
55
|
+
if (stop.signal.aborted) return;
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, RECONNECT_MS));
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
return { close: () => stop.abort() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stop what an OpenCode session runs (a subagent's session too). False when OpenCode could not be asked or refused. */
|
|
63
|
+
export async function abortOpenCodeSession(api: OpenCodeApi, directory: string, sessionId: string): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(`${api.url}/session/${encodeURIComponent(sessionId)}/abort?directory=${encodeURIComponent(directory)}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { Authorization: api.authorization },
|
|
68
|
+
});
|
|
69
|
+
return response.ok && (await response.json().catch(() => false)) === true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Server-sent events: each event's `data:` lines are one JSON object. */
|
|
76
|
+
async function readEvents(body: ReadableStream<Uint8Array>, onEvent: (event: OpenCodeEvent) => void): Promise<void> {
|
|
77
|
+
const decoder = new TextDecoder();
|
|
78
|
+
let buffer = '';
|
|
79
|
+
for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
|
|
80
|
+
buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
|
|
81
|
+
for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) {
|
|
82
|
+
const block = buffer.slice(0, end);
|
|
83
|
+
buffer = buffer.slice(end + 2);
|
|
84
|
+
const data = block
|
|
85
|
+
.split('\n')
|
|
86
|
+
.filter((line) => line.startsWith('data:'))
|
|
87
|
+
.map((line) => line.slice(5).trimStart())
|
|
88
|
+
.join('\n');
|
|
89
|
+
const event = parseEvent(data);
|
|
90
|
+
if (event) onEvent(event);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseEvent(data: string): OpenCodeEvent | undefined {
|
|
96
|
+
if (!data) return undefined;
|
|
97
|
+
try {
|
|
98
|
+
const value = JSON.parse(data) as { type?: unknown; properties?: unknown };
|
|
99
|
+
if (typeof value.type !== 'string') return undefined;
|
|
100
|
+
const properties = typeof value.properties === 'object' && value.properties !== null ? (value.properties as Record<string, unknown>) : {};
|
|
101
|
+
return { type: value.type, properties };
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|