@link-assistant/agent 0.0.8 → 0.0.9

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/EXAMPLES.md CHANGED
@@ -279,7 +279,28 @@ echo '{"message":"fetch url","tools":[{"name":"webfetch","params":{"url":"https:
279
279
 
280
280
  ## Output Format
281
281
 
282
- ### JSON Event Streaming (Pretty-Printed)
282
+ ### JSON Standards
283
+
284
+ @link-assistant/agent supports two JSON output format standards via the `--json-standard` option:
285
+
286
+ #### OpenCode Standard (default)
287
+
288
+ ```bash
289
+ # Default - same as --json-standard opencode
290
+ echo "hi" | agent
291
+
292
+ # Explicit opencode standard
293
+ echo "hi" | agent --json-standard opencode
294
+ ```
295
+
296
+ #### Claude Standard (experimental)
297
+
298
+ ```bash
299
+ # Claude CLI compatible format (NDJSON)
300
+ echo "hi" | agent --json-standard claude
301
+ ```
302
+
303
+ ### JSON Event Streaming (Pretty-Printed) - OpenCode Standard
283
304
 
284
305
  @link-assistant/agent outputs JSON events in pretty-printed streaming format for easy readability, 100% compatible with OpenCode's event structure:
285
306
 
@@ -327,6 +348,28 @@ This format is designed for:
327
348
  - **Compatibility**: 100% compatible with OpenCode's event structure
328
349
  - **Automation**: Can be parsed using standard JSON tools (see filtering examples below)
329
350
 
351
+ ### Claude Stream-JSON Output (NDJSON)
352
+
353
+ When using `--json-standard claude`, output is in NDJSON (Newline-Delimited JSON) format, compatible with Claude CLI:
354
+
355
+ ```bash
356
+ echo "hi" | agent --json-standard claude
357
+ ```
358
+
359
+ Output (compact NDJSON):
360
+ ```json
361
+ {"type":"init","timestamp":"2025-01-01T00:00:00.000Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF"}
362
+ {"type":"message","timestamp":"2025-01-01T00:00:01.000Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF","role":"assistant","content":[{"type":"text","text":"Hi! How can I help with your coding tasks today?"}]}
363
+ {"type":"result","timestamp":"2025-01-01T00:00:01.100Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF","status":"success","duration_ms":1100}
364
+ ```
365
+
366
+ Key differences from OpenCode format:
367
+ - **Compact**: One JSON per line (no pretty-printing)
368
+ - **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
369
+ - **Timestamps**: ISO 8601 strings instead of Unix milliseconds
370
+ - **Session ID**: `session_id` (snake_case) instead of `sessionID` (camelCase)
371
+ - **Content**: Message content in array format with `{type, text}` objects
372
+
330
373
  ### Filtering Output
331
374
 
332
375
  Extract specific event types using `jq`:
package/README.md CHANGED
@@ -156,6 +156,8 @@ agent [options]
156
156
  Options:
157
157
  --model Model to use in format providerID/modelID
158
158
  Default: opencode/grok-code
159
+ --json-standard JSON output format standard
160
+ Choices: "opencode" (default), "claude" (experimental)
159
161
  --system-message Full override of the system message
160
162
  --system-message-file Full override of the system message from file
161
163
  --append-system-message Append to the default system message
@@ -164,6 +166,36 @@ Options:
164
166
  --version Show version number
165
167
  ```
166
168
 
169
+ ### JSON Output Standards
170
+
171
+ The agent supports two JSON output format standards via the `--json-standard` option:
172
+
173
+ #### OpenCode Standard (default)
174
+
175
+ The OpenCode format is the default JSON output format, compatible with `opencode run --format json`:
176
+
177
+ ```bash
178
+ echo "hi" | agent --json-standard opencode
179
+ ```
180
+
181
+ - **Format**: Pretty-printed JSON (human-readable with indentation)
182
+ - **Event Types**: `step_start`, `step_finish`, `text`, `tool_use`, `error`
183
+ - **Timestamps**: Unix milliseconds (number)
184
+ - **Session ID**: `sessionID` (camelCase)
185
+
186
+ #### Claude Standard (experimental)
187
+
188
+ The Claude format provides compatibility with Anthropic's Claude CLI `--output-format stream-json`:
189
+
190
+ ```bash
191
+ echo "hi" | agent --json-standard claude
192
+ ```
193
+
194
+ - **Format**: NDJSON (Newline-Delimited JSON - compact, one JSON per line)
195
+ - **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
196
+ - **Timestamps**: ISO 8601 strings
197
+ - **Session ID**: `session_id` (snake_case)
198
+
167
199
  ### Input Formats
168
200
 
169
201
  **Plain Text (auto-converted):**
@@ -222,11 +254,14 @@ See [EXAMPLES.md](EXAMPLES.md) for detailed usage examples of each tool with bot
222
254
  bun test
223
255
 
224
256
  # Run specific test file
257
+ bun test tests/mcp.test.js
225
258
  bun test tests/websearch.tools.test.js
226
259
  bun test tests/batch.tools.test.js
227
260
  bun test tests/plaintext.input.test.js
228
261
  ```
229
262
 
263
+ For detailed testing information including how to run tests manually and trigger CI tests, see [TESTING.md](TESTING.md).
264
+
230
265
  ## Maintenance
231
266
 
232
267
  ### Development
@@ -255,6 +290,7 @@ Bun automatically discovers and runs all `*.test.js` files in the project.
255
290
  - ✅ 13 tool implementation tests
256
291
  - ✅ Plain text input support test
257
292
  - ✅ OpenCode compatibility tests for websearch/codesearch
293
+ - ✅ JSON standard unit tests (opencode and claude formats)
258
294
  - ✅ All tests pass with 100% OpenCode JSON format compatibility
259
295
 
260
296
  ### Publishing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  "@agentclientprotocol/sdk": "^0.5.1",
44
44
  "@ai-sdk/mcp": "^0.0.8",
45
45
  "@ai-sdk/xai": "^2.0.33",
46
+ "@clack/prompts": "^0.11.0",
46
47
  "@hono/standard-validator": "^0.2.0",
47
48
  "@hono/zod-validator": "^0.7.5",
48
49
  "@modelcontextprotocol/sdk": "^1.22.0",
@@ -1,19 +1,94 @@
1
+ import type { Argv } from "yargs"
1
2
  import { cmd } from "./cmd"
2
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js"
3
4
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
4
5
  import * as prompts from "@clack/prompts"
5
6
  import { UI } from "../ui"
7
+ import { Global } from "../../global"
8
+ import { Config } from "../../config/config"
9
+ import path from "path"
10
+ import fs from "fs/promises"
6
11
 
7
12
  export const McpCommand = cmd({
8
13
  command: "mcp",
9
- builder: (yargs) => yargs.command(McpAddCommand).demandCommand(),
14
+ builder: (yargs) => yargs.command(McpAddCommand).command(McpListCommand).demandCommand(),
10
15
  async handler() {},
11
16
  })
12
17
 
18
+ async function loadGlobalConfig(): Promise<Config.Info> {
19
+ const configPath = path.join(Global.Path.config, "opencode.json")
20
+ try {
21
+ const content = await Bun.file(configPath).text()
22
+ return JSON.parse(content)
23
+ } catch {
24
+ return {
25
+ $schema: "https://opencode.ai/config.json",
26
+ }
27
+ }
28
+ }
29
+
30
+ async function saveGlobalConfig(config: Config.Info): Promise<void> {
31
+ const configPath = path.join(Global.Path.config, "opencode.json")
32
+ await fs.mkdir(Global.Path.config, { recursive: true })
33
+ await Bun.write(configPath, JSON.stringify(config, null, 2))
34
+ }
35
+
13
36
  export const McpAddCommand = cmd({
14
- command: "add",
37
+ command: "add [name] [command..]",
15
38
  describe: "add an MCP server",
16
- async handler() {
39
+ builder: (yargs: Argv) => {
40
+ return yargs
41
+ .positional("name", {
42
+ describe: "name of the MCP server",
43
+ type: "string",
44
+ })
45
+ .positional("command", {
46
+ describe: "command and arguments to run the MCP server (e.g., npx @playwright/mcp@latest)",
47
+ type: "string",
48
+ array: true,
49
+ })
50
+ .option("url", {
51
+ describe: "URL for remote MCP server",
52
+ type: "string",
53
+ })
54
+ .option("enabled", {
55
+ describe: "enable the MCP server",
56
+ type: "boolean",
57
+ default: true,
58
+ })
59
+ },
60
+ async handler(args) {
61
+ // If name and command are provided as CLI arguments, use non-interactive mode
62
+ if (args.name && ((args.command && args.command.length > 0) || args.url)) {
63
+ // Non-interactive mode: CLI arguments provided
64
+ const config = await loadGlobalConfig()
65
+ config.mcp = config.mcp || {}
66
+
67
+ if (args.url) {
68
+ // Remote MCP server
69
+ config.mcp[args.name] = {
70
+ type: "remote",
71
+ url: args.url,
72
+ enabled: args.enabled,
73
+ }
74
+ UI.success(`Remote MCP server "${args.name}" added with URL: ${args.url}`)
75
+ } else if (args.command && args.command.length > 0) {
76
+ // Local MCP server
77
+ config.mcp[args.name] = {
78
+ type: "local",
79
+ command: args.command,
80
+ enabled: args.enabled,
81
+ }
82
+ UI.success(`Local MCP server "${args.name}" added with command: ${args.command.join(" ")}`)
83
+ }
84
+
85
+ await saveGlobalConfig(config)
86
+ const configPath = path.join(Global.Path.config, "opencode.json")
87
+ UI.info(`Configuration saved to: ${configPath}`)
88
+ return
89
+ }
90
+
91
+ // Interactive mode: prompt for input
17
92
  UI.empty()
18
93
  prompts.intro("Add MCP server")
19
94
 
@@ -40,17 +115,27 @@ export const McpAddCommand = cmd({
40
115
  })
41
116
  if (prompts.isCancel(type)) throw new UI.CancelledError()
42
117
 
118
+ const config = await loadGlobalConfig()
119
+ config.mcp = config.mcp || {}
120
+
43
121
  if (type === "local") {
44
122
  const command = await prompts.text({
45
123
  message: "Enter command to run",
46
- placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
124
+ placeholder: "e.g., npx @playwright/mcp@latest",
47
125
  validate: (x) => (x && x.length > 0 ? undefined : "Required"),
48
126
  })
49
127
  if (prompts.isCancel(command)) throw new UI.CancelledError()
50
128
 
129
+ // Parse command into array
130
+ const commandParts = command.split(/\s+/)
131
+ config.mcp[name] = {
132
+ type: "local",
133
+ command: commandParts,
134
+ enabled: true,
135
+ }
136
+
137
+ await saveGlobalConfig(config)
51
138
  prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
52
- prompts.outro("MCP server added successfully")
53
- return
54
139
  }
55
140
 
56
141
  if (type === "remote") {
@@ -66,15 +151,62 @@ export const McpAddCommand = cmd({
66
151
  })
67
152
  if (prompts.isCancel(url)) throw new UI.CancelledError()
68
153
 
69
- const client = new Client({
70
- name: "opencode",
71
- version: "1.0.0",
72
- })
73
- const transport = new StreamableHTTPClientTransport(new URL(url))
74
- await client.connect(transport)
154
+ // Test connection
155
+ try {
156
+ const client = new Client({
157
+ name: "opencode",
158
+ version: "1.0.0",
159
+ })
160
+ const transport = new StreamableHTTPClientTransport(new URL(url))
161
+ await client.connect(transport)
162
+ await client.close()
163
+ } catch (error) {
164
+ prompts.log.warn(`Could not verify connection to ${url}, but saving configuration anyway`)
165
+ }
166
+
167
+ config.mcp[name] = {
168
+ type: "remote",
169
+ url: url,
170
+ enabled: true,
171
+ }
172
+
173
+ await saveGlobalConfig(config)
75
174
  prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
76
175
  }
77
176
 
177
+ const configPath = path.join(Global.Path.config, "opencode.json")
178
+ prompts.log.info(`Configuration saved to: ${configPath}`)
78
179
  prompts.outro("MCP server added successfully")
79
180
  },
80
181
  })
182
+
183
+ export const McpListCommand = cmd({
184
+ command: "list",
185
+ describe: "list configured MCP servers",
186
+ async handler() {
187
+ const config = await loadGlobalConfig()
188
+ const mcpServers = config.mcp || {}
189
+
190
+ if (Object.keys(mcpServers).length === 0) {
191
+ UI.info("No MCP servers configured")
192
+ return
193
+ }
194
+
195
+ UI.println(UI.Style.TEXT_BOLD + "Configured MCP servers:" + UI.Style.TEXT_NORMAL)
196
+ UI.empty()
197
+
198
+ for (const [name, server] of Object.entries(mcpServers)) {
199
+ const enabledStatus = server.enabled !== false ? UI.Style.TEXT_SUCCESS_BOLD + "[enabled]" : UI.Style.TEXT_DIM + "[disabled]"
200
+ UI.println(UI.Style.TEXT_INFO_BOLD + ` ${name}` + UI.Style.TEXT_NORMAL + ` ${enabledStatus}` + UI.Style.TEXT_NORMAL)
201
+
202
+ if (server.type === "local") {
203
+ UI.println(UI.Style.TEXT_DIM + ` Type: local`)
204
+ UI.println(UI.Style.TEXT_DIM + ` Command: ${server.command.join(" ")}`)
205
+ } else if (server.type === "remote") {
206
+ UI.println(UI.Style.TEXT_DIM + ` Type: remote`)
207
+ UI.println(UI.Style.TEXT_DIM + ` URL: ${server.url}`)
208
+ }
209
+ UI.empty()
210
+ }
211
+ },
212
+ })
package/src/cli/ui.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { NamedError } from "../util/error"
2
+ import z from "zod"
3
+
4
+ export namespace UI {
5
+ // ANSI color codes for terminal output
6
+ export const Style = {
7
+ TEXT_NORMAL: "\x1b[0m",
8
+ TEXT_BOLD: "\x1b[1m",
9
+ TEXT_DIM: "\x1b[2m",
10
+ TEXT_DANGER_BOLD: "\x1b[1;31m",
11
+ TEXT_SUCCESS_BOLD: "\x1b[1;32m",
12
+ TEXT_WARNING_BOLD: "\x1b[1;33m",
13
+ TEXT_INFO_BOLD: "\x1b[1;34m",
14
+ TEXT_HIGHLIGHT_BOLD: "\x1b[1;35m",
15
+ TEXT_DIM_BOLD: "\x1b[1;90m",
16
+ } as const
17
+
18
+ // Error for cancelled operations (e.g., Ctrl+C in prompts)
19
+ export const CancelledError = NamedError.create(
20
+ "CancelledError",
21
+ z.object({})
22
+ )
23
+
24
+ // Print an empty line
25
+ export function empty() {
26
+ process.stderr.write("\n")
27
+ }
28
+
29
+ // Print a line with optional formatting
30
+ export function println(...args: string[]) {
31
+ process.stderr.write(args.join("") + Style.TEXT_NORMAL + "\n")
32
+ }
33
+
34
+ // Print an error message
35
+ export function error(message: string) {
36
+ process.stderr.write(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message + "\n")
37
+ }
38
+
39
+ // Print a success message
40
+ export function success(message: string) {
41
+ process.stderr.write(Style.TEXT_SUCCESS_BOLD + "Success: " + Style.TEXT_NORMAL + message + "\n")
42
+ }
43
+
44
+ // Print an info message
45
+ export function info(message: string) {
46
+ process.stderr.write(Style.TEXT_INFO_BOLD + "Info: " + Style.TEXT_NORMAL + message + "\n")
47
+ }
48
+
49
+ // Basic markdown rendering for terminal
50
+ export function markdown(text: string): string {
51
+ // Simple markdown to ANSI conversion
52
+ let result = text
53
+
54
+ // Bold text: **text** or __text__
55
+ result = result.replace(/\*\*(.+?)\*\*/g, Style.TEXT_BOLD + "$1" + Style.TEXT_NORMAL)
56
+ result = result.replace(/__(.+?)__/g, Style.TEXT_BOLD + "$1" + Style.TEXT_NORMAL)
57
+
58
+ // Code blocks: `code`
59
+ result = result.replace(/`([^`]+)`/g, Style.TEXT_DIM + "$1" + Style.TEXT_NORMAL)
60
+
61
+ return result
62
+ }
63
+ }
package/src/index.js CHANGED
@@ -9,6 +9,34 @@ import { SessionPrompt } from './session/prompt.ts'
9
9
  import { EOL } from 'os'
10
10
  import yargs from 'yargs'
11
11
  import { hideBin } from 'yargs/helpers'
12
+ import { createEventHandler, isValidJsonStandard } from './json-standard/index.ts'
13
+ import { McpCommand } from './cli/cmd/mcp.ts'
14
+
15
+ // Track if any errors occurred during execution
16
+ let hasError = false
17
+
18
+ // Install global error handlers to ensure non-zero exit codes
19
+ process.on('uncaughtException', (error) => {
20
+ hasError = true
21
+ console.error(JSON.stringify({
22
+ type: 'error',
23
+ errorType: error.name || 'UncaughtException',
24
+ message: error.message,
25
+ stack: error.stack
26
+ }, null, 2))
27
+ process.exit(1)
28
+ })
29
+
30
+ process.on('unhandledRejection', (reason, promise) => {
31
+ hasError = true
32
+ console.error(JSON.stringify({
33
+ type: 'error',
34
+ errorType: 'UnhandledRejection',
35
+ message: reason?.message || String(reason),
36
+ stack: reason?.stack
37
+ }, null, 2))
38
+ process.exit(1)
39
+ })
12
40
 
13
41
  async function readStdin() {
14
42
  return new Promise((resolve, reject) => {
@@ -35,15 +63,341 @@ async function readStdin() {
35
63
  })
36
64
  }
37
65
 
66
+ async function runAgentMode(argv) {
67
+ // Parse model argument
68
+ const modelParts = argv.model.split('/')
69
+ const providerID = modelParts[0] || 'opencode'
70
+ const modelID = modelParts[1] || 'grok-code'
71
+
72
+ // Validate and get JSON standard
73
+ const jsonStandard = argv['json-standard']
74
+ if (!isValidJsonStandard(jsonStandard)) {
75
+ console.error(`Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`)
76
+ process.exit(1)
77
+ }
78
+
79
+ // Read system message files
80
+ let systemMessage = argv['system-message']
81
+ let appendSystemMessage = argv['append-system-message']
82
+
83
+ if (argv['system-message-file']) {
84
+ const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file'])
85
+ const file = Bun.file(resolvedPath)
86
+ if (!(await file.exists())) {
87
+ console.error(`System message file not found: ${argv['system-message-file']}`)
88
+ process.exit(1)
89
+ }
90
+ systemMessage = await file.text()
91
+ }
92
+
93
+ if (argv['append-system-message-file']) {
94
+ const resolvedPath = require('path').resolve(process.cwd(), argv['append-system-message-file'])
95
+ const file = Bun.file(resolvedPath)
96
+ if (!(await file.exists())) {
97
+ console.error(`Append system message file not found: ${argv['append-system-message-file']}`)
98
+ process.exit(1)
99
+ }
100
+ appendSystemMessage = await file.text()
101
+ }
102
+
103
+ // Initialize logging to redirect to log file instead of stderr
104
+ // This prevents log messages from mixing with JSON output
105
+ await Log.init({
106
+ print: false, // Don't print to stderr
107
+ level: 'INFO'
108
+ })
109
+
110
+ // Read input from stdin
111
+ const input = await readStdin()
112
+ const trimmedInput = input.trim()
113
+
114
+ // Try to parse as JSON, if it fails treat it as plain text message
115
+ let request
116
+ try {
117
+ request = JSON.parse(trimmedInput)
118
+ } catch (e) {
119
+ // Not JSON, treat as plain text message
120
+ request = {
121
+ message: trimmedInput
122
+ }
123
+ }
124
+
125
+ // Wrap in Instance.provide for OpenCode infrastructure
126
+ await Instance.provide({
127
+ directory: process.cwd(),
128
+ fn: async () => {
129
+ if (argv.server) {
130
+ // SERVER MODE: Start server and communicate via HTTP
131
+ await runServerMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard)
132
+ } else {
133
+ // DIRECT MODE: Run everything in single process
134
+ await runDirectMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard)
135
+ }
136
+ }
137
+ })
138
+
139
+ // Explicitly exit to ensure process terminates
140
+ process.exit(hasError ? 1 : 0)
141
+ }
142
+
143
+ async function runServerMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard) {
144
+ // Start server like OpenCode does
145
+ const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
146
+ let unsub = null
147
+
148
+ try {
149
+ // Create a session
150
+ const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({})
154
+ })
155
+ const session = await createRes.json()
156
+ const sessionID = session.id
157
+
158
+ if (!sessionID) {
159
+ throw new Error("Failed to create session")
160
+ }
161
+
162
+ // Create event handler for the selected JSON standard
163
+ const eventHandler = createEventHandler(jsonStandard, sessionID)
164
+
165
+ // Subscribe to all bus events and output in selected format
166
+ const eventPromise = new Promise((resolve) => {
167
+ unsub = Bus.subscribeAll((event) => {
168
+ // Output events in selected JSON format
169
+ if (event.type === 'message.part.updated') {
170
+ const part = event.properties.part
171
+ if (part.sessionID !== sessionID) return
172
+
173
+ // Output different event types
174
+ if (part.type === 'step-start') {
175
+ eventHandler.output({
176
+ type: 'step_start',
177
+ timestamp: Date.now(),
178
+ sessionID,
179
+ part
180
+ })
181
+ }
182
+
183
+ if (part.type === 'step-finish') {
184
+ eventHandler.output({
185
+ type: 'step_finish',
186
+ timestamp: Date.now(),
187
+ sessionID,
188
+ part
189
+ })
190
+ }
191
+
192
+ if (part.type === 'text' && part.time?.end) {
193
+ eventHandler.output({
194
+ type: 'text',
195
+ timestamp: Date.now(),
196
+ sessionID,
197
+ part
198
+ })
199
+ }
200
+
201
+ if (part.type === 'tool' && part.state.status === 'completed') {
202
+ eventHandler.output({
203
+ type: 'tool_use',
204
+ timestamp: Date.now(),
205
+ sessionID,
206
+ part
207
+ })
208
+ }
209
+ }
210
+
211
+ // Handle session idle to know when to stop
212
+ if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
213
+ resolve()
214
+ }
215
+
216
+ // Handle errors
217
+ if (event.type === 'session.error') {
218
+ const props = event.properties
219
+ if (props.sessionID !== sessionID || !props.error) return
220
+ hasError = true
221
+ eventHandler.output({
222
+ type: 'error',
223
+ timestamp: Date.now(),
224
+ sessionID,
225
+ error: props.error
226
+ })
227
+ }
228
+ })
229
+ })
230
+
231
+ // Send message to session with specified model (default: opencode/grok-code)
232
+ const message = request.message || "hi"
233
+ const parts = [{ type: "text", text: message }]
234
+
235
+ // Start the prompt (don't wait for response, events come via Bus)
236
+ fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({
240
+ parts,
241
+ model: {
242
+ providerID,
243
+ modelID
244
+ },
245
+ system: systemMessage,
246
+ appendSystem: appendSystemMessage
247
+ })
248
+ }).catch((error) => {
249
+ hasError = true
250
+ eventHandler.output({
251
+ type: 'error',
252
+ timestamp: Date.now(),
253
+ sessionID,
254
+ error: error instanceof Error ? error.message : String(error)
255
+ })
256
+ })
257
+
258
+ // Wait for session to become idle
259
+ await eventPromise
260
+ } finally {
261
+ // Always clean up resources
262
+ if (unsub) unsub()
263
+ server.stop()
264
+ await Instance.dispose()
265
+ }
266
+ }
267
+
268
+ async function runDirectMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard) {
269
+ // DIRECT MODE: Run in single process without server
270
+ let unsub = null
271
+
272
+ try {
273
+ // Create a session directly
274
+ const session = await Session.createNext({
275
+ directory: process.cwd()
276
+ })
277
+ const sessionID = session.id
278
+
279
+ // Create event handler for the selected JSON standard
280
+ const eventHandler = createEventHandler(jsonStandard, sessionID)
281
+
282
+ // Subscribe to all bus events and output in selected format
283
+ const eventPromise = new Promise((resolve) => {
284
+ unsub = Bus.subscribeAll((event) => {
285
+ // Output events in selected JSON format
286
+ if (event.type === 'message.part.updated') {
287
+ const part = event.properties.part
288
+ if (part.sessionID !== sessionID) return
289
+
290
+ // Output different event types
291
+ if (part.type === 'step-start') {
292
+ eventHandler.output({
293
+ type: 'step_start',
294
+ timestamp: Date.now(),
295
+ sessionID,
296
+ part
297
+ })
298
+ }
299
+
300
+ if (part.type === 'step-finish') {
301
+ eventHandler.output({
302
+ type: 'step_finish',
303
+ timestamp: Date.now(),
304
+ sessionID,
305
+ part
306
+ })
307
+ }
308
+
309
+ if (part.type === 'text' && part.time?.end) {
310
+ eventHandler.output({
311
+ type: 'text',
312
+ timestamp: Date.now(),
313
+ sessionID,
314
+ part
315
+ })
316
+ }
317
+
318
+ if (part.type === 'tool' && part.state.status === 'completed') {
319
+ eventHandler.output({
320
+ type: 'tool_use',
321
+ timestamp: Date.now(),
322
+ sessionID,
323
+ part
324
+ })
325
+ }
326
+ }
327
+
328
+ // Handle session idle to know when to stop
329
+ if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
330
+ resolve()
331
+ }
332
+
333
+ // Handle errors
334
+ if (event.type === 'session.error') {
335
+ const props = event.properties
336
+ if (props.sessionID !== sessionID || !props.error) return
337
+ hasError = true
338
+ eventHandler.output({
339
+ type: 'error',
340
+ timestamp: Date.now(),
341
+ sessionID,
342
+ error: props.error
343
+ })
344
+ }
345
+ })
346
+ })
347
+
348
+ // Send message to session directly
349
+ const message = request.message || "hi"
350
+ const parts = [{ type: "text", text: message }]
351
+
352
+ // Start the prompt directly without HTTP
353
+ SessionPrompt.prompt({
354
+ sessionID,
355
+ parts,
356
+ model: {
357
+ providerID,
358
+ modelID
359
+ },
360
+ system: systemMessage,
361
+ appendSystem: appendSystemMessage
362
+ }).catch((error) => {
363
+ hasError = true
364
+ eventHandler.output({
365
+ type: 'error',
366
+ timestamp: Date.now(),
367
+ sessionID,
368
+ error: error instanceof Error ? error.message : String(error)
369
+ })
370
+ })
371
+
372
+ // Wait for session to become idle
373
+ await eventPromise
374
+ } finally {
375
+ // Always clean up resources
376
+ if (unsub) unsub()
377
+ await Instance.dispose()
378
+ }
379
+ }
380
+
38
381
  async function main() {
39
382
  try {
40
- // Parse command line arguments
383
+ // Parse command line arguments with subcommands
41
384
  const argv = await yargs(hideBin(process.argv))
385
+ .scriptName('agent')
386
+ .usage('$0 [command] [options]')
387
+ // MCP subcommand
388
+ .command(McpCommand)
389
+ // Default run mode (when piping stdin)
42
390
  .option('model', {
43
391
  type: 'string',
44
392
  description: 'Model to use in format providerID/modelID',
45
393
  default: 'opencode/grok-code'
46
394
  })
395
+ .option('json-standard', {
396
+ type: 'string',
397
+ description: 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
398
+ default: 'opencode',
399
+ choices: ['opencode', 'claude']
400
+ })
47
401
  .option('system-message', {
48
402
  type: 'string',
49
403
  description: 'Full override of the system message'
@@ -68,302 +422,23 @@ async function main() {
68
422
  .help()
69
423
  .argv
70
424
 
71
- // Parse model argument
72
- const modelParts = argv.model.split('/')
73
- const providerID = modelParts[0] || 'opencode'
74
- const modelID = modelParts[1] || 'grok-code'
75
-
76
- // Read system message files
77
- let systemMessage = argv['system-message']
78
- let appendSystemMessage = argv['append-system-message']
79
-
80
- if (argv['system-message-file']) {
81
- const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file'])
82
- const file = Bun.file(resolvedPath)
83
- if (!(await file.exists())) {
84
- console.error(`System message file not found: ${argv['system-message-file']}`)
85
- process.exit(1)
86
- }
87
- systemMessage = await file.text()
88
- }
425
+ // If a command was executed (like mcp), yargs handles it
426
+ // Otherwise, check if we should run in agent mode (stdin piped)
427
+ const commandExecuted = argv._ && argv._.length > 0
89
428
 
90
- if (argv['append-system-message-file']) {
91
- const resolvedPath = require('path').resolve(process.cwd(), argv['append-system-message-file'])
92
- const file = Bun.file(resolvedPath)
93
- if (!(await file.exists())) {
94
- console.error(`Append system message file not found: ${argv['append-system-message-file']}`)
95
- process.exit(1)
96
- }
97
- appendSystemMessage = await file.text()
429
+ if (!commandExecuted) {
430
+ // No command specified, run in default agent mode (stdin processing)
431
+ await runAgentMode(argv)
98
432
  }
99
-
100
- // Initialize logging to redirect to log file instead of stderr
101
- // This prevents log messages from mixing with JSON output
102
- await Log.init({
103
- print: false, // Don't print to stderr
104
- level: 'INFO'
105
- })
106
-
107
- // Read input from stdin
108
- const input = await readStdin()
109
- const trimmedInput = input.trim()
110
-
111
- // Try to parse as JSON, if it fails treat it as plain text message
112
- let request
113
- try {
114
- request = JSON.parse(trimmedInput)
115
- } catch (e) {
116
- // Not JSON, treat as plain text message
117
- request = {
118
- message: trimmedInput
119
- }
120
- }
121
-
122
- // Wrap in Instance.provide for OpenCode infrastructure
123
- await Instance.provide({
124
- directory: process.cwd(),
125
- fn: async () => {
126
- if (argv.server) {
127
- // SERVER MODE: Start server and communicate via HTTP
128
- await runServerMode()
129
- } else {
130
- // DIRECT MODE: Run everything in single process
131
- await runDirectMode()
132
- }
133
- }
134
- })
135
-
136
- async function runServerMode() {
137
- // Start server like OpenCode does
138
- const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
139
- let unsub = null
140
-
141
- try {
142
- // Create a session
143
- const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, {
144
- method: 'POST',
145
- headers: { 'Content-Type': 'application/json' },
146
- body: JSON.stringify({})
147
- })
148
- const session = await createRes.json()
149
- const sessionID = session.id
150
-
151
- if (!sessionID) {
152
- throw new Error("Failed to create session")
153
- }
154
-
155
- // Subscribe to all bus events to output them in OpenCode format
156
- const eventPromise = new Promise((resolve) => {
157
- unsub = Bus.subscribeAll((event) => {
158
- // Output events in OpenCode JSON format
159
- if (event.type === 'message.part.updated') {
160
- const part = event.properties.part
161
- if (part.sessionID !== sessionID) return
162
-
163
- // Output different event types (pretty-printed for readability)
164
- if (part.type === 'step-start') {
165
- process.stdout.write(JSON.stringify({
166
- type: 'step_start',
167
- timestamp: Date.now(),
168
- sessionID,
169
- part
170
- }, null, 2) + EOL)
171
- }
172
-
173
- if (part.type === 'step-finish') {
174
- process.stdout.write(JSON.stringify({
175
- type: 'step_finish',
176
- timestamp: Date.now(),
177
- sessionID,
178
- part
179
- }, null, 2) + EOL)
180
- }
181
-
182
- if (part.type === 'text' && part.time?.end) {
183
- process.stdout.write(JSON.stringify({
184
- type: 'text',
185
- timestamp: Date.now(),
186
- sessionID,
187
- part
188
- }, null, 2) + EOL)
189
- }
190
-
191
- if (part.type === 'tool' && part.state.status === 'completed') {
192
- process.stdout.write(JSON.stringify({
193
- type: 'tool_use',
194
- timestamp: Date.now(),
195
- sessionID,
196
- part
197
- }, null, 2) + EOL)
198
- }
199
- }
200
-
201
- // Handle session idle to know when to stop
202
- if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
203
- resolve()
204
- }
205
-
206
- // Handle errors
207
- if (event.type === 'session.error') {
208
- const props = event.properties
209
- if (props.sessionID !== sessionID || !props.error) return
210
- process.stdout.write(JSON.stringify({
211
- type: 'error',
212
- timestamp: Date.now(),
213
- sessionID,
214
- error: props.error
215
- }, null, 2) + EOL)
216
- }
217
- })
218
- })
219
-
220
- // Send message to session with specified model (default: opencode/grok-code)
221
- const message = request.message || "hi"
222
- const parts = [{ type: "text", text: message }]
223
-
224
- // Start the prompt (don't wait for response, events come via Bus)
225
- fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, {
226
- method: 'POST',
227
- headers: { 'Content-Type': 'application/json' },
228
- body: JSON.stringify({
229
- parts,
230
- model: {
231
- providerID,
232
- modelID
233
- },
234
- system: systemMessage,
235
- appendSystem: appendSystemMessage
236
- })
237
- }).catch(() => {
238
- // Ignore errors, we're listening to events
239
- })
240
-
241
- // Wait for session to become idle
242
- await eventPromise
243
- } finally {
244
- // Always clean up resources
245
- if (unsub) unsub()
246
- server.stop()
247
- await Instance.dispose()
248
- }
249
- }
250
-
251
- async function runDirectMode() {
252
- // DIRECT MODE: Run in single process without server
253
- let unsub = null
254
-
255
- try {
256
- // Create a session directly
257
- const session = await Session.createNext({
258
- directory: process.cwd()
259
- })
260
- const sessionID = session.id
261
-
262
- // Subscribe to all bus events to output them in OpenCode format
263
- const eventPromise = new Promise((resolve) => {
264
- unsub = Bus.subscribeAll((event) => {
265
- // Output events in OpenCode JSON format
266
- if (event.type === 'message.part.updated') {
267
- const part = event.properties.part
268
- if (part.sessionID !== sessionID) return
269
-
270
- // Output different event types (pretty-printed for readability)
271
- if (part.type === 'step-start') {
272
- process.stdout.write(JSON.stringify({
273
- type: 'step_start',
274
- timestamp: Date.now(),
275
- sessionID,
276
- part
277
- }, null, 2) + EOL)
278
- }
279
-
280
- if (part.type === 'step-finish') {
281
- process.stdout.write(JSON.stringify({
282
- type: 'step_finish',
283
- timestamp: Date.now(),
284
- sessionID,
285
- part
286
- }, null, 2) + EOL)
287
- }
288
-
289
- if (part.type === 'text' && part.time?.end) {
290
- process.stdout.write(JSON.stringify({
291
- type: 'text',
292
- timestamp: Date.now(),
293
- sessionID,
294
- part
295
- }, null, 2) + EOL)
296
- }
297
-
298
- if (part.type === 'tool' && part.state.status === 'completed') {
299
- process.stdout.write(JSON.stringify({
300
- type: 'tool_use',
301
- timestamp: Date.now(),
302
- sessionID,
303
- part
304
- }, null, 2) + EOL)
305
- }
306
- }
307
-
308
- // Handle session idle to know when to stop
309
- if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
310
- resolve()
311
- }
312
-
313
- // Handle errors
314
- if (event.type === 'session.error') {
315
- const props = event.properties
316
- if (props.sessionID !== sessionID || !props.error) return
317
- process.stdout.write(JSON.stringify({
318
- type: 'error',
319
- timestamp: Date.now(),
320
- sessionID,
321
- error: props.error
322
- }, null, 2) + EOL)
323
- }
324
- })
325
- })
326
-
327
- // Send message to session directly
328
- const message = request.message || "hi"
329
- const parts = [{ type: "text", text: message }]
330
-
331
- // Start the prompt directly without HTTP
332
- SessionPrompt.prompt({
333
- sessionID,
334
- parts,
335
- model: {
336
- providerID,
337
- modelID
338
- },
339
- system: systemMessage,
340
- appendSystem: appendSystemMessage
341
- }).catch((error) => {
342
- process.stdout.write(JSON.stringify({
343
- type: 'error',
344
- timestamp: Date.now(),
345
- sessionID,
346
- error: error instanceof Error ? error.message : String(error)
347
- }, null, 2) + EOL)
348
- })
349
-
350
- // Wait for session to become idle
351
- await eventPromise
352
- } finally {
353
- // Always clean up resources
354
- if (unsub) unsub()
355
- await Instance.dispose()
356
- }
357
- }
358
-
359
- // Explicitly exit to ensure process terminates
360
- process.exit(0)
361
433
  } catch (error) {
434
+ hasError = true
362
435
  console.error(JSON.stringify({
363
436
  type: 'error',
364
437
  timestamp: Date.now(),
365
- error: error instanceof Error ? error.message : String(error)
366
- }))
438
+ errorType: error instanceof Error ? error.name : 'Error',
439
+ message: error instanceof Error ? error.message : String(error),
440
+ stack: error instanceof Error ? error.stack : undefined
441
+ }, null, 2))
367
442
  process.exit(1)
368
443
  }
369
444
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * JSON Standard Format Handlers
3
+ *
4
+ * Provides adapters for different JSON output formats:
5
+ * - opencode: OpenCode format (default) - pretty-printed JSON events
6
+ * - claude: Claude CLI stream-json format - NDJSON (newline-delimited JSON)
7
+ */
8
+
9
+ import { EOL } from 'os'
10
+
11
+ export type JsonStandard = 'opencode' | 'claude'
12
+
13
+ /**
14
+ * OpenCode JSON event types
15
+ */
16
+ export interface OpenCodeEvent {
17
+ type: 'step_start' | 'step_finish' | 'text' | 'tool_use' | 'error'
18
+ timestamp: number
19
+ sessionID: string
20
+ part?: Record<string, unknown>
21
+ error?: string | Record<string, unknown>
22
+ }
23
+
24
+ /**
25
+ * Claude JSON event types (stream-json format)
26
+ */
27
+ export interface ClaudeEvent {
28
+ type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result'
29
+ timestamp?: string
30
+ session_id?: string
31
+ role?: 'assistant' | 'user'
32
+ content?: Array<{type: 'text' | 'tool_use', text?: string, name?: string, input?: unknown}>
33
+ output?: string
34
+ name?: string
35
+ input?: unknown
36
+ tool_use_id?: string
37
+ status?: 'success' | 'error'
38
+ duration_ms?: number
39
+ model?: string
40
+ }
41
+
42
+ /**
43
+ * Serialize JSON output based on the selected standard
44
+ */
45
+ export function serializeOutput(event: OpenCodeEvent | ClaudeEvent, standard: JsonStandard): string {
46
+ if (standard === 'claude') {
47
+ // NDJSON format - compact, one line
48
+ return JSON.stringify(event) + EOL
49
+ }
50
+ // OpenCode format - pretty-printed
51
+ return JSON.stringify(event, null, 2) + EOL
52
+ }
53
+
54
+ /**
55
+ * Convert OpenCode event to Claude event format
56
+ */
57
+ export function convertOpenCodeToClaude(event: OpenCodeEvent, startTime: number): ClaudeEvent | null {
58
+ const timestamp = new Date(event.timestamp).toISOString()
59
+ const session_id = event.sessionID
60
+
61
+ switch (event.type) {
62
+ case 'step_start':
63
+ return {
64
+ type: 'init',
65
+ timestamp,
66
+ session_id
67
+ }
68
+
69
+ case 'text':
70
+ if (event.part && typeof event.part.text === 'string') {
71
+ return {
72
+ type: 'message',
73
+ timestamp,
74
+ session_id,
75
+ role: 'assistant',
76
+ content: [{
77
+ type: 'text',
78
+ text: event.part.text
79
+ }]
80
+ }
81
+ }
82
+ return null
83
+
84
+ case 'tool_use':
85
+ if (event.part && event.part.state) {
86
+ const state = event.part.state as Record<string, unknown>
87
+ const tool = state.tool as Record<string, unknown> | undefined
88
+ return {
89
+ type: 'tool_use',
90
+ timestamp,
91
+ session_id,
92
+ name: (tool?.name as string) || 'unknown',
93
+ input: tool?.parameters || {},
94
+ tool_use_id: event.part.id as string
95
+ }
96
+ }
97
+ return null
98
+
99
+ case 'step_finish':
100
+ return {
101
+ type: 'result',
102
+ timestamp,
103
+ session_id,
104
+ status: 'success',
105
+ duration_ms: event.timestamp - startTime
106
+ }
107
+
108
+ case 'error':
109
+ return {
110
+ type: 'result',
111
+ timestamp,
112
+ session_id,
113
+ status: 'error',
114
+ output: typeof event.error === 'string' ? event.error : JSON.stringify(event.error)
115
+ }
116
+
117
+ default:
118
+ return null
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Create an event output handler based on the selected standard
124
+ */
125
+ export function createEventHandler(standard: JsonStandard, sessionID: string) {
126
+ const startTime = Date.now()
127
+
128
+ return {
129
+ /**
130
+ * Format and output an event
131
+ */
132
+ output(event: OpenCodeEvent): void {
133
+ if (standard === 'claude') {
134
+ const claudeEvent = convertOpenCodeToClaude(event, startTime)
135
+ if (claudeEvent) {
136
+ process.stdout.write(serializeOutput(claudeEvent, standard))
137
+ }
138
+ } else {
139
+ process.stdout.write(serializeOutput(event, standard))
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Get the start time for duration calculations
145
+ */
146
+ getStartTime(): number {
147
+ return startTime
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Validate JSON standard option
154
+ */
155
+ export function isValidJsonStandard(value: string): value is JsonStandard {
156
+ return value === 'opencode' || value === 'claude'
157
+ }