claude-yes 1.24.2 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseCliArgs } from './parseCliArgs';
3
+
4
+ describe('CLI argument parsing', () => {
5
+ it('should parse cli name from first positional argument', () => {
6
+ const result = parseCliArgs(['node', '/path/to/cli', 'claude']);
7
+
8
+ expect(result.cli).toBe('claude');
9
+ });
10
+
11
+ it('should parse prompt from --prompt flag', () => {
12
+ const result = parseCliArgs([
13
+ 'node',
14
+ '/path/to/cli',
15
+ '--prompt',
16
+ 'hello world',
17
+ 'claude',
18
+ ]);
19
+
20
+ expect(result.prompt).toBe('hello world');
21
+ });
22
+
23
+ it('should parse prompt from -- separator', () => {
24
+ const result = parseCliArgs([
25
+ 'node',
26
+ '/path/to/cli',
27
+ 'claude',
28
+ '--',
29
+ 'hello',
30
+ 'world',
31
+ ]);
32
+
33
+ expect(result.prompt).toBe('hello world');
34
+ });
35
+
36
+ it('should combine --prompt and -- prompt', () => {
37
+ const result = parseCliArgs([
38
+ 'node',
39
+ '/path/to/cli',
40
+ '--prompt',
41
+ 'part1',
42
+ 'claude',
43
+ '--',
44
+ 'part2',
45
+ ]);
46
+
47
+ expect(result.prompt).toBe('part1 part2');
48
+ });
49
+
50
+ it('should parse --idle flag', () => {
51
+ const result = parseCliArgs([
52
+ 'node',
53
+ '/path/to/cli',
54
+ '--idle',
55
+ '30s',
56
+ 'claude',
57
+ ]);
58
+
59
+ expect(result.exitOnIdle).toBe(30000);
60
+ });
61
+
62
+ it('should parse --exit-on-idle flag (deprecated)', () => {
63
+ const result = parseCliArgs([
64
+ 'node',
65
+ '/path/to/cli',
66
+ '--exit-on-idle',
67
+ '1m',
68
+ 'claude',
69
+ ]);
70
+
71
+ expect(result.exitOnIdle).toBe(60000);
72
+ });
73
+
74
+ it('should parse --robust flag', () => {
75
+ const result = parseCliArgs(['node', '/path/to/cli', '--robust', 'claude']);
76
+
77
+ expect(result.robust).toBe(true);
78
+ });
79
+
80
+ it('should parse --no-robust flag', () => {
81
+ const result = parseCliArgs([
82
+ 'node',
83
+ '/path/to/cli',
84
+ '--no-robust',
85
+ 'claude',
86
+ ]);
87
+
88
+ expect(result.robust).toBe(false);
89
+ });
90
+
91
+ it('should parse --queue flag', () => {
92
+ const result = parseCliArgs(['node', '/path/to/cli', '--queue', 'claude']);
93
+
94
+ expect(result.queue).toBe(true);
95
+ });
96
+
97
+ it('should parse --no-queue flag', () => {
98
+ const result = parseCliArgs([
99
+ 'node',
100
+ '/path/to/cli',
101
+ '--no-queue',
102
+ 'claude',
103
+ ]);
104
+
105
+ expect(result.queue).toBe(false);
106
+ });
107
+
108
+ it('should parse --logFile flag', () => {
109
+ const result = parseCliArgs([
110
+ 'node',
111
+ '/path/to/cli',
112
+ '--logFile',
113
+ './output.log',
114
+ 'claude',
115
+ ]);
116
+
117
+ expect(result.logFile).toBe('./output.log');
118
+ });
119
+
120
+ it('should parse --verbose flag', () => {
121
+ const result = parseCliArgs([
122
+ 'node',
123
+ '/path/to/cli',
124
+ '--verbose',
125
+ 'claude',
126
+ ]);
127
+
128
+ expect(result.verbose).toBe(true);
129
+ });
130
+
131
+ it('should pass through unknown CLI args to cliArgs', () => {
132
+ const result = parseCliArgs([
133
+ 'node',
134
+ '/path/to/cli',
135
+ 'claude',
136
+ '--unknown-flag',
137
+ 'value',
138
+ ]);
139
+
140
+ expect(result.cliArgs).toContain('--unknown-flag');
141
+ expect(result.cliArgs).toContain('value');
142
+ });
143
+
144
+ it('should separate cli-yes args from cli args before --', () => {
145
+ const result = parseCliArgs([
146
+ 'node',
147
+ '/path/to/cli',
148
+ '--robust',
149
+ 'claude',
150
+ '--claude-arg',
151
+ '--',
152
+ 'prompt',
153
+ ]);
154
+
155
+ expect(result.cli).toBe('claude');
156
+ expect(result.robust).toBe(true);
157
+ expect(result.cliArgs).toContain('--claude-arg');
158
+ expect(result.prompt).toBe('prompt');
159
+ });
160
+
161
+ it('should detect cli name from script name (claude-yes)', () => {
162
+ const result = parseCliArgs([
163
+ '/usr/bin/node',
164
+ '/usr/local/bin/claude-yes',
165
+ '--prompt',
166
+ 'test',
167
+ ]);
168
+
169
+ expect(result.cli).toBe('claude');
170
+ });
171
+
172
+ it('should detect cli name from script name (codex-yes)', () => {
173
+ const result = parseCliArgs([
174
+ '/usr/bin/node',
175
+ '/usr/local/bin/codex-yes',
176
+ '--prompt',
177
+ 'test',
178
+ ]);
179
+
180
+ expect(result.cli).toBe('codex');
181
+ });
182
+
183
+ it('should prefer script name over explicit cli argument', () => {
184
+ const result = parseCliArgs([
185
+ '/usr/bin/node',
186
+ '/usr/local/bin/claude-yes',
187
+ '--prompt',
188
+ 'test',
189
+ 'gemini',
190
+ ]);
191
+
192
+ // cliName (from script) takes precedence over positional arg
193
+ expect(result.cli).toBe('claude');
194
+ });
195
+
196
+ it('should handle empty cliArgs when no positional cli is provided', () => {
197
+ const result = parseCliArgs([
198
+ '/usr/bin/node',
199
+ '/usr/local/bin/claude-yes',
200
+ '--prompt',
201
+ 'prompt',
202
+ ]);
203
+
204
+ expect(result.cliArgs).toEqual([]);
205
+ expect(result.prompt).toBe('prompt');
206
+ });
207
+
208
+ it('should include all args when no -- separator is present', () => {
209
+ const result = parseCliArgs([
210
+ 'node',
211
+ '/path/to/cli',
212
+ 'claude',
213
+ '--some-flag',
214
+ '--another-flag',
215
+ ]);
216
+
217
+ expect(result.cliArgs).toContain('--some-flag');
218
+ expect(result.cliArgs).toContain('--another-flag');
219
+ });
220
+ });
@@ -0,0 +1,111 @@
1
+ import enhancedMs from 'enhanced-ms';
2
+ import yargs from 'yargs';
3
+ import { hideBin } from 'yargs/helpers';
4
+ import { SUPPORTED_CLIS } from '.';
5
+
6
+ /**
7
+ * Parse CLI arguments the same way cli.ts does
8
+ * This is a test helper that mirrors the parsing logic in cli.ts
9
+ */
10
+ export function parseCliArgs(argv: string[]) {
11
+ // Detect cli name from script name (same logic as cli.ts:10-14)
12
+ const cliName = ((e?: string) => {
13
+ if (e === 'cli' || e === 'cli.ts') return undefined;
14
+ return e;
15
+ })(argv[1]?.split('/').pop()?.split('-')[0]);
16
+
17
+ // Parse args with yargs (same logic as cli.ts:16-73)
18
+ const parsedArgv = yargs(hideBin(argv))
19
+ .usage('Usage: $0 [cli] [cli-yes args] [agent-cli args] [--] [prompts...]')
20
+ .example(
21
+ '$0 claude --idle=30s -- solve all todos in my codebase, commit one by one',
22
+ 'Run Claude with a 30 seconds idle timeout, and the prompt is everything after `--`',
23
+ )
24
+ .option('robust', {
25
+ type: 'boolean',
26
+ default: true,
27
+ description:
28
+ 're-spawn Claude with --continue if it crashes, only works for claude yet',
29
+ alias: 'r',
30
+ })
31
+ .option('logFile', {
32
+ type: 'string',
33
+ description: 'Rendered log file to write to.',
34
+ })
35
+ .option('prompt', {
36
+ type: 'string',
37
+ description: 'Prompt to send to Claude (also can be passed after --)',
38
+ alias: 'p',
39
+ })
40
+ .option('verbose', {
41
+ type: 'boolean',
42
+ description: 'Enable verbose logging, will emit ./agent-yes.log',
43
+ default: false,
44
+ })
45
+ .option('exit-on-idle', {
46
+ type: 'string',
47
+ description: 'Exit after a period of inactivity, e.g., "5s" or "1m"',
48
+ deprecated: 'use --idle instead',
49
+ default: '60s',
50
+ alias: 'e',
51
+ })
52
+ .option('idle', {
53
+ type: 'string',
54
+ description: 'Exit after a period of inactivity, e.g., "5s" or "1m"',
55
+ alias: 'i',
56
+ })
57
+ .option('queue', {
58
+ type: 'boolean',
59
+ description:
60
+ 'Queue Agent when spawning multiple agents in the same directory/repo, can be disabled with --no-queue',
61
+ default: true,
62
+ })
63
+ .positional('cli', {
64
+ describe:
65
+ 'The AI CLI to run, e.g., claude, codex, copilot, cursor, gemini',
66
+ type: 'string',
67
+ choices: SUPPORTED_CLIS,
68
+ demandOption: false,
69
+ default: cliName,
70
+ })
71
+ .help()
72
+ .version()
73
+ .parserConfiguration({
74
+ 'unknown-options-as-args': true,
75
+ 'halt-at-non-option': true,
76
+ })
77
+ .parseSync();
78
+
79
+ // Extract cli args and dash prompt (same logic as cli.ts:76-91)
80
+ const optionalIndex = (e: number) => (0 <= e ? e : undefined);
81
+ const rawArgs = argv.slice(2);
82
+ const cliArgIndex = optionalIndex(rawArgs.indexOf(String(parsedArgv._[0])));
83
+ const dashIndex = optionalIndex(rawArgs.indexOf('--'));
84
+
85
+ const cliArgsForSpawn = parsedArgv._[0]
86
+ ? rawArgs.slice(cliArgIndex ?? 0, dashIndex ?? undefined)
87
+ : [];
88
+ const dashPrompt: string | undefined = dashIndex
89
+ ? rawArgs.slice(dashIndex + 1).join(' ')
90
+ : undefined;
91
+
92
+ // Return the config object that would be passed to cliYes (same logic as cli.ts:99-121)
93
+ return {
94
+ cli: (cliName ||
95
+ parsedArgv.cli ||
96
+ parsedArgv._[0]
97
+ ?.toString()
98
+ ?.replace?.(/-yes$/, '')) as (typeof SUPPORTED_CLIS)[number],
99
+ cliArgs: cliArgsForSpawn,
100
+ prompt: [parsedArgv.prompt, dashPrompt].join(' ').trim() || undefined,
101
+ exitOnIdle: Number(
102
+ (parsedArgv.idle || parsedArgv.exitOnIdle)?.replace(/.*/, (e) =>
103
+ String(enhancedMs(e)),
104
+ ) || 0,
105
+ ),
106
+ queue: parsedArgv.queue,
107
+ robust: parsedArgv.robust,
108
+ logFile: parsedArgv.logFile,
109
+ verbose: parsedArgv.verbose,
110
+ };
111
+ }
package/ts/postbuild.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  import { execaCommand } from 'execa';
3
3
  import { copyFile } from 'fs/promises';
4
4
  import * as pkg from '../package.json';
5
- import { CLI_CONFIGURES } from '.';
5
+ import { CLIS_CONFIG } from '.';
6
6
 
7
7
  const src = 'dist/cli.js';
8
8
  await Promise.all(
9
- Object.keys(CLI_CONFIGURES).map(async (cli) => {
9
+ Object.keys(CLIS_CONFIG).map(async (cli) => {
10
10
  const dst = `dist/${cli}-yes.js`;
11
- if (!pkg.bin?.[cli as keyof typeof pkg.bin])
11
+ if (!(pkg.bin as Record<string, string>)?.[`${cli}-yes`]) {
12
+ console.log(`package.json Updated bin.${cli}-yes = ${dst}`);
12
13
  await execaCommand(`npm pkg set bin.${cli}-yes=${dst}`);
14
+ }
13
15
  await copyFile(src, dst);
14
- console.log(`Created ${dst}`);
16
+ console.log(`${dst} Updated`);
15
17
  }),
16
18
  );
@@ -133,14 +133,21 @@ describe('runningLock', () => {
133
133
  });
134
134
 
135
135
  it('should not have gitRoot for non-git directory', async () => {
136
- // /tmp is typically not a git repository
137
- await acquireLock('/tmp', 'Non-git task');
136
+ // Create a temporary directory outside of any git repo
137
+ const tempDir = path.join('/tmp', 'test-non-git-' + Date.now());
138
+ await mkdir(tempDir, { recursive: true });
138
139
 
139
- const lockData = await readLockFile();
140
- expect(lockData.tasks[0].gitRoot).toBeUndefined();
141
- expect(lockData.tasks[0].cwd).toBe('/tmp');
140
+ try {
141
+ await acquireLock(tempDir, 'Non-git task');
142
142
 
143
- await releaseLock();
143
+ const lockData = await readLockFile();
144
+ expect(lockData.tasks[0].gitRoot).toBeUndefined();
145
+ expect(lockData.tasks[0].cwd).toBe(path.resolve(tempDir));
146
+
147
+ await releaseLock();
148
+ } finally {
149
+ await rm(tempDir, { recursive: true, force: true });
150
+ }
144
151
  });
145
152
  });
146
153
 
@@ -247,31 +254,27 @@ describe('runningLock', () => {
247
254
 
248
255
  describe('concurrent access', () => {
249
256
  it('should handle multiple tasks from different processes', async () => {
250
- // Simulate multiple processes by using different PIDs in the lock file
257
+ // Acquire first task
251
258
  await acquireLock(TEST_DIR, 'Task 1');
252
259
 
253
- // Manually add another task with our PID + 1 (simulating another process)
254
- const lockData = await readLockFile();
255
- lockData.tasks.push({
256
- cwd: '/tmp',
257
- task: 'Task 2',
258
- pid: process.pid + 1,
259
- status: 'running',
260
- startedAt: Date.now(),
261
- lockedAt: Date.now(),
262
- });
263
- await writeFile(LOCK_FILE, JSON.stringify(lockData, null, 2));
260
+ // Verify the task exists
261
+ let lockData = await readLockFile();
262
+ expect(lockData.tasks).toHaveLength(1);
263
+ expect(lockData.tasks[0].task).toBe('Task 1');
264
264
 
265
- // Read and verify both tasks exist
266
- const updatedLockData = await readLockFile();
267
- expect(updatedLockData.tasks).toHaveLength(2);
265
+ // Acquire a second task with the same PID (should replace the first)
266
+ await acquireLock('/tmp', 'Task 2');
267
+
268
+ // Should have only one task (the latest one)
269
+ lockData = await readLockFile();
270
+ expect(lockData.tasks).toHaveLength(1);
271
+ expect(lockData.tasks[0].task).toBe('Task 2');
268
272
 
269
273
  await releaseLock();
270
274
 
271
- // After release, only the "other process" task should remain
275
+ // After release, no tasks should remain
272
276
  const finalLockData = await readLockFile();
273
- expect(finalLockData.tasks).toHaveLength(1);
274
- expect(finalLockData.tasks[0].pid).toBe(process.pid + 1);
277
+ expect(finalLockData.tasks).toHaveLength(0);
275
278
  });
276
279
 
277
280
  it('should not duplicate tasks with same PID', async () => {
@@ -411,13 +414,16 @@ describe('runningLock', () => {
411
414
  });
412
415
 
413
416
  it('should allow different directories without git repos', async () => {
414
- // Create lock for /tmp
417
+ // Test that when we already have a task, acquiring a new one replaces it
418
+ // (since both use the same PID)
419
+
420
+ // Create lock for /tmp manually
415
421
  const lock = {
416
422
  tasks: [
417
423
  {
418
424
  cwd: '/tmp',
419
425
  task: 'Tmp task',
420
- pid: process.pid + 1,
426
+ pid: process.pid,
421
427
  status: 'running' as const,
422
428
  startedAt: Date.now(),
423
429
  lockedAt: Date.now(),
@@ -426,12 +432,18 @@ describe('runningLock', () => {
426
432
  };
427
433
  await writeFile(LOCK_FILE, JSON.stringify(lock, null, 2));
428
434
 
429
- // Acquire lock for different directory
435
+ // Verify initial state
436
+ let lockData = await readLockFile();
437
+ expect(lockData.tasks).toHaveLength(1);
438
+ expect(lockData.tasks[0].task).toBe('Tmp task');
439
+
440
+ // Acquire lock for different directory (should replace the existing task)
430
441
  await acquireLock(TEST_DIR, 'Test task');
431
442
 
432
- // Both should coexist
433
- const lockData = await readLockFile();
434
- expect(lockData.tasks).toHaveLength(2);
443
+ // Should only have the new task
444
+ lockData = await readLockFile();
445
+ expect(lockData.tasks).toHaveLength(1);
446
+ expect(lockData.tasks[0].task).toBe('Test task');
435
447
 
436
448
  await releaseLock();
437
449
  });
@@ -470,7 +482,9 @@ async function cleanupLockFile() {
470
482
  async function readLockFile(): Promise<{ tasks: Task[] }> {
471
483
  try {
472
484
  const content = await readFile(LOCK_FILE, 'utf8');
473
- return JSON.parse(content);
485
+ const lockFile = JSON.parse(content);
486
+ // Don't clean stale locks in tests - we want to see the raw data
487
+ return lockFile;
474
488
  } catch {
475
489
  return { tasks: [] };
476
490
  }
package/ts/tryCatch.ts ADDED
@@ -0,0 +1,25 @@
1
+ // curried overload
2
+ export function catcher<F extends (...args: any[]) => any, R>(
3
+ catchFn: (error: unknown) => R,
4
+ ): (fn: F) => (...args: Parameters<F>) => ReturnType<F> | R;
5
+
6
+ // direct overload
7
+ export function catcher<F extends (...args: any[]) => any, R>(
8
+ catchFn: (error: unknown) => R,
9
+ fn: F,
10
+ ): (...args: Parameters<F>) => ReturnType<F> | R;
11
+
12
+ // implementation
13
+ export function catcher<F extends (...args: any[]) => any, R>(
14
+ catchFn: (error: unknown) => R,
15
+ fn?: F,
16
+ ) {
17
+ if (!fn) return (fn: F) => catcher(catchFn, fn) as any;
18
+ return (...args: Parameters<F>) => {
19
+ try {
20
+ return fn(...args);
21
+ } catch (error) {
22
+ return catchFn(error);
23
+ }
24
+ };
25
+ }
package/ts/utils.ts CHANGED
@@ -1,3 +1,23 @@
1
1
  export function sleepms(ms: number) {
2
2
  return new Promise((resolve) => setTimeout(resolve, ms));
3
3
  }
4
+ export function deepMixin<T>(target: T, source: DeepPartial<T>): T {
5
+ for (const key in source) {
6
+ if (
7
+ source[key] &&
8
+ typeof source[key] === 'object' &&
9
+ !Array.isArray(source[key])
10
+ ) {
11
+ if (!target[key] || typeof target[key] !== 'object') {
12
+ (target as any)[key] = {};
13
+ }
14
+ deepMixin(target[key], source[key] as any);
15
+ } else if (source[key] !== undefined) {
16
+ (target as any)[key] = source[key];
17
+ }
18
+ }
19
+ return target;
20
+ }
21
+ export type DeepPartial<T> = {
22
+ [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
23
+ };
package/ts/yesLog.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { appendFileSync, rmSync } from 'node:fs';
2
+ import tsaComposer from 'tsa-composer';
3
+ import { catcher } from './tryCatch';
4
+
5
+ let initial = true;
6
+
7
+ /**
8
+ * Log messages to agent-yes.log file
9
+ * Each message is appended as a new line
10
+ * The log file is cleared on the first call
11
+ *
12
+ * use only for debug, enabled when process.env.VERBOSE is set
13
+ */
14
+ export const yesLog = tsaComposer()(
15
+ catcher(
16
+ (error) => {
17
+ console.error('yesLog error:', error);
18
+ },
19
+ function yesLog(msg: string) {
20
+ // process.stdout.write(`${msg}\r`); // touch process to avoid "The process is not running a TTY." error
21
+ if (!process.env.VERBOSE) return; // no-op if not verbose
22
+ if (initial) rmSync('./agent-yes.log'); // ignore error if file doesn't exist
23
+ initial = false;
24
+ appendFileSync('./agent-yes.log', `${msg}\n`);
25
+ },
26
+ ),
27
+ );
package/ts/cli.test.ts DELETED
@@ -1,63 +0,0 @@
1
- import { exec } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { readFile, unlink } from 'node:fs/promises';
4
- import { execaCommand } from 'execa';
5
- import { fromStdio } from 'from-node-stream';
6
- import sflow from 'sflow';
7
- import { beforeAll, describe, expect, it } from 'vitest';
8
- import { IdleWaiter } from './idleWaiter';
9
- import { sleepms } from './utils';
10
-
11
- it('Write file with auto bypass prompts', async () => {
12
- const flagFile = './.cache/flag.json';
13
- await cleanup();
14
- async function cleanup() {
15
- await unlink(flagFile).catch(() => {});
16
- await unlink('./cli-rendered.log').catch(() => {});
17
- }
18
-
19
- const p = exec(
20
- `bunx tsx ./cli.ts --logFile=./cli-rendered.log --exit-on-idle=3s "just write {on: 1} into ./.cache/flag.json and wait"`,
21
- );
22
- const pExitCode = new Promise<number | null>((r) => p.once('exit', r));
23
-
24
- const tr = new TransformStream<string, string>();
25
- const w = tr.writable.getWriter();
26
-
27
- const exit = async () =>
28
- await sflow(['\r', '/exit', '\r', '\r'])
29
- .forEach(async (e) => {
30
- await sleepms(200);
31
- await w.write(e);
32
- })
33
- .run();
34
-
35
- // ping function to exit claude when idle
36
-
37
- const idleWaiter = new IdleWaiter();
38
- idleWaiter.wait(3000).then(() => exit());
39
-
40
- const output = await sflow(tr.readable)
41
- .by(fromStdio(p))
42
- .log()
43
- .forEach(() => idleWaiter.ping())
44
- .text();
45
-
46
- // expect the file exists
47
- expect(existsSync(flagFile)).toBe(true);
48
- // expect the output contains the file path
49
- expect(output).toContain(flagFile);
50
-
51
- // expect the file content to be 'on'
52
- expect(await new Response(await readFile(flagFile)).json()).toEqual({
53
- on: 1,
54
- });
55
-
56
- expect(await pExitCode).toBe(0); // expect the process to exit successfully
57
- expect(await readFile('./cli-rendered.log', 'utf8')).toBeTruthy();
58
-
59
- // clean
60
- await cleanup();
61
-
62
- // it usually takes 13s to run (10s for claude to solve this problem, 3s for idle watcher to exit)
63
- }, 30e3);