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.
- package/README.md +136 -38
- package/dist/claude-yes.js +326 -175
- package/dist/cli.js +326 -175
- package/dist/cli.js.map +11 -5
- package/dist/codex-yes.js +326 -175
- package/dist/copilot-yes.js +326 -175
- package/dist/cursor-yes.js +326 -175
- package/dist/gemini-yes.js +326 -175
- package/dist/grok-yes.js +326 -175
- package/dist/index.js +226 -105
- package/dist/index.js.map +10 -5
- package/dist/qwen-yes.js +12102 -0
- package/package.json +39 -4
- package/ts/cli-idle.spec.ts +15 -12
- package/ts/cli.ts +15 -83
- package/ts/defineConfig.ts +12 -0
- package/ts/index.ts +166 -159
- package/ts/parseCliArgs.spec.ts +220 -0
- package/ts/parseCliArgs.ts +111 -0
- package/ts/postbuild.ts +6 -4
- package/ts/runningLock.spec.ts +45 -31
- package/ts/tryCatch.ts +25 -0
- package/ts/utils.ts +20 -0
- package/ts/yesLog.ts +27 -0
- package/ts/cli.test.ts +0 -63
|
@@ -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 {
|
|
5
|
+
import { CLIS_CONFIG } from '.';
|
|
6
6
|
|
|
7
7
|
const src = 'dist/cli.js';
|
|
8
8
|
await Promise.all(
|
|
9
|
-
Object.keys(
|
|
9
|
+
Object.keys(CLIS_CONFIG).map(async (cli) => {
|
|
10
10
|
const dst = `dist/${cli}-yes.js`;
|
|
11
|
-
if (!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(
|
|
16
|
+
console.log(`${dst} Updated`);
|
|
15
17
|
}),
|
|
16
18
|
);
|
package/ts/runningLock.spec.ts
CHANGED
|
@@ -133,14 +133,21 @@ describe('runningLock', () => {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it('should not have gitRoot for non-git directory', async () => {
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
expect(lockData.tasks[0].cwd).toBe('/tmp');
|
|
140
|
+
try {
|
|
141
|
+
await acquireLock(tempDir, 'Non-git task');
|
|
142
142
|
|
|
143
|
-
|
|
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
|
-
//
|
|
257
|
+
// Acquire first task
|
|
251
258
|
await acquireLock(TEST_DIR, 'Task 1');
|
|
252
259
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
lockData.tasks.
|
|
256
|
-
|
|
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
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
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,
|
|
275
|
+
// After release, no tasks should remain
|
|
272
276
|
const finalLockData = await readLockFile();
|
|
273
|
-
expect(finalLockData.tasks).toHaveLength(
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
433
|
-
|
|
434
|
-
expect(lockData.tasks).toHaveLength(
|
|
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
|
-
|
|
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);
|