agent-yes 1.72.4 → 1.73.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/index.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
4
4
  import path from "path";
5
5
  import DIE from "phpdie";
6
6
  import sflow from "sflow";
7
- import { TerminalRenderStream } from "terminal-render";
7
+ import { XtermProxy } from "./xterm-proxy.ts";
8
8
  import {
9
9
  extractSessionId,
10
10
  getSessionForCwd,
@@ -16,7 +16,6 @@ import { acquireLock, releaseLock, shouldUseLock } from "./runningLock.ts";
16
16
  import { logger } from "./logger.ts";
17
17
  import { createFifoStream } from "./beta/fifo.ts";
18
18
  import { PidStore } from "./pidStore.ts";
19
- import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
20
19
  import { sendEnter, sendMessage } from "./core/messaging.ts";
21
20
  import {
22
21
  initializeLogPaths,
@@ -41,11 +40,16 @@ export type AgentCliConfig = {
41
40
  version?: string; // hint user for version command to check if installed
42
41
  binary?: string; // actual binary name if different from cli, e.g. cursor -> cursor-agent
43
42
  defaultArgs?: string[]; // function to ensure certain args are present
43
+ help?: string; // documentation/help URL for the CLI
44
+ bunx?: boolean; // metadata for bunx-based launches
45
+ systemPrompt?: string; // flag name for system prompt injection
46
+ system?: string; // system prompt content to inject
44
47
 
45
48
  // status detect, and actions
46
49
  ready?: RegExp[]; // regex matcher for stdin ready, or line index for gemini. Set to empty array [] to disable ready check entirely.
47
50
  fatal?: RegExp[]; // array of regex to match for fatal errors
48
51
  working?: RegExp[]; // regex matcher for working status
52
+ updateAvailable?: RegExp[]; // regex matcher for update available banners
49
53
  exitCommands?: string[]; // commands to exit the cli gracefully
50
54
  promptArg?: (string & {}) | "first-arg" | "last-arg"; // argument name to pass the prompt, e.g. --prompt, or first-arg for positional arg
51
55
 
@@ -121,7 +125,7 @@ export default async function agentYes({
121
125
  autoYes = true,
122
126
  idleAction,
123
127
  }: {
124
- cli: SUPPORTED_CLIS;
128
+ cli: keyof typeof CLIS_CONFIG;
125
129
  cliArgs?: string[];
126
130
  prompt?: string;
127
131
  robust?: boolean;
@@ -185,9 +189,14 @@ export default async function agentYes({
185
189
  process.stdin.setRawMode?.(true); // must be called any stdout/stdin usage
186
190
  if (verbose) logger.debug(`[stdin] Raw mode set, isRaw: ${(process.stdin as any).isRaw}`);
187
191
 
188
- const terminalStream = new TerminalRenderStream({ mode: "raw" });
189
- const terminalRender = terminalStream.getRenderer();
190
- const outputWriter = terminalStream.writable.getWriter();
192
+ // XtermProxy: headless xterm emulator that auto-responds to all terminal
193
+ // queries (DSR, DA, etc.) so the spawned CLI never blocks waiting for replies.
194
+ // writeToPty is set after shell spawn (see below).
195
+ let shellWrite: (data: string) => void = () => {};
196
+ const xtermProxy = new XtermProxy({
197
+ ...getTerminalDimensions(),
198
+ writeToPty: (data) => shellWrite(data),
199
+ });
191
200
 
192
201
  logger.debug(`Using ${ptyPackage} for pseudo terminal management.`);
193
202
 
@@ -329,11 +338,14 @@ export default async function agentYes({
329
338
  ptyOptions,
330
339
  });
331
340
 
341
+ // Wire up the xterm proxy to write back to the PTY
342
+ shellWrite = (data: string) => shell.write(data);
343
+
332
344
  // Attach data handler IMMEDIATELY after spawn to avoid losing early PTY output.
333
345
  // node-pty emits 'data' events eagerly — if no listener is attached, events are lost.
334
346
  function onData(data: string) {
335
347
  const currentPid = shell.pid;
336
- outputWriter.write(data);
348
+ xtermProxy.write(data);
337
349
  globalAgentRegistry.appendStdout(currentPid, data);
338
350
  }
339
351
  shell.onData(onData);
@@ -348,7 +360,7 @@ export default async function agentYes({
348
360
 
349
361
  // Initialize log paths (independent of registration)
350
362
  const logPaths = await initializeLogPaths(pidStore, shell.pid);
351
- setupDebugLogging(logPaths.debuggingLogsPath);
363
+ await setupDebugLogging(logPaths.debuggingLogsPath);
352
364
 
353
365
  // Create agent context
354
366
  const ctx = new AgentContext({
@@ -447,6 +459,7 @@ export default async function agentYes({
447
459
  env: ptyEnv,
448
460
  };
449
461
  shell = pty.spawn(bin!, args, restartPtyOptions);
462
+ shellWrite = (data: string) => shell.write(data);
450
463
  // Register process in pidStore (non-blocking)
451
464
  try {
452
465
  await pidStore.registerProcess({ pid: shell.pid, cli, args, prompt, cwd: workingDir });
@@ -548,6 +561,7 @@ export default async function agentYes({
548
561
  env: ptyEnv,
549
562
  };
550
563
  shell = pty.spawn(cli, restoreArgs, restorePtyOptions);
564
+ shellWrite = (data: string) => shell.write(data);
551
565
  // Register process in pidStore (non-blocking)
552
566
  try {
553
567
  await pidStore.registerProcess({
@@ -601,14 +615,15 @@ export default async function agentYes({
601
615
  return pendingExitCode.resolve(exitCode);
602
616
  });
603
617
 
604
- // when current tty resized, resize the pty too
618
+ // when current tty resized, resize both pty and xterm proxy
605
619
  process.stdout.on("resize", () => {
606
620
  const { cols, rows } = getTerminalDimensions();
607
621
  shell.resize(cols, rows);
622
+ xtermProxy.resize(cols, rows);
608
623
  });
609
624
 
610
625
  const isStillWorkingQ = () => {
611
- const rendered = terminalRender.tail(24).replace(/\s+/g, " ");
626
+ const rendered = xtermProxy.tail(24).replace(/\s+/g, " ");
612
627
  return conf.working?.some((rgx) => rgx.test(rendered));
613
628
  };
614
629
 
@@ -617,7 +632,7 @@ export default async function agentYes({
617
632
  let lastHeartbeatRendered = "";
618
633
  const heartbeatInterval = setInterval(async () => {
619
634
  try {
620
- const rendered = removeControlCharacters(terminalRender.tail(12));
635
+ const rendered = removeControlCharacters(xtermProxy.tail(12));
621
636
 
622
637
  // Skip if output hasn't changed since last heartbeat
623
638
  if (rendered === lastHeartbeatRendered) return;
@@ -895,7 +910,7 @@ export default async function agentYes({
895
910
  shell.write(data);
896
911
  },
897
912
  }),
898
- readable: terminalStream.readable,
913
+ readable: xtermProxy.readable,
899
914
  })
900
915
 
901
916
  .forEach(() => {
@@ -926,97 +941,72 @@ export default async function agentYes({
926
941
  // if (cli === "codex" && !process.stdin.isTTY) shell.write(`\u001b[1;1R`); // send cursor position response when stdin is not tty
927
942
 
928
943
  let lastRendered = "";
929
- return e
930
- .forEach((chunk) => {
931
- // NOTE: terminalRender is already updated by terminalStream.writable.write() in onData()
932
- // (terminal-render v1.5+ calls renderer.write() as a side effect of the writable).
933
- // Calling terminalRender.write(chunk) here again would double-process every chunk,
934
- // corrupting cursor state and breaking all pattern matching.
935
- // ============ HANDLE special control sequences
936
- // Handle Device Attributes query (DA) - ESC[c or ESC[0c
937
- // This must be handled regardless of TTY status
938
- if (chunk.includes("\u001b[c") || chunk.includes("\u001b[0c")) {
939
- // Respond shell with VT100 with Advanced Video Option
940
- shell.write("\u001b[?1;2c");
941
- if (verbose) {
942
- logger.debug("device|respond DA: VT100 with Advanced Video Option");
944
+ return (
945
+ e
946
+ // Terminal query responses (DA, DSR, etc.) are handled automatically
947
+ // by XtermProxy via @xterm/headless no ad-hoc interception needed.
948
+ .forEach(async (line, lineIndex) => {
949
+ // ============ respond on rendered screen
950
+ const rendered = xtermProxy.tail(24);
951
+ // Skip processing if output hasn't changed
952
+ if (rendered === lastRendered) return;
953
+ lastRendered = rendered;
954
+
955
+ logger.debug(`stdout|${line}`);
956
+
957
+ // ready matcher: if matched, mark stdin ready
958
+ if (conf.ready?.some((rx: RegExp) => line.match(rx))) {
959
+ logger.debug(`ready |${line}`);
960
+ if (cli === "gemini" && lineIndex <= 80) return; // gemini initial noise, only after many lines
961
+ ctx.stdinReady.ready();
962
+ ctx.stdinFirstReady.ready();
943
963
  }
944
- return;
945
- }
946
-
947
- // Only handle cursor position when stdin is not tty, because tty already handled this
948
- if (process.stdin.isTTY) return;
949
-
950
- // Handle cursor position request - ESC[6n
951
- if (!chunk.includes("\u001b[6n")) return;
952
-
953
- // xterm replies CSI row; column R if asked cursor position
954
- // https://en.wikipedia.org/wiki/ANSI_escape_code#:~:text=citation%20needed%5D-,xterm%20replies,-CSI%20row%C2%A0%3B
955
- const { col, row } = terminalRender.getCursorPosition();
956
- shell.write(`\u001b[${row};${col}R`); // reply cli when getting cursor position
957
-
958
- logger.debug(`cursor|respond position: row=${String(row)}, col=${String(col)}`);
959
- })
960
- .forEach(async (line, lineIndex) => {
961
- // ============ respond on rendered screen
962
- const rendered = terminalRender.tail(24);
963
- // Skip processing if output hasn't changed
964
- if (rendered === lastRendered) return;
965
-
966
- logger.debug(`stdout|${line}`);
967
-
968
- // ready matcher: if matched, mark stdin ready
969
- if (conf.ready?.some((rx: RegExp) => line.match(rx))) {
970
- logger.debug(`ready |${line}`);
971
- if (cli === "gemini" && lineIndex <= 80) return; // gemini initial noise, only after many lines
972
- ctx.stdinReady.ready();
973
- ctx.stdinFirstReady.ready();
974
- }
975
964
 
976
- // enter matchers: send Enter when any enter regex matches
977
- if (conf.enter?.some((rx: RegExp) => line.match(rx))) {
978
- logger.debug(`sendEnter matched|${line}`);
979
- return await sendEnter(ctx.messageContext, 400); // wait for idle for a short while and then send Enter
980
- }
965
+ // enter matchers: send Enter when any enter regex matches
966
+ if (conf.enter?.some((rx: RegExp) => line.match(rx))) {
967
+ logger.debug(`sendEnter matched|${line}`);
968
+ return await sendEnter(ctx.messageContext, 400); // wait for idle for a short while and then send Enter
969
+ }
981
970
 
982
- // typingRespond matcher: if matched, send the specified message
983
- const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter(
984
- ([_sendString, onThePatterns]) => onThePatterns.some((rx) => line.match(rx)),
985
- );
986
- const typingResponded =
987
- typeingRespondMatched.length &&
988
- (await sflow(typeingRespondMatched)
989
- .map(
990
- async ([sendString]) =>
991
- await sendMessage(ctx.messageContext, sendString, { waitForReady: false }),
992
- )
993
- .toCount());
994
- if (typingResponded) return;
995
-
996
- // fatal matchers: set isFatal flag when matched
997
- if (conf.fatal?.some((rx: RegExp) => line.match(rx))) {
998
- logger.debug(`fatal |${line}`);
999
- ctx.isFatal = true;
1000
- await exitAgent();
1001
- }
971
+ // typingRespond matcher: if matched, send the specified message
972
+ const typeingRespondMatched = Object.entries(conf.typingRespond ?? {}).filter(
973
+ ([_sendString, onThePatterns]) => onThePatterns.some((rx) => line.match(rx)),
974
+ );
975
+ const typingResponded =
976
+ typeingRespondMatched.length &&
977
+ (await sflow(typeingRespondMatched)
978
+ .map(
979
+ async ([sendString]) =>
980
+ await sendMessage(ctx.messageContext, sendString, { waitForReady: false }),
981
+ )
982
+ .toCount());
983
+ if (typingResponded) return;
984
+
985
+ // fatal matchers: set isFatal flag when matched
986
+ if (conf.fatal?.some((rx: RegExp) => line.match(rx))) {
987
+ logger.debug(`fatal |${line}`);
988
+ ctx.isFatal = true;
989
+ await exitAgent();
990
+ }
1002
991
 
1003
- // restartWithoutContinueArg matchers: set flag to restart without continue args
1004
- if (conf.restartWithoutContinueArg?.some((rx: RegExp) => line.match(rx))) {
1005
- logger.debug(`restart-without-continue|${line}`);
1006
- ctx.shouldRestartWithoutContinue = true;
1007
- ctx.isFatal = true; // also set fatal to trigger exit
1008
- await exitAgent();
1009
- }
992
+ // restartWithoutContinueArg matchers: set flag to restart without continue args
993
+ if (conf.restartWithoutContinueArg?.some((rx: RegExp) => line.match(rx))) {
994
+ logger.debug(`restart-without-continue|${line}`);
995
+ ctx.shouldRestartWithoutContinue = true;
996
+ ctx.isFatal = true; // also set fatal to trigger exit
997
+ await exitAgent();
998
+ }
1010
999
 
1011
- // session ID capture for codex
1012
- if (cli === "codex") {
1013
- const sessionId = extractSessionId(line);
1014
- if (sessionId) {
1015
- logger.debug(`session|captured session ID: ${sessionId}`);
1016
- await storeSessionForCwd(workingDir, sessionId);
1000
+ // session ID capture for codex
1001
+ if (cli === "codex") {
1002
+ const sessionId = extractSessionId(line);
1003
+ if (sessionId) {
1004
+ logger.debug(`session|captured session ID: ${sessionId}`);
1005
+ await storeSessionForCwd(workingDir, sessionId);
1006
+ }
1017
1007
  }
1018
- }
1019
- });
1008
+ })
1009
+ );
1020
1010
  })
1021
1011
 
1022
1012
  // auto-response
@@ -1043,7 +1033,7 @@ export default async function agentYes({
1043
1033
  .by(createTerminatorStream(pendingExitCode.promise))
1044
1034
  .to(fromWritable(process.stdout));
1045
1035
 
1046
- await saveLogFile(ctx.logPaths.logPath, terminalRender.render());
1036
+ await saveLogFile(ctx.logPaths.logPath, xtermProxy.render());
1047
1037
 
1048
1038
  // and then get its exitcode
1049
1039
  const exitCode = await pendingExitCode.promise;
@@ -1052,13 +1042,14 @@ export default async function agentYes({
1052
1042
  // Final pidStore cleanup
1053
1043
  await pidStore.close();
1054
1044
 
1055
- // Update task status.writable release lock
1056
- await outputWriter.close();
1045
+ // Capture final render before disposing xterm proxy
1046
+ const finalRender = xtermProxy.render();
1047
+ xtermProxy.dispose();
1057
1048
 
1058
1049
  // deprecated logFile option, we have logPath now, but keep for backward compatibility
1059
- await saveDeprecatedLogFile(logFile, terminalRender.render(), verbose);
1050
+ await saveDeprecatedLogFile(logFile, finalRender, verbose);
1060
1051
 
1061
- return { exitCode, logs: terminalRender.render() };
1052
+ return { exitCode, logs: finalRender };
1062
1053
 
1063
1054
  async function exitAgent() {
1064
1055
  ctx.robust = false; // disable robust to avoid auto restart
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+
3
+ // Reset module state between tests by re-importing fresh
4
+ describe("logger", () => {
5
+ it("queues messages before winston loads and flushes them", async () => {
6
+ const { logger, flushLogger } = await import("./logger.ts");
7
+ const logs: string[] = [];
8
+ logger.info("queued message");
9
+ await flushLogger();
10
+ // If we reach here without throwing, the lazy init succeeded
11
+ expect(true).toBe(true);
12
+ });
13
+
14
+ it("logs directly when already initialized", async () => {
15
+ const { logger } = await import("./logger.ts");
16
+ // Second call — _inner should be set, no queue
17
+ expect(() => logger.info("direct message")).not.toThrow();
18
+ });
19
+
20
+ it("addTransport adds a transport after init", async () => {
21
+ const { addTransport, flushLogger } = await import("./logger.ts");
22
+ await flushLogger(); // ensure initialized
23
+ const winston = (await import("winston")).default;
24
+ const transport = new winston.transports.Console({ silent: true });
25
+ await expect(addTransport(transport)).resolves.toBeUndefined();
26
+ });
27
+ });
package/ts/logger.ts CHANGED
@@ -1,21 +1,65 @@
1
- import winston from "winston";
1
+ type LogLevel = "error" | "warn" | "info" | "http" | "verbose" | "debug" | "silly";
2
+ type WinstonLike = Record<LogLevel, (msg: string, ...meta: unknown[]) => void>;
2
3
 
3
- // Configure Winston logger
4
- const logFormat = winston.format.combine(
5
- winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
6
- winston.format.printf(({ timestamp, level, message, ...meta }) => {
7
- const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
8
- return `${timestamp} [${level}]: ${message}${metaStr}`;
9
- }),
10
- );
4
+ let _inner: WinstonLike | null = null;
5
+ let _initPromise: Promise<void> | null = null;
6
+ const _queue: Array<{ level: LogLevel; msg: string; meta: unknown[] }> = [];
11
7
 
12
- export const logger = winston.createLogger({
13
- level: process.env.VERBOSE ? "debug" : "info",
14
- format: logFormat,
15
- transports: [
16
- new winston.transports.Console({
17
- format: winston.format.combine(winston.format.colorize(), logFormat),
18
- }),
19
- ],
20
- silent: false,
21
- });
8
+ function init(): Promise<void> {
9
+ if (_initPromise) return _initPromise;
10
+ _initPromise = import("winston").then(({ default: winston }) => {
11
+ const logFormat = winston.format.combine(
12
+ winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
13
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
14
+ const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
15
+ return `${timestamp} [${level}]: ${message}${metaStr}`;
16
+ }),
17
+ );
18
+ _inner = winston.createLogger({
19
+ level: process.env.VERBOSE ? "debug" : "info",
20
+ format: logFormat,
21
+ transports: [
22
+ new winston.transports.Console({
23
+ format: winston.format.combine(winston.format.colorize(), logFormat),
24
+ }),
25
+ ],
26
+ silent: false,
27
+ }) as unknown as WinstonLike;
28
+ for (const { level, msg, meta } of _queue.splice(0)) {
29
+ _inner[level](msg, ...meta);
30
+ }
31
+ });
32
+ return _initPromise;
33
+ }
34
+
35
+ function makeMethod(level: LogLevel) {
36
+ return (msg: string, ...meta: unknown[]) => {
37
+ if (_inner) {
38
+ _inner[level](msg, ...meta);
39
+ } else {
40
+ _queue.push({ level, msg, meta });
41
+ init().catch((e) => console.error("[logger] Failed to load winston:", e));
42
+ }
43
+ };
44
+ }
45
+
46
+ /** Wait for all queued log messages to be flushed. Call before process.exit when needed. */
47
+ export async function flushLogger(): Promise<void> {
48
+ await init();
49
+ }
50
+
51
+ /** Add a winston transport. Awaits logger initialization first. */
52
+ export async function addTransport(transport: unknown): Promise<void> {
53
+ await init();
54
+ (_inner as unknown as { add(t: unknown): void }).add(transport);
55
+ }
56
+
57
+ export const logger = {
58
+ error: makeMethod("error"),
59
+ warn: makeMethod("warn"),
60
+ info: makeMethod("info"),
61
+ http: makeMethod("http"),
62
+ verbose: makeMethod("verbose"),
63
+ debug: makeMethod("debug"),
64
+ silly: makeMethod("silly"),
65
+ };
@@ -333,4 +333,92 @@ describe("CLI argument parsing", () => {
333
333
  expect(result.cli).toBe("claude");
334
334
  expect(result.autoYes).toBe(true);
335
335
  });
336
+
337
+ it("should parse bare positional words as prompt (cy arg1 arg2)", () => {
338
+ const result = parseCliArgs(["node", "/usr/local/bin/cy", "arg1", "arg2"]);
339
+
340
+ expect(result.cli).toBe("claude");
341
+ expect(result.prompt).toBe("arg1 arg2");
342
+ expect(result.cliArgs).toEqual([]);
343
+ });
344
+
345
+ it("should parse bare positional words as prompt with explicit CLI name", () => {
346
+ const result = parseCliArgs(["node", "/path/to/cli", "claude", "fix", "this", "bug"]);
347
+
348
+ expect(result.cli).toBe("claude");
349
+ expect(result.prompt).toBe("fix this bug");
350
+ expect(result.cliArgs).toEqual([]);
351
+ });
352
+
353
+ it("should parse bare words as prompt with claude-yes script name", () => {
354
+ const result = parseCliArgs(["node", "/usr/local/bin/claude-yes", "solve", "all", "todos"]);
355
+
356
+ expect(result.cli).toBe("claude");
357
+ expect(result.prompt).toBe("solve all todos");
358
+ expect(result.cliArgs).toEqual([]);
359
+ });
360
+
361
+ it("should separate CLI flags from bare words as prompt", () => {
362
+ const result = parseCliArgs([
363
+ "node",
364
+ "/usr/local/bin/claude-yes",
365
+ "--some-flag",
366
+ "value",
367
+ "fix",
368
+ "the",
369
+ "bug",
370
+ ]);
371
+
372
+ expect(result.cli).toBe("claude");
373
+ expect(result.prompt).toBe("fix the bug");
374
+ expect(result.cliArgs).toContain("--some-flag");
375
+ expect(result.cliArgs).toContain("value");
376
+ });
377
+
378
+ it("should combine --prompt flag with bare positional words", () => {
379
+ const result = parseCliArgs([
380
+ "node",
381
+ "/usr/local/bin/claude-yes",
382
+ "--prompt",
383
+ "prefix:",
384
+ "solve",
385
+ "this",
386
+ ]);
387
+
388
+ expect(result.prompt).toBe("prefix: solve this");
389
+ });
390
+
391
+ it("should combine bare positional words with dash prompt", () => {
392
+ const result = parseCliArgs([
393
+ "node",
394
+ "/usr/local/bin/claude-yes",
395
+ "solve",
396
+ "--",
397
+ "this",
398
+ "too",
399
+ ]);
400
+
401
+ expect(result.prompt).toBe("solve this too");
402
+ });
403
+
404
+ it("should not pass --no-rust to the target CLI", () => {
405
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-rust"]);
406
+
407
+ expect(result.useRust).toBe(false);
408
+ expect(result.cliArgs).not.toContain("--no-rust");
409
+ });
410
+
411
+ it("should not pass --no-robust to the target CLI", () => {
412
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-robust"]);
413
+
414
+ expect(result.robust).toBe(false);
415
+ expect(result.cliArgs).not.toContain("--no-robust");
416
+ });
417
+
418
+ it("should not pass --no-queue to the target CLI", () => {
419
+ const result = parseCliArgs(["node", "/path/to/cy", "--no-queue"]);
420
+
421
+ expect(result.queue).toBe(false);
422
+ expect(result.cliArgs).not.toContain("--no-queue");
423
+ });
336
424
  });
@@ -1,12 +1,11 @@
1
1
  import ms from "ms";
2
2
  import yargs from "yargs";
3
3
  import { hideBin } from "yargs/helpers";
4
- import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
5
4
  /**
6
5
  * Parse CLI arguments the same way cli.ts does
7
6
  * This is a test helper that mirrors the parsing logic in cli.ts
8
7
  */
9
- export function parseCliArgs(argv: string[]) {
8
+ export function parseCliArgs(argv: string[], supportedClis?: readonly string[]) {
10
9
  // Detect cli name from script name (same logic as cli.ts:10-14)
11
10
  const scriptBaseName =
12
11
  argv[1]
@@ -171,7 +170,7 @@ export function parseCliArgs(argv: string[]) {
171
170
  .positional("cli", {
172
171
  describe: "The AI CLI to run, e.g., claude, codex, copilot, cursor, gemini",
173
172
  type: "string",
174
- choices: SUPPORTED_CLIS,
173
+ choices: supportedClis as string[] | undefined,
175
174
  demandOption: false,
176
175
  default: cliName,
177
176
  })
@@ -211,12 +210,33 @@ export function parseCliArgs(argv: string[]) {
211
210
  }
212
211
  });
213
212
 
213
+ // Collect bare positional words as prompt text (e.g., `cy arg1 arg2` → prompt = "arg1 arg2")
214
+ const positionalPromptWords: string[] = [];
215
+
214
216
  const cliArgsForSpawn = (() => {
215
217
  if (parsedArgv._[0] && !cliName) {
216
- // Explicit CLI name provided as positional arg
217
- return rawArgs.slice((cliArgIndex ?? 0) + 1, dashIndex ?? undefined);
218
+ // Explicit CLI name provided as positional arg — separate flags from bare words
219
+ const allAfterCli = rawArgs.slice((cliArgIndex ?? 0) + 1, dashIndex ?? undefined);
220
+ const result: string[] = [];
221
+ for (let i = 0; i < allAfterCli.length; i++) {
222
+ const arg = allAfterCli[i]!;
223
+ if (arg.startsWith("-")) {
224
+ result.push(arg);
225
+ // Consume the next arg as the flag's value if separate (--flag value)
226
+ if (!arg.includes("=") && i + 1 < allAfterCli.length) {
227
+ const nextArg = allAfterCli[i + 1];
228
+ if (nextArg && !nextArg.startsWith("-")) {
229
+ result.push(nextArg);
230
+ i++;
231
+ }
232
+ }
233
+ } else {
234
+ positionalPromptWords.push(arg);
235
+ }
236
+ }
237
+ return result;
218
238
  } else if (cliName) {
219
- // CLI name from script, filter out only what yargs consumed
239
+ // CLI name from script, filter out what yargs consumed; bare words become prompt
220
240
  const result: string[] = [];
221
241
  const argsToCheck = rawArgs.slice(0, dashIndex ?? undefined);
222
242
 
@@ -226,7 +246,11 @@ export function parseCliArgs(argv: string[]) {
226
246
 
227
247
  const [flag] = arg.split("=");
228
248
 
229
- if (flag && yargsConsumed.has(flag)) {
249
+ // Check both the flag itself and its --no- negation (yargs stores --no-x as key "x")
250
+ const isConsumed =
251
+ (flag && yargsConsumed.has(flag)) ||
252
+ (flag?.startsWith("--no-") && yargsConsumed.has(`--${flag.slice(5)}`));
253
+ if (isConsumed) {
230
254
  // Skip consumed flag and its value if separate
231
255
  if (!arg.includes("=") && i + 1 < argsToCheck.length) {
232
256
  const nextArg = argsToCheck[i + 1];
@@ -234,14 +258,27 @@ export function parseCliArgs(argv: string[]) {
234
258
  i++; // Skip value
235
259
  }
236
260
  }
237
- } else {
261
+ } else if (arg.startsWith("-")) {
262
+ // Non-consumed flag → pass to target CLI
238
263
  result.push(arg);
264
+ // Consume the next arg as the flag's value if separate
265
+ if (!arg.includes("=") && i + 1 < argsToCheck.length) {
266
+ const nextArg = argsToCheck[i + 1];
267
+ if (nextArg && !nextArg.startsWith("-")) {
268
+ result.push(nextArg);
269
+ i++;
270
+ }
271
+ }
272
+ } else {
273
+ // Bare word → treat as prompt text
274
+ positionalPromptWords.push(arg);
239
275
  }
240
276
  }
241
277
  return result;
242
278
  }
243
279
  return [];
244
280
  })();
281
+ const positionalPrompt = positionalPromptWords.join(" ") || undefined;
245
282
  const dashPrompt: string | undefined =
246
283
  dashIndex === undefined ? undefined : rawArgs.slice(dashIndex + 1).join(" ");
247
284
 
@@ -260,9 +297,10 @@ export function parseCliArgs(argv: string[]) {
260
297
  parsedArgv.cli ||
261
298
  (dashIndex !== 0
262
299
  ? parsedArgv._[0]?.toString()?.replace?.(/-yes$/, "")
263
- : undefined)) as (typeof SUPPORTED_CLIS)[number],
300
+ : undefined)) as string,
264
301
  cliArgs: [...cliArgsForSpawn, ...(parsedArgv.yes ? ["--dangerously-skip-permissions"] : [])],
265
- prompt: [parsedArgv.prompt, dashPrompt].filter(Boolean).join(" ") || undefined,
302
+ prompt:
303
+ [parsedArgv.prompt, positionalPrompt, dashPrompt].filter(Boolean).join(" ") || undefined,
266
304
  install: parsedArgv.install,
267
305
  exitOnIdle: Number(
268
306
  (parsedArgv.timeout || parsedArgv.idle || parsedArgv.exitOnIdle)?.replace(/.*/, (e) =>