@link-assistant/agent 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -218,6 +218,9 @@ Stdin Mode Options:
218
218
  --no-interactive Only accept JSON input
219
219
  --auto-merge-queued-messages Merge rapidly arriving lines (default: true)
220
220
  --no-auto-merge-queued-messages Treat each line as separate message
221
+ --always-accept-stdin Keep accepting input after agent finishes (default: true)
222
+ --no-always-accept-stdin Single-message mode - exit after first response
223
+ --compact-json Output compact JSON for program-to-program use
221
224
 
222
225
  --help Show help
223
226
  --version Show version number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Continuous stdin mode for the Agent CLI.
3
+ * Keeps the session alive and processes messages as they arrive.
4
+ */
5
+
6
+ import { Server } from '../server/server.ts';
7
+ import { Instance } from '../project/instance.ts';
8
+ import { Bus } from '../bus/index.ts';
9
+ import { Session } from '../session/index.ts';
10
+ import { SessionPrompt } from '../session/prompt.ts';
11
+ import { createEventHandler } from '../json-standard/index.ts';
12
+ import { createContinuousStdinReader } from './input-queue.js';
13
+
14
+ // Shared error tracking
15
+ let hasError = false;
16
+
17
+ /**
18
+ * Set error state
19
+ * @param {boolean} value - Error state value
20
+ */
21
+ export function setHasError(value) {
22
+ hasError = value;
23
+ }
24
+
25
+ /**
26
+ * Get error state
27
+ * @returns {boolean}
28
+ */
29
+ export function getHasError() {
30
+ return hasError;
31
+ }
32
+
33
+ /**
34
+ * Output JSON status message to stderr
35
+ * @param {object} status - Status object to output
36
+ * @param {boolean} compact - If true, output compact JSON (single line)
37
+ */
38
+ function outputStatus(status, compact = false) {
39
+ const json = compact
40
+ ? JSON.stringify(status)
41
+ : JSON.stringify(status, null, 2);
42
+ console.error(json);
43
+ }
44
+
45
+ /**
46
+ * Run server mode with continuous stdin input
47
+ * Keeps the session alive and processes messages as they arrive
48
+ */
49
+ export async function runContinuousServerMode(
50
+ argv,
51
+ providerID,
52
+ modelID,
53
+ systemMessage,
54
+ appendSystemMessage,
55
+ jsonStandard
56
+ ) {
57
+ const compactJson = argv['compact-json'] === true;
58
+ const isInteractive = argv.interactive !== false;
59
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
60
+
61
+ // Start server like OpenCode does
62
+ const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
63
+ let unsub = null;
64
+ let stdinReader = null;
65
+
66
+ try {
67
+ // Create a session
68
+ const createRes = await fetch(
69
+ `http://${server.hostname}:${server.port}/session`,
70
+ {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({}),
74
+ }
75
+ );
76
+ const session = await createRes.json();
77
+ const sessionID = session.id;
78
+
79
+ if (!sessionID) {
80
+ throw new Error('Failed to create session');
81
+ }
82
+
83
+ // Create event handler for the selected JSON standard
84
+ const eventHandler = createEventHandler(jsonStandard, sessionID);
85
+
86
+ // Track if we're currently processing a message
87
+ let isProcessing = false;
88
+ const pendingMessages = [];
89
+
90
+ // Process messages from the queue
91
+ const processMessage = async (message) => {
92
+ if (isProcessing) {
93
+ pendingMessages.push(message);
94
+ return;
95
+ }
96
+
97
+ isProcessing = true;
98
+ const messageText = message.message || 'hi';
99
+ const parts = [{ type: 'text', text: messageText }];
100
+
101
+ // Create a promise to wait for this message to complete
102
+ const messagePromise = new Promise((resolve) => {
103
+ const checkIdle = Bus.subscribeAll((event) => {
104
+ if (
105
+ event.type === 'session.idle' &&
106
+ event.properties.sessionID === sessionID
107
+ ) {
108
+ checkIdle();
109
+ resolve();
110
+ }
111
+ });
112
+ });
113
+
114
+ // Send message
115
+ fetch(
116
+ `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
117
+ {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({
121
+ parts,
122
+ model: { providerID, modelID },
123
+ system: systemMessage,
124
+ appendSystem: appendSystemMessage,
125
+ }),
126
+ }
127
+ ).catch((error) => {
128
+ hasError = true;
129
+ eventHandler.output({
130
+ type: 'error',
131
+ timestamp: Date.now(),
132
+ sessionID,
133
+ error: error instanceof Error ? error.message : String(error),
134
+ });
135
+ });
136
+
137
+ await messagePromise;
138
+ isProcessing = false;
139
+
140
+ // Process next pending message if any
141
+ if (pendingMessages.length > 0) {
142
+ const nextMessage = pendingMessages.shift();
143
+ processMessage(nextMessage);
144
+ }
145
+ };
146
+
147
+ // Subscribe to all bus events and output in selected format
148
+ unsub = Bus.subscribeAll((event) => {
149
+ if (event.type === 'message.part.updated') {
150
+ const part = event.properties.part;
151
+ if (part.sessionID !== sessionID) {
152
+ return;
153
+ }
154
+
155
+ if (part.type === 'step-start') {
156
+ eventHandler.output({
157
+ type: 'step_start',
158
+ timestamp: Date.now(),
159
+ sessionID,
160
+ part,
161
+ });
162
+ }
163
+
164
+ if (part.type === 'step-finish') {
165
+ eventHandler.output({
166
+ type: 'step_finish',
167
+ timestamp: Date.now(),
168
+ sessionID,
169
+ part,
170
+ });
171
+ }
172
+
173
+ if (part.type === 'text' && part.time?.end) {
174
+ eventHandler.output({
175
+ type: 'text',
176
+ timestamp: Date.now(),
177
+ sessionID,
178
+ part,
179
+ });
180
+ }
181
+
182
+ if (part.type === 'tool' && part.state.status === 'completed') {
183
+ eventHandler.output({
184
+ type: 'tool_use',
185
+ timestamp: Date.now(),
186
+ sessionID,
187
+ part,
188
+ });
189
+ }
190
+ }
191
+
192
+ if (event.type === 'session.error') {
193
+ const props = event.properties;
194
+ if (props.sessionID !== sessionID || !props.error) {
195
+ return;
196
+ }
197
+ hasError = true;
198
+ eventHandler.output({
199
+ type: 'error',
200
+ timestamp: Date.now(),
201
+ sessionID,
202
+ error: props.error,
203
+ });
204
+ }
205
+ });
206
+
207
+ // Create continuous stdin reader
208
+ stdinReader = createContinuousStdinReader({
209
+ interactive: isInteractive,
210
+ autoMerge,
211
+ onMessage: (message) => {
212
+ processMessage(message);
213
+ },
214
+ });
215
+
216
+ // Wait for stdin to end (EOF or close)
217
+ await new Promise((resolve) => {
218
+ const checkRunning = setInterval(() => {
219
+ if (!stdinReader.isRunning()) {
220
+ clearInterval(checkRunning);
221
+ // Wait for any pending messages to complete
222
+ const waitForPending = () => {
223
+ if (!isProcessing && pendingMessages.length === 0) {
224
+ resolve();
225
+ } else {
226
+ setTimeout(waitForPending, 100);
227
+ }
228
+ };
229
+ waitForPending();
230
+ }
231
+ }, 100);
232
+
233
+ // Also handle SIGINT
234
+ process.on('SIGINT', () => {
235
+ outputStatus(
236
+ {
237
+ type: 'status',
238
+ message: 'Received SIGINT. Shutting down...',
239
+ },
240
+ compactJson
241
+ );
242
+ clearInterval(checkRunning);
243
+ resolve();
244
+ });
245
+ });
246
+ } finally {
247
+ if (stdinReader) {
248
+ stdinReader.stop();
249
+ }
250
+ if (unsub) {
251
+ unsub();
252
+ }
253
+ server.stop();
254
+ await Instance.dispose();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Run direct mode with continuous stdin input
260
+ * Keeps the session alive and processes messages as they arrive
261
+ */
262
+ export async function runContinuousDirectMode(
263
+ argv,
264
+ providerID,
265
+ modelID,
266
+ systemMessage,
267
+ appendSystemMessage,
268
+ jsonStandard
269
+ ) {
270
+ const compactJson = argv['compact-json'] === true;
271
+ const isInteractive = argv.interactive !== false;
272
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
273
+
274
+ let unsub = null;
275
+ let stdinReader = null;
276
+
277
+ try {
278
+ // Create a session directly
279
+ const session = await Session.createNext({
280
+ directory: process.cwd(),
281
+ });
282
+ const sessionID = session.id;
283
+
284
+ // Create event handler for the selected JSON standard
285
+ const eventHandler = createEventHandler(jsonStandard, sessionID);
286
+
287
+ // Track if we're currently processing a message
288
+ let isProcessing = false;
289
+ const pendingMessages = [];
290
+
291
+ // Process messages from the queue
292
+ const processMessage = async (message) => {
293
+ if (isProcessing) {
294
+ pendingMessages.push(message);
295
+ return;
296
+ }
297
+
298
+ isProcessing = true;
299
+ const messageText = message.message || 'hi';
300
+ const parts = [{ type: 'text', text: messageText }];
301
+
302
+ // Create a promise to wait for this message to complete
303
+ const messagePromise = new Promise((resolve) => {
304
+ const checkIdle = Bus.subscribeAll((event) => {
305
+ if (
306
+ event.type === 'session.idle' &&
307
+ event.properties.sessionID === sessionID
308
+ ) {
309
+ checkIdle();
310
+ resolve();
311
+ }
312
+ });
313
+ });
314
+
315
+ // Send message directly
316
+ SessionPrompt.prompt({
317
+ sessionID,
318
+ parts,
319
+ model: { providerID, modelID },
320
+ system: systemMessage,
321
+ appendSystem: appendSystemMessage,
322
+ }).catch((error) => {
323
+ hasError = true;
324
+ eventHandler.output({
325
+ type: 'error',
326
+ timestamp: Date.now(),
327
+ sessionID,
328
+ error: error instanceof Error ? error.message : String(error),
329
+ });
330
+ });
331
+
332
+ await messagePromise;
333
+ isProcessing = false;
334
+
335
+ // Process next pending message if any
336
+ if (pendingMessages.length > 0) {
337
+ const nextMessage = pendingMessages.shift();
338
+ processMessage(nextMessage);
339
+ }
340
+ };
341
+
342
+ // Subscribe to all bus events and output in selected format
343
+ unsub = Bus.subscribeAll((event) => {
344
+ if (event.type === 'message.part.updated') {
345
+ const part = event.properties.part;
346
+ if (part.sessionID !== sessionID) {
347
+ return;
348
+ }
349
+
350
+ if (part.type === 'step-start') {
351
+ eventHandler.output({
352
+ type: 'step_start',
353
+ timestamp: Date.now(),
354
+ sessionID,
355
+ part,
356
+ });
357
+ }
358
+
359
+ if (part.type === 'step-finish') {
360
+ eventHandler.output({
361
+ type: 'step_finish',
362
+ timestamp: Date.now(),
363
+ sessionID,
364
+ part,
365
+ });
366
+ }
367
+
368
+ if (part.type === 'text' && part.time?.end) {
369
+ eventHandler.output({
370
+ type: 'text',
371
+ timestamp: Date.now(),
372
+ sessionID,
373
+ part,
374
+ });
375
+ }
376
+
377
+ if (part.type === 'tool' && part.state.status === 'completed') {
378
+ eventHandler.output({
379
+ type: 'tool_use',
380
+ timestamp: Date.now(),
381
+ sessionID,
382
+ part,
383
+ });
384
+ }
385
+ }
386
+
387
+ if (event.type === 'session.error') {
388
+ const props = event.properties;
389
+ if (props.sessionID !== sessionID || !props.error) {
390
+ return;
391
+ }
392
+ hasError = true;
393
+ eventHandler.output({
394
+ type: 'error',
395
+ timestamp: Date.now(),
396
+ sessionID,
397
+ error: props.error,
398
+ });
399
+ }
400
+ });
401
+
402
+ // Create continuous stdin reader
403
+ stdinReader = createContinuousStdinReader({
404
+ interactive: isInteractive,
405
+ autoMerge,
406
+ onMessage: (message) => {
407
+ processMessage(message);
408
+ },
409
+ });
410
+
411
+ // Wait for stdin to end (EOF or close)
412
+ await new Promise((resolve) => {
413
+ const checkRunning = setInterval(() => {
414
+ if (!stdinReader.isRunning()) {
415
+ clearInterval(checkRunning);
416
+ // Wait for any pending messages to complete
417
+ const waitForPending = () => {
418
+ if (!isProcessing && pendingMessages.length === 0) {
419
+ resolve();
420
+ } else {
421
+ setTimeout(waitForPending, 100);
422
+ }
423
+ };
424
+ waitForPending();
425
+ }
426
+ }, 100);
427
+
428
+ // Also handle SIGINT
429
+ process.on('SIGINT', () => {
430
+ outputStatus(
431
+ {
432
+ type: 'status',
433
+ message: 'Received SIGINT. Shutting down...',
434
+ },
435
+ compactJson
436
+ );
437
+ clearInterval(checkRunning);
438
+ resolve();
439
+ });
440
+ });
441
+ } finally {
442
+ if (stdinReader) {
443
+ stdinReader.stop();
444
+ }
445
+ if (unsub) {
446
+ unsub();
447
+ }
448
+ await Instance.dispose();
449
+ }
450
+ }
package/src/index.js CHANGED
@@ -18,6 +18,10 @@ import { AuthCommand } from './cli/cmd/auth.ts';
18
18
  import { Flag } from './flag/flag.ts';
19
19
  import { FormatError } from './cli/error.ts';
20
20
  import { UI } from './cli/ui.ts';
21
+ import {
22
+ runContinuousServerMode,
23
+ runContinuousDirectMode,
24
+ } from './cli/continuous-mode.js';
21
25
  import { createRequire } from 'module';
22
26
  import { readFileSync } from 'fs';
23
27
  import { dirname, join } from 'path';
@@ -132,30 +136,21 @@ function readStdinWithTimeout(timeout = null) {
132
136
  * Output JSON status message to stderr
133
137
  * This prevents the status message from interfering with JSON output parsing
134
138
  * @param {object} status - Status object to output
139
+ * @param {boolean} compact - If true, output compact JSON (single line)
135
140
  */
136
- function outputStatus(status) {
137
- console.error(JSON.stringify(status));
141
+ function outputStatus(status, compact = false) {
142
+ const json = compact
143
+ ? JSON.stringify(status)
144
+ : JSON.stringify(status, null, 2);
145
+ console.error(json);
138
146
  }
139
147
 
140
- async function runAgentMode(argv, request) {
141
- // Note: verbose flag and logging are now initialized in middleware
142
- // See main() function for the middleware that sets up Flag and Log.init()
143
-
144
- // Log version and command info in verbose mode
145
- if (Flag.OPENCODE_VERBOSE) {
146
- console.error(`Agent version: ${pkg.version}`);
147
- console.error(`Command: ${process.argv.join(' ')}`);
148
- console.error(`Working directory: ${process.cwd()}`);
149
- console.error(`Script path: ${import.meta.path}`);
150
- }
151
-
152
- // Log dry-run mode if enabled
153
- if (Flag.OPENCODE_DRY_RUN) {
154
- console.error(
155
- `[DRY RUN MODE] No actual API calls or package installations will be made`
156
- );
157
- }
158
-
148
+ /**
149
+ * Parse model configuration from argv
150
+ * @param {object} argv - Command line arguments
151
+ * @returns {object} - { providerID, modelID }
152
+ */
153
+ async function parseModelConfig(argv) {
159
154
  // Parse model argument (handle model IDs with slashes like groq/qwen/qwen3-32b)
160
155
  const modelParts = argv.model.split('/');
161
156
  let providerID = modelParts[0] || 'opencode';
@@ -170,13 +165,15 @@ async function runAgentMode(argv, request) {
170
165
  const creds = await ClaudeOAuth.getCredentials();
171
166
 
172
167
  if (!creds?.accessToken) {
173
- console.error(
174
- JSON.stringify({
168
+ const compactJson = argv['compact-json'] === true;
169
+ outputStatus(
170
+ {
175
171
  type: 'error',
176
172
  errorType: 'AuthenticationError',
177
173
  message:
178
174
  'No Claude OAuth credentials found in ~/.claude/.credentials.json. Either authenticate with Claude Code CLI first, or use: agent auth login (select Anthropic > Claude Pro/Max)',
179
- })
175
+ },
176
+ compactJson
180
177
  );
181
178
  process.exit(1);
182
179
  }
@@ -191,26 +188,27 @@ async function runAgentMode(argv, request) {
191
188
  modelID = 'claude-sonnet-4-5';
192
189
  } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
193
190
  // If user specified a different provider, warn them
194
- console.error(
195
- JSON.stringify({
191
+ const compactJson = argv['compact-json'] === true;
192
+ outputStatus(
193
+ {
196
194
  type: 'warning',
197
195
  message: `--use-existing-claude-oauth is set but model uses provider "${providerID}". Using OAuth credentials anyway.`,
198
- })
196
+ },
197
+ compactJson
199
198
  );
200
199
  providerID = 'claude-oauth';
201
200
  }
202
201
  }
203
202
 
204
- // Validate and get JSON standard
205
- const jsonStandard = argv['json-standard'];
206
- if (!isValidJsonStandard(jsonStandard)) {
207
- console.error(
208
- `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`
209
- );
210
- process.exit(1);
211
- }
203
+ return { providerID, modelID };
204
+ }
212
205
 
213
- // Read system message files
206
+ /**
207
+ * Read system message from files if specified
208
+ * @param {object} argv - Command line arguments
209
+ * @returns {object} - { systemMessage, appendSystemMessage }
210
+ */
211
+ async function readSystemMessages(argv) {
214
212
  let systemMessage = argv['system-message'];
215
213
  let appendSystemMessage = argv['append-system-message'];
216
214
 
@@ -244,6 +242,41 @@ async function runAgentMode(argv, request) {
244
242
  appendSystemMessage = await file.text();
245
243
  }
246
244
 
245
+ return { systemMessage, appendSystemMessage };
246
+ }
247
+
248
+ async function runAgentMode(argv, request) {
249
+ // Note: verbose flag and logging are now initialized in middleware
250
+ // See main() function for the middleware that sets up Flag and Log.init()
251
+
252
+ // Log version and command info in verbose mode
253
+ if (Flag.OPENCODE_VERBOSE) {
254
+ console.error(`Agent version: ${pkg.version}`);
255
+ console.error(`Command: ${process.argv.join(' ')}`);
256
+ console.error(`Working directory: ${process.cwd()}`);
257
+ console.error(`Script path: ${import.meta.path}`);
258
+ }
259
+
260
+ // Log dry-run mode if enabled
261
+ if (Flag.OPENCODE_DRY_RUN) {
262
+ console.error(
263
+ `[DRY RUN MODE] No actual API calls or package installations will be made`
264
+ );
265
+ }
266
+
267
+ const { providerID, modelID } = await parseModelConfig(argv);
268
+
269
+ // Validate and get JSON standard
270
+ const jsonStandard = argv['json-standard'];
271
+ if (!isValidJsonStandard(jsonStandard)) {
272
+ console.error(
273
+ `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`
274
+ );
275
+ process.exit(1);
276
+ }
277
+
278
+ const { systemMessage, appendSystemMessage } = await readSystemMessages(argv);
279
+
247
280
  // Logging is already initialized in middleware, no need to call Log.init() again
248
281
 
249
282
  // Wrap in Instance.provide for OpenCode infrastructure
@@ -278,6 +311,81 @@ async function runAgentMode(argv, request) {
278
311
  process.exit(hasError ? 1 : 0);
279
312
  }
280
313
 
314
+ /**
315
+ * Run agent in continuous stdin mode
316
+ * Keeps accepting input until EOF or SIGINT
317
+ * @param {object} argv - Command line arguments
318
+ */
319
+ async function runContinuousAgentMode(argv) {
320
+ // Note: verbose flag and logging are now initialized in middleware
321
+ // See main() function for the middleware that sets up Flag and Log.init()
322
+
323
+ const compactJson = argv['compact-json'] === true;
324
+
325
+ // Log version and command info in verbose mode
326
+ if (Flag.OPENCODE_VERBOSE) {
327
+ console.error(`Agent version: ${pkg.version}`);
328
+ console.error(`Command: ${process.argv.join(' ')}`);
329
+ console.error(`Working directory: ${process.cwd()}`);
330
+ console.error(`Script path: ${import.meta.path}`);
331
+ }
332
+
333
+ // Log dry-run mode if enabled
334
+ if (Flag.OPENCODE_DRY_RUN) {
335
+ console.error(
336
+ `[DRY RUN MODE] No actual API calls or package installations will be made`
337
+ );
338
+ }
339
+
340
+ const { providerID, modelID } = await parseModelConfig(argv);
341
+
342
+ // Validate and get JSON standard
343
+ const jsonStandard = argv['json-standard'];
344
+ if (!isValidJsonStandard(jsonStandard)) {
345
+ outputStatus(
346
+ {
347
+ type: 'error',
348
+ message: `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`,
349
+ },
350
+ compactJson
351
+ );
352
+ process.exit(1);
353
+ }
354
+
355
+ const { systemMessage, appendSystemMessage } = await readSystemMessages(argv);
356
+
357
+ // Wrap in Instance.provide for OpenCode infrastructure
358
+ await Instance.provide({
359
+ directory: process.cwd(),
360
+ fn: async () => {
361
+ if (argv.server) {
362
+ // SERVER MODE: Start server and communicate via HTTP
363
+ await runContinuousServerMode(
364
+ argv,
365
+ providerID,
366
+ modelID,
367
+ systemMessage,
368
+ appendSystemMessage,
369
+ jsonStandard
370
+ );
371
+ } else {
372
+ // DIRECT MODE: Run everything in single process
373
+ await runContinuousDirectMode(
374
+ argv,
375
+ providerID,
376
+ modelID,
377
+ systemMessage,
378
+ appendSystemMessage,
379
+ jsonStandard
380
+ );
381
+ }
382
+ },
383
+ });
384
+
385
+ // Explicitly exit to ensure process terminates
386
+ process.exit(hasError ? 1 : 0);
387
+ }
388
+
281
389
  async function runServerMode(
282
390
  request,
283
391
  providerID,
@@ -565,85 +673,253 @@ async function main() {
565
673
  .command(McpCommand)
566
674
  // Auth subcommand
567
675
  .command(AuthCommand)
568
- // Default run mode (when piping stdin)
569
- .option('model', {
570
- type: 'string',
571
- description: 'Model to use in format providerID/modelID',
572
- default: 'opencode/grok-code',
573
- })
574
- .option('json-standard', {
575
- type: 'string',
576
- description:
577
- 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
578
- default: 'opencode',
579
- choices: ['opencode', 'claude'],
580
- })
581
- .option('system-message', {
582
- type: 'string',
583
- description: 'Full override of the system message',
584
- })
585
- .option('system-message-file', {
586
- type: 'string',
587
- description: 'Full override of the system message from file',
588
- })
589
- .option('append-system-message', {
590
- type: 'string',
591
- description: 'Append to the default system message',
592
- })
593
- .option('append-system-message-file', {
594
- type: 'string',
595
- description: 'Append to the default system message from file',
596
- })
597
- .option('server', {
598
- type: 'boolean',
599
- description: 'Run in server mode (default)',
600
- default: true,
601
- })
602
- .option('verbose', {
603
- type: 'boolean',
604
- description:
605
- 'Enable verbose mode to debug API requests (shows system prompt, token counts, etc.)',
606
- default: false,
607
- })
608
- .option('dry-run', {
609
- type: 'boolean',
610
- description:
611
- 'Simulate operations without making actual API calls or package installations (useful for testing)',
612
- default: false,
613
- })
614
- .option('use-existing-claude-oauth', {
615
- type: 'boolean',
616
- description:
617
- 'Use existing Claude OAuth credentials from ~/.claude/.credentials.json (from Claude Code CLI)',
618
- default: false,
619
- })
620
- .option('prompt', {
621
- alias: 'p',
622
- type: 'string',
623
- description: 'Prompt message to send directly (bypasses stdin reading)',
624
- })
625
- .option('disable-stdin', {
626
- type: 'boolean',
627
- description:
628
- 'Disable stdin streaming mode (requires --prompt or shows help)',
629
- default: false,
630
- })
631
- .option('stdin-stream-timeout', {
632
- type: 'number',
633
- description:
634
- 'Optional timeout in milliseconds for stdin reading (default: no timeout)',
635
- })
636
- .option('auto-merge-queued-messages', {
637
- type: 'boolean',
638
- description:
639
- 'Enable auto-merging of rapidly arriving input lines into single messages (default: true)',
640
- default: true,
641
- })
642
- .option('interactive', {
643
- type: 'boolean',
644
- description:
645
- 'Enable interactive mode to accept manual input as plain text strings (default: true). Use --no-interactive to only accept JSON input.',
646
- default: true,
676
+ // Default command for agent mode (when no subcommand specified)
677
+ .command({
678
+ command: '$0',
679
+ describe: 'Run agent in interactive or stdin mode (default)',
680
+ builder: (yargs) =>
681
+ yargs
682
+ .option('model', {
683
+ type: 'string',
684
+ description: 'Model to use in format providerID/modelID',
685
+ default: 'opencode/grok-code',
686
+ })
687
+ .option('json-standard', {
688
+ type: 'string',
689
+ description:
690
+ 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
691
+ default: 'opencode',
692
+ choices: ['opencode', 'claude'],
693
+ })
694
+ .option('system-message', {
695
+ type: 'string',
696
+ description: 'Full override of the system message',
697
+ })
698
+ .option('system-message-file', {
699
+ type: 'string',
700
+ description: 'Full override of the system message from file',
701
+ })
702
+ .option('append-system-message', {
703
+ type: 'string',
704
+ description: 'Append to the default system message',
705
+ })
706
+ .option('append-system-message-file', {
707
+ type: 'string',
708
+ description: 'Append to the default system message from file',
709
+ })
710
+ .option('server', {
711
+ type: 'boolean',
712
+ description: 'Run in server mode (default)',
713
+ default: true,
714
+ })
715
+ .option('verbose', {
716
+ type: 'boolean',
717
+ description:
718
+ 'Enable verbose mode to debug API requests (shows system prompt, token counts, etc.)',
719
+ default: false,
720
+ })
721
+ .option('dry-run', {
722
+ type: 'boolean',
723
+ description:
724
+ 'Simulate operations without making actual API calls or package installations (useful for testing)',
725
+ default: false,
726
+ })
727
+ .option('use-existing-claude-oauth', {
728
+ type: 'boolean',
729
+ description:
730
+ 'Use existing Claude OAuth credentials from ~/.claude/.credentials.json (from Claude Code CLI)',
731
+ default: false,
732
+ })
733
+ .option('prompt', {
734
+ alias: 'p',
735
+ type: 'string',
736
+ description:
737
+ 'Prompt message to send directly (bypasses stdin reading)',
738
+ })
739
+ .option('disable-stdin', {
740
+ type: 'boolean',
741
+ description:
742
+ 'Disable stdin streaming mode (requires --prompt or shows help)',
743
+ default: false,
744
+ })
745
+ .option('stdin-stream-timeout', {
746
+ type: 'number',
747
+ description:
748
+ 'Optional timeout in milliseconds for stdin reading (default: no timeout)',
749
+ })
750
+ .option('auto-merge-queued-messages', {
751
+ type: 'boolean',
752
+ description:
753
+ 'Enable auto-merging of rapidly arriving input lines into single messages (default: true)',
754
+ default: true,
755
+ })
756
+ .option('interactive', {
757
+ type: 'boolean',
758
+ description:
759
+ 'Enable interactive mode to accept manual input as plain text strings (default: true). Use --no-interactive to only accept JSON input.',
760
+ default: true,
761
+ })
762
+ .option('always-accept-stdin', {
763
+ type: 'boolean',
764
+ description:
765
+ 'Keep accepting stdin input even after the agent finishes work (default: true). Use --no-always-accept-stdin for single-message mode.',
766
+ default: true,
767
+ })
768
+ .option('compact-json', {
769
+ type: 'boolean',
770
+ description:
771
+ 'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
772
+ default: false,
773
+ }),
774
+ handler: async (argv) => {
775
+ const compactJson = argv['compact-json'] === true;
776
+
777
+ // Check if --prompt flag was provided
778
+ if (argv.prompt) {
779
+ // Direct prompt mode - bypass stdin entirely
780
+ const request = { message: argv.prompt };
781
+ await runAgentMode(argv, request);
782
+ return;
783
+ }
784
+
785
+ // Check if --disable-stdin was set without --prompt
786
+ if (argv['disable-stdin']) {
787
+ // Output a helpful message suggesting to use --prompt
788
+ outputStatus(
789
+ {
790
+ type: 'error',
791
+ message:
792
+ 'No prompt provided. Use -p/--prompt to specify a message, or remove --disable-stdin to read from stdin.',
793
+ hint: 'Example: agent -p "Hello, how are you?"',
794
+ },
795
+ compactJson
796
+ );
797
+ process.exit(1);
798
+ }
799
+
800
+ // Check if stdin is a TTY (interactive terminal)
801
+ if (process.stdin.isTTY) {
802
+ // Enter interactive terminal mode with continuous listening
803
+ const isInteractive = argv.interactive !== false;
804
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
805
+ const alwaysAcceptStdin = argv['always-accept-stdin'] !== false;
806
+
807
+ // Exit if --no-always-accept-stdin is set (single message mode not supported in TTY)
808
+ if (!alwaysAcceptStdin) {
809
+ outputStatus(
810
+ {
811
+ type: 'error',
812
+ message:
813
+ 'Single message mode (--no-always-accept-stdin) is not supported in interactive terminal mode.',
814
+ hint: 'Use piped input or --prompt for single messages.',
815
+ },
816
+ compactJson
817
+ );
818
+ process.exit(1);
819
+ }
820
+
821
+ outputStatus(
822
+ {
823
+ type: 'status',
824
+ mode: 'interactive-terminal',
825
+ message:
826
+ 'Agent CLI in interactive terminal mode. Type your message and press Enter.',
827
+ hint: 'Press CTRL+C to exit. Use --help for options.',
828
+ acceptedFormats: isInteractive
829
+ ? ['JSON object with "message" field', 'Plain text']
830
+ : ['JSON object with "message" field'],
831
+ options: {
832
+ interactive: isInteractive,
833
+ autoMergeQueuedMessages: autoMerge,
834
+ alwaysAcceptStdin,
835
+ compactJson,
836
+ },
837
+ },
838
+ compactJson
839
+ );
840
+
841
+ // Use continuous mode for interactive terminal
842
+ await runContinuousAgentMode(argv);
843
+ return;
844
+ }
845
+
846
+ // stdin is piped - enter stdin listening mode
847
+ const isInteractive = argv.interactive !== false;
848
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
849
+ const alwaysAcceptStdin = argv['always-accept-stdin'] !== false;
850
+
851
+ outputStatus(
852
+ {
853
+ type: 'status',
854
+ mode: 'stdin-stream',
855
+ message: alwaysAcceptStdin
856
+ ? 'Agent CLI in continuous listening mode. Accepts JSON and plain text input.'
857
+ : 'Agent CLI in single-message mode. Accepts JSON and plain text input.',
858
+ hint: 'Press CTRL+C to exit. Use --help for options.',
859
+ acceptedFormats: isInteractive
860
+ ? ['JSON object with "message" field', 'Plain text']
861
+ : ['JSON object with "message" field'],
862
+ options: {
863
+ interactive: isInteractive,
864
+ autoMergeQueuedMessages: autoMerge,
865
+ alwaysAcceptStdin,
866
+ compactJson,
867
+ },
868
+ },
869
+ compactJson
870
+ );
871
+
872
+ // Use continuous mode if --always-accept-stdin is enabled (default)
873
+ if (alwaysAcceptStdin) {
874
+ await runContinuousAgentMode(argv);
875
+ return;
876
+ }
877
+
878
+ // Single-message mode (--no-always-accept-stdin)
879
+ const timeout = argv['stdin-stream-timeout'] ?? null;
880
+ const input = await readStdinWithTimeout(timeout);
881
+ const trimmedInput = input.trim();
882
+
883
+ if (trimmedInput === '') {
884
+ outputStatus(
885
+ {
886
+ type: 'status',
887
+ message: 'No input received. Exiting.',
888
+ },
889
+ compactJson
890
+ );
891
+ yargsInstance.showHelp();
892
+ process.exit(0);
893
+ }
894
+
895
+ // Try to parse as JSON, if it fails treat it as plain text message
896
+ let request;
897
+ try {
898
+ request = JSON.parse(trimmedInput);
899
+ } catch (_e) {
900
+ // Not JSON
901
+ if (!isInteractive) {
902
+ // In non-interactive mode, only accept JSON
903
+ outputStatus(
904
+ {
905
+ type: 'error',
906
+ message:
907
+ 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
908
+ hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
909
+ },
910
+ compactJson
911
+ );
912
+ process.exit(1);
913
+ }
914
+ // In interactive mode, treat as plain text message
915
+ request = {
916
+ message: trimmedInput,
917
+ };
918
+ }
919
+
920
+ // Run agent mode
921
+ await runAgentMode(argv, request);
922
+ },
647
923
  })
648
924
  // Initialize logging early for all CLI commands
649
925
  // This prevents debug output from appearing in CLI unless --verbose is used
@@ -695,99 +971,8 @@ async function main() {
695
971
  })
696
972
  .help();
697
973
 
698
- const argv = await yargsInstance.argv;
699
-
700
- // If a command was executed (like mcp), yargs handles it
701
- // Otherwise, check if we should run in agent mode (stdin piped)
702
- const commandExecuted = argv._ && argv._.length > 0;
703
-
704
- if (!commandExecuted) {
705
- // Check if --prompt flag was provided
706
- if (argv.prompt) {
707
- // Direct prompt mode - bypass stdin entirely
708
- const request = { message: argv.prompt };
709
- await runAgentMode(argv, request);
710
- return;
711
- }
712
-
713
- // Check if --disable-stdin was set without --prompt
714
- if (argv['disable-stdin']) {
715
- // Output a helpful message suggesting to use --prompt
716
- outputStatus({
717
- type: 'error',
718
- message:
719
- 'No prompt provided. Use -p/--prompt to specify a message, or remove --disable-stdin to read from stdin.',
720
- hint: 'Example: agent -p "Hello, how are you?"',
721
- });
722
- process.exit(1);
723
- }
724
-
725
- // Check if stdin is a TTY (interactive terminal)
726
- // If it is, show help instead of waiting for input
727
- if (process.stdin.isTTY) {
728
- yargsInstance.showHelp();
729
- process.exit(0);
730
- }
731
-
732
- // stdin is piped - enter stdin listening mode
733
- // Output status message to inform user what's happening
734
- const isInteractive = argv.interactive !== false;
735
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
736
-
737
- outputStatus({
738
- type: 'status',
739
- mode: 'stdin-stream',
740
- message:
741
- 'Agent CLI in stdin listening mode. Accepts JSON and plain text input.',
742
- hint: 'Press CTRL+C to exit. Use --help for options.',
743
- acceptedFormats: isInteractive
744
- ? ['JSON object with "message" field', 'Plain text']
745
- : ['JSON object with "message" field'],
746
- options: {
747
- interactive: isInteractive,
748
- autoMergeQueuedMessages: autoMerge,
749
- },
750
- });
751
-
752
- // Read stdin with optional timeout
753
- const timeout = argv['stdin-stream-timeout'] ?? null;
754
- const input = await readStdinWithTimeout(timeout);
755
- const trimmedInput = input.trim();
756
-
757
- if (trimmedInput === '') {
758
- outputStatus({
759
- type: 'status',
760
- message: 'No input received. Exiting.',
761
- });
762
- yargsInstance.showHelp();
763
- process.exit(0);
764
- }
765
-
766
- // Try to parse as JSON, if it fails treat it as plain text message
767
- let request;
768
- try {
769
- request = JSON.parse(trimmedInput);
770
- } catch (_e) {
771
- // Not JSON
772
- if (!isInteractive) {
773
- // In non-interactive mode, only accept JSON
774
- outputStatus({
775
- type: 'error',
776
- message:
777
- 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
778
- hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
779
- });
780
- process.exit(1);
781
- }
782
- // In interactive mode, treat as plain text message
783
- request = {
784
- message: trimmedInput,
785
- };
786
- }
787
-
788
- // Run agent mode
789
- await runAgentMode(argv, request);
790
- }
974
+ // Parse arguments (handlers will be called automatically)
975
+ await yargsInstance.argv;
791
976
  } catch (error) {
792
977
  hasError = true;
793
978
  console.error(