@axiom-lattice/cli-a2a 0.1.1

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 (63) hide show
  1. package/.turbo/turbo-build.log +51 -0
  2. package/CHANGELOG.md +7 -0
  3. package/LICENSE +201 -0
  4. package/README.md +290 -0
  5. package/__tests__/opencode-executor.test.ts +159 -0
  6. package/agents/opencode-example/config.json +64 -0
  7. package/dist/bridge-7ZUDKCZT.mjs +271 -0
  8. package/dist/bridge-7ZUDKCZT.mjs.map +1 -0
  9. package/dist/chunk-35NFMGMS.mjs +27 -0
  10. package/dist/chunk-35NFMGMS.mjs.map +1 -0
  11. package/dist/chunk-G7AGL2QA.mjs +284 -0
  12. package/dist/chunk-G7AGL2QA.mjs.map +1 -0
  13. package/dist/chunk-LXL47XMZ.mjs +43 -0
  14. package/dist/chunk-LXL47XMZ.mjs.map +1 -0
  15. package/dist/chunk-NQIDRU47.mjs +178 -0
  16. package/dist/chunk-NQIDRU47.mjs.map +1 -0
  17. package/dist/chunk-VSZ3DACI.mjs +179 -0
  18. package/dist/chunk-VSZ3DACI.mjs.map +1 -0
  19. package/dist/chunk-VZEH3EPJ.mjs +56 -0
  20. package/dist/chunk-VZEH3EPJ.mjs.map +1 -0
  21. package/dist/chunk-WNCDOYZS.mjs +187 -0
  22. package/dist/chunk-WNCDOYZS.mjs.map +1 -0
  23. package/dist/cli.d.mts +1 -0
  24. package/dist/cli.d.ts +1 -0
  25. package/dist/cli.js +1562 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/cli.mjs +293 -0
  28. package/dist/cli.mjs.map +1 -0
  29. package/dist/executor-OWPVDUXH.mjs +11 -0
  30. package/dist/executor-OWPVDUXH.mjs.map +1 -0
  31. package/dist/executor-RVBGAWUF.mjs +11 -0
  32. package/dist/executor-RVBGAWUF.mjs.map +1 -0
  33. package/dist/executor-XWHWUVQ3.mjs +11 -0
  34. package/dist/executor-XWHWUVQ3.mjs.map +1 -0
  35. package/dist/executors-QIIKBUMJ.mjs +15 -0
  36. package/dist/executors-QIIKBUMJ.mjs.map +1 -0
  37. package/dist/index.d.mts +295 -0
  38. package/dist/index.d.ts +295 -0
  39. package/dist/index.js +942 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/index.mjs +44 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/jest.config.js +16 -0
  44. package/package.json +63 -0
  45. package/src/bridge.ts +355 -0
  46. package/src/cli.ts +384 -0
  47. package/src/config/defaults.ts +109 -0
  48. package/src/config/index.ts +16 -0
  49. package/src/config/loader.ts +121 -0
  50. package/src/config/types.ts +163 -0
  51. package/src/executors/claude/client.ts +138 -0
  52. package/src/executors/claude/executor.ts +111 -0
  53. package/src/executors/codex/client.ts +139 -0
  54. package/src/executors/codex/executor.ts +118 -0
  55. package/src/executors/events.ts +117 -0
  56. package/src/executors/index.ts +54 -0
  57. package/src/executors/opencode/client.ts +149 -0
  58. package/src/executors/opencode/executor.ts +112 -0
  59. package/src/index.ts +54 -0
  60. package/src/logger.ts +78 -0
  61. package/src/server/agent-card.ts +51 -0
  62. package/src/server/index.ts +124 -0
  63. package/tsconfig.json +21 -0
package/src/cli.ts ADDED
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI A2A Gateway — Entry Point
4
+ *
5
+ * Usage:
6
+ * npx @axiom-lattice/cli-a2a --provider opencode --config agents/example.json
7
+ * npx @axiom-lattice/cli-a2a --provider opencode --port 3000 --opencode-url http://localhost:4096
8
+ *
9
+ * Run with --help for full usage.
10
+ */
11
+
12
+ import { parseArgs } from 'node:util';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { resolveConfig } from './config/loader.js';
15
+ import type { AgentConfig, ProviderType } from './config/types.js';
16
+ import { createA2AServer } from './server/index.js';
17
+ import { logger, LogLevel } from './logger.js';
18
+
19
+ const log = logger.child('cli');
20
+
21
+ // ─── Version ────────────────────────────────────────────────────────────────
22
+
23
+ const PKG_VERSION = '0.1.0';
24
+
25
+ // ─── Usage ──────────────────────────────────────────────────────────────────
26
+
27
+ function printUsage(): void {
28
+ console.log(`
29
+ Usage: cli-a2a [options]
30
+
31
+ Options:
32
+ --provider <name> Provider: opencode | codex | claude
33
+ --config, -c <path> Path to agent JSON config file
34
+ --port, -p <number> A2A server port (default: 3000)
35
+ --hostname <addr> Bind address (default: 0.0.0.0)
36
+ --advertise-host <host> Hostname for agent card URLs
37
+ --agent-name <name> Agent display name
38
+ --agent-description <desc> Agent description
39
+ --log-level <level> debug | info | warn | error (default: info)
40
+
41
+ Gateway Registration:
42
+ --register-url <url> Gateway URL to register this agent with
43
+ --register-key <key> API key for gateway authentication
44
+ --unregister-on-shutdown Remove agent from gateway on exit
45
+
46
+ Bridge (NAT traversal):
47
+ --bridge-url <url> Gateway WebSocket bridge (ws://gateway:8080/api/a2a/bridge)
48
+ --bridge-key <key> API key for bridge authentication
49
+
50
+ OpenCode Options:
51
+ --opencode-url <url> OpenCode server URL (default: http://localhost:4096)
52
+ --directory, -d <path> Project directory
53
+ --model, -m <provider/model> LLM model (e.g. anthropic/claude-sonnet-4)
54
+ --agent <name> OpenCode agent preset
55
+
56
+ Codex Options:
57
+ --codex-model <model> Codex model ID
58
+ --codex-api-key <key> OpenAI API key
59
+
60
+ Claude Options:
61
+ --claude-model <model> Claude Code model ID
62
+ --claude-api-key <key> Anthropic API key
63
+
64
+ --help, -h Show this help
65
+ --version, -v Show version
66
+
67
+ Examples:
68
+ npx @axiom-lattice/cli-a2a --provider opencode
69
+ npx @axiom-lattice/cli-a2a --provider opencode --port 3001 --opencode-url http://localhost:4096
70
+ npx @axiom-lattice/cli-a2a --config ./my-agent.json
71
+ npx @axiom-lattice/cli-a2a --provider opencode --model anthropic/claude-sonnet-4
72
+ npx @axiom-lattice/cli-a2a --provider opencode --register-url http://gateway:8080 --register-key sk-xxx --unregister-on-shutdown
73
+ npx @axiom-lattice/cli-a2a --provider opencode --bridge-url ws://gateway:8080/api/a2a/bridge --bridge-key sk-xxx
74
+ `);
75
+ }
76
+
77
+ // ─── CLI Args ───────────────────────────────────────────────────────────────
78
+
79
+ function parseCliArgs(): {
80
+ configPath?: string;
81
+ overrides: Partial<AgentConfig>;
82
+ registerUrl?: string;
83
+ registerKey?: string;
84
+ unregisterOnShutdown: boolean;
85
+ bridgeUrl?: string;
86
+ bridgeKey?: string;
87
+ } {
88
+ const { values } = parseArgs({
89
+ options: {
90
+ provider: { type: 'string' },
91
+ config: { type: 'string', short: 'c' },
92
+ port: { type: 'string', short: 'p' },
93
+ hostname: { type: 'string' },
94
+ 'advertise-host': { type: 'string' },
95
+ 'agent-name': { type: 'string' },
96
+ 'agent-description':{ type: 'string' },
97
+ 'opencode-url': { type: 'string' },
98
+ directory: { type: 'string', short: 'd' },
99
+ model: { type: 'string', short: 'm' },
100
+ agent: { type: 'string' },
101
+ 'codex-model': { type: 'string' },
102
+ 'codex-api-key': { type: 'string' },
103
+ 'claude-model': { type: 'string' },
104
+ 'claude-api-key': { type: 'string' },
105
+ 'log-level': { type: 'string' },
106
+ 'register-url': { type: 'string' },
107
+ 'register-key': { type: 'string' },
108
+ 'unregister-on-shutdown': { type: 'boolean' },
109
+ 'bridge-url': { type: 'string' },
110
+ 'bridge-key': { type: 'string' },
111
+ help: { type: 'boolean', short: 'h' },
112
+ version: { type: 'boolean', short: 'v' },
113
+ },
114
+ strict: false,
115
+ });
116
+
117
+ if (values.help) {
118
+ printUsage();
119
+ process.exit(0);
120
+ }
121
+
122
+ if (values.version) {
123
+ console.log(PKG_VERSION);
124
+ process.exit(0);
125
+ }
126
+
127
+ const overrides: Partial<AgentConfig> = {};
128
+
129
+ // Provider
130
+ if (values.provider) {
131
+ overrides.provider = values.provider as ProviderType;
132
+ }
133
+
134
+ // Server
135
+ if (values.port || values.hostname || values['advertise-host']) {
136
+ overrides.server = {};
137
+ if (values.port) overrides.server.port = parseInt(values.port as string, 10);
138
+ if (values.hostname) overrides.server.hostname = values.hostname as string;
139
+ if (values['advertise-host']) overrides.server.advertiseHost = values['advertise-host'] as string;
140
+ }
141
+
142
+ // Agent card
143
+ const agentCardOverrides: Partial<AgentConfig['agentCard']> = {};
144
+ if (values['agent-name']) agentCardOverrides.name = values['agent-name'] as string;
145
+ if (values['agent-description']) agentCardOverrides.description = values['agent-description'] as string;
146
+ if (Object.keys(agentCardOverrides).length > 0) {
147
+ overrides.agentCard = agentCardOverrides as AgentConfig['agentCard'];
148
+ }
149
+
150
+ // OpenCode
151
+ const hasOpenCode = !!(values['opencode-url'] || values.directory || values.model || values.agent);
152
+ if (hasOpenCode) {
153
+ overrides.opencode = {};
154
+ if (values['opencode-url']) overrides.opencode.baseUrl = values['opencode-url'] as string;
155
+ if (values.directory) overrides.opencode.projectDirectory = values.directory as string;
156
+ if (values.model) overrides.opencode.model = values.model as string;
157
+ if (values.agent) overrides.opencode.agent = values.agent as string;
158
+ }
159
+
160
+ // Codex
161
+ const hasCodex = !!(values['codex-model'] || values['codex-api-key']);
162
+ if (hasCodex) {
163
+ overrides.codex = {};
164
+ if (values['codex-model']) overrides.codex.model = values['codex-model'] as string;
165
+ if (values['codex-api-key']) overrides.codex.apiKey = values['codex-api-key'] as string;
166
+ }
167
+
168
+ // Claude
169
+ const hasClaude = !!(values['claude-model'] || values['claude-api-key']);
170
+ if (hasClaude) {
171
+ overrides.claude = {};
172
+ if (values['claude-model']) overrides.claude.model = values['claude-model'] as string;
173
+ if (values['claude-api-key']) overrides.claude.apiKey = values['claude-api-key'] as string;
174
+ }
175
+
176
+ // Logging
177
+ if (values['log-level']) {
178
+ overrides.logging = { level: values['log-level'] as 'debug' | 'info' | 'warn' | 'error' };
179
+ }
180
+
181
+ const configPath = (values.config) as string | undefined;
182
+ const registerUrl = values['register-url'] as string | undefined;
183
+ const registerKey = values['register-key'] as string | undefined;
184
+ const unregisterOnShutdown = (values['unregister-on-shutdown'] as boolean) ?? false;
185
+ const bridgeUrl = values['bridge-url'] as string | undefined;
186
+ const bridgeKey = values['bridge-key'] as string | undefined;
187
+ return { configPath, overrides, registerUrl, registerKey, unregisterOnShutdown, bridgeUrl, bridgeKey };
188
+ }
189
+
190
+ // ─── Gateway Registration ───────────────────────────────────────────────────
191
+
192
+ async function registerWithGateway(
193
+ registerUrl: string,
194
+ registerKey: string,
195
+ config: Required<AgentConfig>,
196
+ agentCardUrl: string,
197
+ ): Promise<{ assistantId: string; agentCardUrl: string } | null> {
198
+ const body = {
199
+ name: config.agentCard.name,
200
+ description: config.agentCard.description,
201
+ graphDefinition: {
202
+ type: 'a2a_remote',
203
+ key: `a2a-${config.provider}-${config.agentCard.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
204
+ name: config.agentCard.name,
205
+ description: config.agentCard.description,
206
+ prompt: config.agentCard.description,
207
+ agentCardUrl,
208
+ timeout: config.timeouts.prompt,
209
+ },
210
+ };
211
+
212
+ try {
213
+ const url = registerUrl.replace(/\/$/, '') + '/api/assistants';
214
+ log.info('Registering with gateway', { url, agentCardUrl });
215
+
216
+ const resp = await fetch(url, {
217
+ method: 'POST',
218
+ headers: {
219
+ 'Content-Type': 'application/json',
220
+ 'Authorization': `Bearer ${registerKey}`,
221
+ },
222
+ body: JSON.stringify(body),
223
+ });
224
+
225
+ if (!resp.ok) {
226
+ const text = await resp.text().catch(() => '');
227
+ log.error('Gateway registration failed', { status: resp.status, body: text });
228
+ return null;
229
+ }
230
+
231
+ const result = await resp.json() as { data?: { id: string } };
232
+ const assistantId = result.data?.id ?? body.graphDefinition.key;
233
+
234
+ log.info('Registered with gateway', { assistantId, agentCardUrl });
235
+ console.log(`Registered as agent: ${assistantId}`);
236
+ console.log(`Agent card: ${agentCardUrl}`);
237
+
238
+ return { assistantId, agentCardUrl };
239
+ } catch (err) {
240
+ log.error('Gateway registration error', { error: (err as Error).message });
241
+ return null;
242
+ }
243
+ }
244
+
245
+ async function unregisterFromGateway(
246
+ registerUrl: string,
247
+ registerKey: string,
248
+ assistantId: string,
249
+ ): Promise<void> {
250
+ try {
251
+ const url = `${registerUrl.replace(/\/$/, '')}/api/assistants/${assistantId}`;
252
+ log.info('Unregistering from gateway', { assistantId });
253
+
254
+ const resp = await fetch(url, {
255
+ method: 'DELETE',
256
+ headers: {
257
+ 'Authorization': `Bearer ${registerKey}`,
258
+ },
259
+ });
260
+
261
+ if (!resp.ok && resp.status !== 404) {
262
+ log.warn('Gateway unregistration failed', { status: resp.status });
263
+ } else {
264
+ log.info('Unregistered from gateway', { assistantId });
265
+ }
266
+ } catch (err) {
267
+ log.warn('Gateway unregistration error', { error: (err as Error).message });
268
+ }
269
+ }
270
+
271
+ // ─── Main ───────────────────────────────────────────────────────────────────
272
+
273
+ async function main(): Promise<void> {
274
+ const { configPath, overrides, registerUrl, registerKey, unregisterOnShutdown, bridgeUrl, bridgeKey } = parseCliArgs();
275
+
276
+ // Resolve config
277
+ const config = resolveConfig(configPath, overrides);
278
+
279
+ // Set configDir for relative path resolution
280
+ if (configPath) {
281
+ config.configDir = dirname(resolve(configPath));
282
+ }
283
+
284
+ // Apply log level
285
+ const levelStr = config.logging.level ?? 'info';
286
+ const levelMap: Record<string, LogLevel> = {
287
+ debug: LogLevel.DEBUG,
288
+ info: LogLevel.INFO,
289
+ warn: LogLevel.WARN,
290
+ error: LogLevel.ERROR,
291
+ };
292
+ logger.setLevel(levelMap[levelStr] ?? LogLevel.INFO);
293
+
294
+ if (!configPath) {
295
+ log.info('No --config provided — running with defaults + CLI args.');
296
+ }
297
+
298
+ log.info('Starting CLI A2A Gateway', {
299
+ provider: config.provider,
300
+ config: configPath ?? '(defaults)',
301
+ agent: config.agentCard.name,
302
+ port: config.server?.port,
303
+ });
304
+
305
+ // Register executors
306
+ const { registerExecutor } = await import('./executors/index.js');
307
+ const { createOpenCodeExecutor } = await import('./executors/opencode/executor.js');
308
+ const { createCodexExecutor } = await import('./executors/codex/executor.js');
309
+ const { createClaudeExecutor } = await import('./executors/claude/executor.js');
310
+ registerExecutor('opencode', createOpenCodeExecutor);
311
+ registerExecutor('codex', createCodexExecutor);
312
+ registerExecutor('claude', createClaudeExecutor);
313
+
314
+ // Start server
315
+ const server = await createA2AServer(config);
316
+
317
+ const listenPort = config.server?.port ?? 3000;
318
+ const agentId = `a2a-${config.provider}-${listenPort}`;
319
+
320
+ // Connect bridge to gateway (NAT traversal)
321
+ let bridgeClient: { start(): Promise<void>; stop(): void } | null = null;
322
+ if (bridgeUrl && bridgeKey) {
323
+ const { BridgeClient } = await import('./bridge.js');
324
+ const agentCardUrl = `${config.server?.advertiseProtocol ?? 'http'}://${config.server?.advertiseHost ?? 'localhost'}:${listenPort}/.well-known/agent-card.json`;
325
+
326
+ bridgeClient = new BridgeClient({
327
+ bridgeUrl: bridgeUrl.replace(/^http/, 'ws'),
328
+ apiKey: bridgeKey,
329
+ agentId,
330
+ agentCard: {
331
+ name: config.agentCard.name,
332
+ description: config.agentCard.description,
333
+ url: agentCardUrl,
334
+ },
335
+ localBaseUrl: `http://localhost:${listenPort}`,
336
+ });
337
+
338
+ bridgeClient.start().then(() => {
339
+ log.info('Bridge connected — agent reachable via gateway proxy');
340
+ console.log(`Bridge: ws://gateway → localhost:${listenPort}`);
341
+ }).catch((err: Error) => {
342
+ log.error('Bridge connection failed', { error: err.message });
343
+ });
344
+ }
345
+
346
+ // Register with gateway (HTTP-based self-registration)
347
+ let registeredAssistantId: string | null = null;
348
+ if (registerUrl && registerKey) {
349
+ // When bridge is active, the agent card URL must point to the gateway proxy
350
+ // so the orchestrator reaches the agent through the WebSocket bridge, not directly.
351
+ const agentCardUrl = bridgeUrl
352
+ ? `${registerUrl.replace(/\/$/, '')}/api/a2a/bridge/proxy/${agentId}/.well-known/agent-card.json`
353
+ : `${config.server?.advertiseProtocol ?? 'http'}://${config.server?.advertiseHost ?? 'localhost'}:${listenPort}/.well-known/agent-card.json`;
354
+
355
+ const reg = await registerWithGateway(
356
+ registerUrl,
357
+ registerKey,
358
+ config,
359
+ agentCardUrl,
360
+ );
361
+ if (reg) {
362
+ registeredAssistantId = reg.assistantId;
363
+ }
364
+ }
365
+
366
+ // Graceful shutdown
367
+ const shutdown = async (signal: string) => {
368
+ log.info(`${signal} received, shutting down...`);
369
+ bridgeClient?.stop();
370
+ if (registeredAssistantId && registerUrl && registerKey && unregisterOnShutdown) {
371
+ await unregisterFromGateway(registerUrl, registerKey, registeredAssistantId);
372
+ }
373
+ await server.shutdown();
374
+ process.exit(0);
375
+ };
376
+
377
+ process.on('SIGINT', () => shutdown('SIGINT'));
378
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
379
+ }
380
+
381
+ main().catch((err) => {
382
+ log.error('Fatal error', { error: (err as Error).message, stack: (err as Error).stack });
383
+ process.exit(1);
384
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Default configuration values.
3
+ *
4
+ * Every field has a sensible default so the agent runs without a config file.
5
+ */
6
+
7
+ import type { AgentConfig } from './types.js';
8
+
9
+ function withDefaults<T extends Record<string, unknown>>(
10
+ partial: Partial<T>,
11
+ defaults: Required<T>,
12
+ ): Required<T> {
13
+ const result = { ...defaults } as Record<string, unknown>;
14
+ for (const key of Object.keys(partial) as Array<keyof T>) {
15
+ const val = partial[key] as unknown;
16
+ if (val !== undefined && val !== null) {
17
+ if (
18
+ typeof val === 'object' &&
19
+ !Array.isArray(val) &&
20
+ typeof defaults[key] === 'object' &&
21
+ !Array.isArray(defaults[key])
22
+ ) {
23
+ result[key as string] = {
24
+ ...(defaults[key] as Record<string, unknown>),
25
+ ...(val as Record<string, unknown>),
26
+ };
27
+ } else {
28
+ result[key as string] = val;
29
+ }
30
+ }
31
+ }
32
+ return result as unknown as Required<T>;
33
+ }
34
+
35
+ type ResolvedConfig = Omit<Required<AgentConfig>, 'opencode' | 'codex' | 'claude' | 'configDir'> & {
36
+ opencode: Required<Required<AgentConfig>['opencode']>;
37
+ codex: Required<Required<AgentConfig>['codex']>;
38
+ claude: Required<Required<AgentConfig>['claude']>;
39
+ configDir: string | undefined;
40
+ };
41
+
42
+ const DEFAULTS: Readonly<ResolvedConfig> = Object.freeze({
43
+ provider: 'opencode' as const,
44
+ agentCard: {
45
+ name: 'CLI A2A Agent',
46
+ description: 'A multi-provider A2A agent driven by local CLI coding tools.',
47
+ protocolVersion: '0.3.0',
48
+ version: '0.1.0',
49
+ skills: [],
50
+ defaultInputModes: ['text'],
51
+ defaultOutputModes: ['text'],
52
+ streaming: true,
53
+ pushNotifications: false,
54
+ },
55
+ server: {
56
+ port: 3000,
57
+ hostname: '0.0.0.0',
58
+ advertiseHost: 'localhost',
59
+ advertiseProtocol: 'http',
60
+ },
61
+ session: {
62
+ titlePrefix: 'A2A Session',
63
+ reuseByContext: true,
64
+ ttl: 3_600_000,
65
+ cleanupInterval: 300_000,
66
+ },
67
+ features: {
68
+ autoApprovePermissions: true,
69
+ autoAnswerQuestions: true,
70
+ streamArtifactChunks: false,
71
+ enablePollingFallback: true,
72
+ },
73
+ timeouts: {
74
+ prompt: 600_000,
75
+ pollingInterval: 2_000,
76
+ healthCheck: 30_000,
77
+ },
78
+ logging: {
79
+ level: 'info',
80
+ },
81
+ opencode: {
82
+ baseUrl: 'http://localhost:4096',
83
+ projectDirectory: '',
84
+ model: '',
85
+ agent: '',
86
+ systemPrompt: '',
87
+ systemPromptMode: 'append',
88
+ contextFile: 'context.md',
89
+ contextPrompt: '',
90
+ },
91
+ codex: {
92
+ cliPath: 'codex',
93
+ workdir: process.cwd(),
94
+ model: '',
95
+ apiKey: '',
96
+ },
97
+ claude: {
98
+ cliPath: 'claude',
99
+ workdir: process.cwd(),
100
+ model: '',
101
+ apiKey: '',
102
+ },
103
+ configDir: undefined,
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ } as any);
106
+
107
+ export function resolveDefaults(overrides: Partial<AgentConfig>): Required<AgentConfig> {
108
+ return withDefaults(overrides, DEFAULTS) as unknown as Required<AgentConfig>;
109
+ }
@@ -0,0 +1,16 @@
1
+ export { resolveConfig, loadConfigFile } from './loader.js';
2
+ export { resolveDefaults } from './defaults.js';
3
+ export type {
4
+ AgentConfig,
5
+ ProviderType,
6
+ AgentCardConfig,
7
+ SkillConfig,
8
+ ServerConfig,
9
+ SessionConfig,
10
+ FeatureFlags,
11
+ TimeoutConfig,
12
+ LoggingConfig,
13
+ OpenCodeConfig,
14
+ CodexConfig,
15
+ ClaudeCodeConfig,
16
+ } from './types.js';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Config loader — reads JSON config files and resolves the final config
3
+ * by merging layers: defaults ← config file ← env overrides ← CLI args.
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { resolveDefaults } from './defaults.js';
9
+ import type { AgentConfig } from './types.js';
10
+ import { logger } from '../logger.js';
11
+
12
+ const log = logger.child('config');
13
+
14
+ /**
15
+ * Read and parse a JSON config file.
16
+ */
17
+ export function loadConfigFile(filePath: string): Partial<AgentConfig> {
18
+ const absPath = resolve(filePath);
19
+ if (!existsSync(absPath)) {
20
+ throw new Error(`Config file not found: ${absPath}`);
21
+ }
22
+ try {
23
+ const raw = readFileSync(absPath, 'utf-8');
24
+ return JSON.parse(raw) as Partial<AgentConfig>;
25
+ } catch (err) {
26
+ const msg = err instanceof SyntaxError
27
+ ? `Invalid JSON in config file: ${err.message}`
28
+ : `Failed to read config file: ${(err as Error).message}`;
29
+ throw new Error(msg);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Load env overrides for provider configs.
35
+ */
36
+ function loadEnvOverrides(): Partial<AgentConfig> {
37
+ const overrides: Partial<AgentConfig> = {
38
+ opencode: {},
39
+ codex: {},
40
+ claude: {},
41
+ };
42
+
43
+ // OpenCode
44
+ if (process.env.OPENCODE_URL) overrides.opencode!.baseUrl = process.env.OPENCODE_URL;
45
+ if (process.env.OPENCODE_DIRECTORY) overrides.opencode!.projectDirectory = process.env.OPENCODE_DIRECTORY;
46
+ if (process.env.OPENCODE_MODEL) overrides.opencode!.model = process.env.OPENCODE_MODEL;
47
+ if (process.env.OPENCODE_AGENT) overrides.opencode!.agent = process.env.OPENCODE_AGENT;
48
+ if (process.env.OPENCODE_SYSTEM_PROMPT) overrides.opencode!.systemPrompt = process.env.OPENCODE_SYSTEM_PROMPT;
49
+
50
+ // Codex
51
+ if (process.env.OPENAI_API_KEY) overrides.codex!.apiKey = process.env.OPENAI_API_KEY;
52
+ if (process.env.CODEX_MODEL) overrides.codex!.model = process.env.CODEX_MODEL;
53
+
54
+ // Claude Code
55
+ if (process.env.ANTHROPIC_API_KEY) overrides.claude!.apiKey = process.env.ANTHROPIC_API_KEY;
56
+ if (process.env.CLAUDE_MODEL) overrides.claude!.model = process.env.CLAUDE_MODEL;
57
+
58
+ return overrides;
59
+ }
60
+
61
+ /**
62
+ * Resolve full config from defaults + file + env.
63
+ */
64
+ export function resolveConfig(
65
+ configPath?: string,
66
+ cliOverrides?: Partial<AgentConfig>,
67
+ ): Required<AgentConfig> {
68
+ // Layer 1: File
69
+ let fileConfig: Partial<AgentConfig> = {};
70
+ if (configPath) {
71
+ fileConfig = loadConfigFile(configPath);
72
+ log.info('Loaded config file', { path: configPath });
73
+ }
74
+
75
+ // Layer 2: Env
76
+ const envOverrides = loadEnvOverrides();
77
+
78
+ // Layer 3: CLI
79
+ const cliConfig = cliOverrides ?? {};
80
+
81
+ // Deep merge: defaults ← file ← env ← CLI
82
+ // Start with a proper deep copy of merged defaults+file to avoid mutating the frozen defaults
83
+ const cloned = structuredClone(resolveDefaults(fileConfig)) as Required<AgentConfig>;
84
+ const base = cloned;
85
+
86
+ // Then merge env overrides
87
+ mergeOverrides(base, envOverrides);
88
+
89
+ // Then merge CLI overrides
90
+ mergeOverrides(base, cliConfig);
91
+
92
+ // Populate configDir
93
+ if (configPath) {
94
+ base.configDir = dirname(resolve(configPath));
95
+ }
96
+
97
+ return base;
98
+ }
99
+
100
+ function mergeOverrides(target: Record<string, unknown>, source: Record<string, unknown>): void {
101
+ for (const key of Object.keys(source)) {
102
+ const srcVal = source[key];
103
+ const tgtVal = target[key];
104
+ if (
105
+ srcVal !== undefined &&
106
+ srcVal !== null &&
107
+ typeof srcVal === 'object' &&
108
+ !Array.isArray(srcVal) &&
109
+ tgtVal !== undefined &&
110
+ typeof tgtVal === 'object' &&
111
+ !Array.isArray(tgtVal)
112
+ ) {
113
+ // Do not merge empty override objects
114
+ if (Object.keys(srcVal as Record<string, unknown>).length > 0) {
115
+ mergeOverrides(tgtVal as Record<string, unknown>, srcVal as Record<string, unknown>);
116
+ }
117
+ } else if (srcVal !== undefined && srcVal !== null) {
118
+ target[key] = srcVal;
119
+ }
120
+ }
121
+ }