tlc-claude-code 2.2.1 → 2.4.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/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +98 -24
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +50 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/CODING-STANDARDS.md +217 -10
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/setup.sh +271 -271
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Careful Patterns Module
|
|
3
|
+
*
|
|
4
|
+
* Destructive command detection patterns for /tlc:careful
|
|
5
|
+
* and path scope checking for /tlc:freeze.
|
|
6
|
+
*
|
|
7
|
+
* @module careful-patterns
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Destructive command patterns with their metadata.
|
|
14
|
+
* Each entry: { regex, reason, pattern }
|
|
15
|
+
* @type {Array<{regex: RegExp, reason: string, pattern: string}>}
|
|
16
|
+
*/
|
|
17
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
18
|
+
{
|
|
19
|
+
regex: /\bgit\s+push\s+(?:.*\s+)?--force\b/,
|
|
20
|
+
reason: 'Force push',
|
|
21
|
+
pattern: 'git push --force',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
regex: /\bgit\s+push\s+(?:.*\s+)?-f\b/,
|
|
25
|
+
reason: 'Force push',
|
|
26
|
+
pattern: 'git push -f',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
regex: /\bgit\s+reset\s+--hard\b/,
|
|
30
|
+
reason: 'Hard reset',
|
|
31
|
+
pattern: 'git reset --hard',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
regex: /\brm\s+-rf\b/,
|
|
35
|
+
reason: 'Recursive force delete',
|
|
36
|
+
pattern: 'rm -rf',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
regex: /\bdrop\s+table\b/i,
|
|
40
|
+
reason: 'Drop table',
|
|
41
|
+
pattern: 'DROP TABLE',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
regex: /\bdrop\s+database\b/i,
|
|
45
|
+
reason: 'Drop database',
|
|
46
|
+
pattern: 'DROP DATABASE',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
regex: /\btruncate\s+table\b/i,
|
|
50
|
+
reason: 'Truncate table',
|
|
51
|
+
pattern: 'TRUNCATE TABLE',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
regex: /\bgit\s+clean\s+-f/,
|
|
55
|
+
reason: 'Clean untracked files',
|
|
56
|
+
pattern: 'git clean -f',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a shell command matches destructive patterns.
|
|
62
|
+
*
|
|
63
|
+
* Checks against known dangerous operations like force push,
|
|
64
|
+
* hard reset, recursive delete, and destructive SQL statements.
|
|
65
|
+
* SQL patterns are matched case-insensitively.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} command - Shell command string to check
|
|
68
|
+
* @returns {{ destructive: boolean, reason?: string, pattern?: string }}
|
|
69
|
+
*/
|
|
70
|
+
function isDestructive(command) {
|
|
71
|
+
if (!command || typeof command !== 'string') {
|
|
72
|
+
return { destructive: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check DELETE FROM without WHERE (special case: only destructive without WHERE)
|
|
76
|
+
// Don't early-return on safe DELETE — continue checking for other destructive patterns
|
|
77
|
+
const deleteMatch = command.match(/\bdelete\s+from\b/i);
|
|
78
|
+
if (deleteMatch) {
|
|
79
|
+
const hasWhere = /\bwhere\b/i.test(command);
|
|
80
|
+
if (!hasWhere) {
|
|
81
|
+
return {
|
|
82
|
+
destructive: true,
|
|
83
|
+
reason: 'Delete without WHERE',
|
|
84
|
+
pattern: 'DELETE FROM',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// DELETE FROM ... WHERE is safe, but continue scanning for other patterns
|
|
88
|
+
// (e.g., "DELETE FROM users WHERE id=1; DROP TABLE sessions")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check all other patterns
|
|
92
|
+
for (const entry of DESTRUCTIVE_PATTERNS) {
|
|
93
|
+
if (entry.regex.test(command)) {
|
|
94
|
+
return {
|
|
95
|
+
destructive: true,
|
|
96
|
+
reason: entry.reason,
|
|
97
|
+
pattern: entry.pattern,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { destructive: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if a file path is within an allowed scope directory.
|
|
107
|
+
*
|
|
108
|
+
* Resolves parent traversal (../) before checking containment.
|
|
109
|
+
* Both paths are normalized to prevent bypass via trailing slashes
|
|
110
|
+
* or prefix collisions (e.g., src/auth vs src/authorization).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} filePath - File path to check
|
|
113
|
+
* @param {string} scopeDir - Allowed scope directory
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function isInScope(filePath, scopeDir) {
|
|
117
|
+
if (!filePath || !scopeDir) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (typeof filePath !== 'string' || typeof scopeDir !== 'string') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normalize both paths to resolve ../ and remove trailing slashes
|
|
125
|
+
const normalizedFile = path.normalize(filePath);
|
|
126
|
+
const normalizedScope = path.normalize(scopeDir);
|
|
127
|
+
|
|
128
|
+
// Exact match
|
|
129
|
+
if (normalizedFile === normalizedScope) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// File must be under scope directory (with path separator to prevent prefix collisions)
|
|
134
|
+
const scopePrefix = normalizedScope.endsWith(path.sep)
|
|
135
|
+
? normalizedScope
|
|
136
|
+
: normalizedScope + path.sep;
|
|
137
|
+
|
|
138
|
+
return normalizedFile.startsWith(scopePrefix);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
module.exports = { isDestructive, isInScope };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Careful Patterns Tests - Phase 89 Task 6
|
|
3
|
+
*
|
|
4
|
+
* Destructive command detection and path scope checking
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { isDestructive, isInScope } from './careful-patterns.js';
|
|
10
|
+
|
|
11
|
+
describe('careful-patterns', () => {
|
|
12
|
+
describe('isDestructive', () => {
|
|
13
|
+
it('detects git push --force as destructive', () => {
|
|
14
|
+
const result = isDestructive('git push --force origin main');
|
|
15
|
+
expect(result.destructive).toBe(true);
|
|
16
|
+
expect(result.reason).toBe('Force push');
|
|
17
|
+
expect(result.pattern).toBe('git push --force');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('detects git push -f as destructive', () => {
|
|
21
|
+
const result = isDestructive('git push -f origin main');
|
|
22
|
+
expect(result.destructive).toBe(true);
|
|
23
|
+
expect(result.reason).toBe('Force push');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('allows normal git push', () => {
|
|
27
|
+
const result = isDestructive('git push origin feature');
|
|
28
|
+
expect(result.destructive).toBe(false);
|
|
29
|
+
expect(result.reason).toBeUndefined();
|
|
30
|
+
expect(result.pattern).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('detects git reset --hard as destructive', () => {
|
|
34
|
+
const result = isDestructive('git reset --hard HEAD~3');
|
|
35
|
+
expect(result.destructive).toBe(true);
|
|
36
|
+
expect(result.reason).toBe('Hard reset');
|
|
37
|
+
expect(result.pattern).toBe('git reset --hard');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('allows git reset --soft', () => {
|
|
41
|
+
const result = isDestructive('git reset --soft HEAD~1');
|
|
42
|
+
expect(result.destructive).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('detects rm -rf as destructive', () => {
|
|
46
|
+
const result = isDestructive('rm -rf /');
|
|
47
|
+
expect(result.destructive).toBe(true);
|
|
48
|
+
expect(result.reason).toBe('Recursive force delete');
|
|
49
|
+
expect(result.pattern).toBe('rm -rf');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('allows simple rm', () => {
|
|
53
|
+
const result = isDestructive('rm file.txt');
|
|
54
|
+
expect(result.destructive).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('detects DROP TABLE as destructive', () => {
|
|
58
|
+
const result = isDestructive('DROP TABLE users');
|
|
59
|
+
expect(result.destructive).toBe(true);
|
|
60
|
+
expect(result.reason).toBe('Drop table');
|
|
61
|
+
expect(result.pattern).toMatch(/drop table/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('detects DELETE FROM without WHERE as destructive', () => {
|
|
65
|
+
const result = isDestructive('DELETE FROM users');
|
|
66
|
+
expect(result.destructive).toBe(true);
|
|
67
|
+
expect(result.reason).toBe('Delete without WHERE');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('allows DELETE FROM with WHERE', () => {
|
|
71
|
+
const result = isDestructive('DELETE FROM users WHERE id = 1');
|
|
72
|
+
expect(result.destructive).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('detects DROP after safe DELETE in multi-statement command', () => {
|
|
76
|
+
const result = isDestructive('DELETE FROM users WHERE id = 1; DROP TABLE sessions');
|
|
77
|
+
expect(result.destructive).toBe(true);
|
|
78
|
+
expect(result.reason).toBe('Drop table');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('detects TRUNCATE TABLE as destructive', () => {
|
|
82
|
+
const result = isDestructive('TRUNCATE TABLE sessions');
|
|
83
|
+
expect(result.destructive).toBe(true);
|
|
84
|
+
expect(result.reason).toBe('Truncate table');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('detects git clean -fd as destructive', () => {
|
|
88
|
+
const result = isDestructive('git clean -fd');
|
|
89
|
+
expect(result.destructive).toBe(true);
|
|
90
|
+
expect(result.reason).toBe('Clean untracked files');
|
|
91
|
+
expect(result.pattern).toBe('git clean -f');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('allows npm install', () => {
|
|
95
|
+
const result = isDestructive('npm install express');
|
|
96
|
+
expect(result.destructive).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('detects DROP DATABASE as destructive', () => {
|
|
100
|
+
const result = isDestructive('DROP DATABASE production');
|
|
101
|
+
expect(result.destructive).toBe(true);
|
|
102
|
+
expect(result.reason).toBe('Drop database');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles SQL commands case-insensitively', () => {
|
|
106
|
+
expect(isDestructive('drop table users').destructive).toBe(true);
|
|
107
|
+
expect(isDestructive('Drop Table users').destructive).toBe(true);
|
|
108
|
+
expect(isDestructive('DELETE from users').destructive).toBe(true);
|
|
109
|
+
expect(isDestructive('truncate TABLE sessions').destructive).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns destructive:false for empty string', () => {
|
|
113
|
+
const result = isDestructive('');
|
|
114
|
+
expect(result.destructive).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns destructive:false for null/undefined', () => {
|
|
118
|
+
expect(isDestructive(null).destructive).toBe(false);
|
|
119
|
+
expect(isDestructive(undefined).destructive).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('isInScope', () => {
|
|
124
|
+
it('returns true for file within scope directory', () => {
|
|
125
|
+
expect(isInScope('src/auth/login.ts', 'src/auth')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns false for file outside scope directory', () => {
|
|
129
|
+
expect(isInScope('src/user/user.ts', 'src/auth')).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns false for parent traversal attempts', () => {
|
|
133
|
+
expect(isInScope('src/auth/../user/user.ts', 'src/auth')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns true for deeply nested files within scope', () => {
|
|
137
|
+
expect(isInScope('src/auth/deep/nested/file.ts', 'src/auth')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns true for exact directory match', () => {
|
|
141
|
+
expect(isInScope('src/auth', 'src/auth')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns false when file path starts with scope but is a different dir', () => {
|
|
145
|
+
// src/authorization is not inside src/auth
|
|
146
|
+
expect(isInScope('src/authorization/file.ts', 'src/auth')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles trailing slashes consistently', () => {
|
|
150
|
+
expect(isInScope('src/auth/file.ts', 'src/auth/')).toBe(true);
|
|
151
|
+
expect(isInScope('src/auth/file.ts', 'src/auth')).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns false for empty inputs', () => {
|
|
155
|
+
expect(isInScope('', 'src/auth')).toBe(false);
|
|
156
|
+
expect(isInScope('src/auth/file.ts', '')).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns false for null/undefined inputs', () => {
|
|
160
|
+
expect(isInScope(null, 'src/auth')).toBe(false);
|
|
161
|
+
expect(isInScope('src/auth/file.ts', null)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Dispatcher
|
|
3
|
+
*
|
|
4
|
+
* Generic dispatcher for shelling out to any CLI-based LLM provider.
|
|
5
|
+
* Spawns the process, pipes the prompt via stdin, captures stdout/stderr,
|
|
6
|
+
* handles timeouts.
|
|
7
|
+
*
|
|
8
|
+
* @module cli-dispatcher
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dispatch a command to a CLI process.
|
|
13
|
+
* @param {Object} opts - Dispatch options
|
|
14
|
+
* @param {string} opts.command - CLI executable name (e.g., "codex", "gemini")
|
|
15
|
+
* @param {string[]} opts.args - CLI arguments
|
|
16
|
+
* @param {string} opts.prompt - Prompt text to pipe via stdin
|
|
17
|
+
* @param {number} [opts.timeout=120000] - Timeout in ms
|
|
18
|
+
* @param {string} [opts.cwd] - Working directory
|
|
19
|
+
* @param {Function} opts.spawn - Injected child_process.spawn
|
|
20
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number, duration: number}>}
|
|
21
|
+
*/
|
|
22
|
+
function dispatch({ command, args = [], prompt = '', timeout = 120000, cwd, spawn = require('child_process').spawn }) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const start = Date.now();
|
|
25
|
+
|
|
26
|
+
const spawnOpts = {};
|
|
27
|
+
if (cwd) spawnOpts.cwd = cwd;
|
|
28
|
+
|
|
29
|
+
const proc = spawn(command, args, spawnOpts);
|
|
30
|
+
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
let settled = false;
|
|
34
|
+
|
|
35
|
+
const finish = (exitCode, stderrOverride) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve({
|
|
40
|
+
stdout,
|
|
41
|
+
stderr: stderrOverride !== undefined ? stderrOverride : stderr,
|
|
42
|
+
exitCode,
|
|
43
|
+
duration: Date.now() - start,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
proc.kill();
|
|
49
|
+
finish(-1, 'Process timed out');
|
|
50
|
+
}, timeout);
|
|
51
|
+
|
|
52
|
+
proc.stdout.on('data', (data) => {
|
|
53
|
+
stdout += data.toString();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
proc.stderr.on('data', (data) => {
|
|
57
|
+
stderr += data.toString();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
proc.on('close', (code) => {
|
|
61
|
+
finish(code);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
proc.on('error', (err) => {
|
|
65
|
+
finish(-1, err.message || 'Failed to spawn process');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Suppress broken pipe if child exits before stdin is consumed
|
|
69
|
+
proc.stdin.on('error', () => {});
|
|
70
|
+
|
|
71
|
+
// Write prompt to stdin then close
|
|
72
|
+
if (prompt) {
|
|
73
|
+
proc.stdin.write(prompt);
|
|
74
|
+
}
|
|
75
|
+
proc.stdin.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build command and args from a provider config object.
|
|
81
|
+
* @param {Object} provider - Provider config from model_providers
|
|
82
|
+
* @param {string} provider.type - Provider type ("cli", "inline", etc.)
|
|
83
|
+
* @param {string} provider.command - CLI executable name
|
|
84
|
+
* @param {string[]} [provider.flags] - CLI flags
|
|
85
|
+
* @returns {{command: string, args: string[]}|null} Command spec, or null for non-CLI types
|
|
86
|
+
*/
|
|
87
|
+
function buildProviderCommand(provider) {
|
|
88
|
+
if (provider.type !== 'cli') {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
command: provider.command,
|
|
94
|
+
args: provider.flags || [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { dispatch, buildProviderCommand };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { dispatch, buildProviderCommand } from './cli-dispatcher.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a mock spawn function that simulates child_process.spawn.
|
|
6
|
+
* @param {Object} opts - Mock behavior
|
|
7
|
+
* @param {string} [opts.stdout=''] - Data to emit on stdout
|
|
8
|
+
* @param {string} [opts.stderr=''] - Data to emit on stderr
|
|
9
|
+
* @param {number} [opts.exitCode=0] - Exit code to emit on close
|
|
10
|
+
* @param {number} [opts.delay=0] - Delay in ms before emitting close
|
|
11
|
+
* @returns {Function} Mock spawn function
|
|
12
|
+
*/
|
|
13
|
+
function createMockSpawn({ stdout = '', stderr = '', exitCode = 0, delay = 0 } = {}) {
|
|
14
|
+
return vi.fn(() => {
|
|
15
|
+
const listeners = {};
|
|
16
|
+
const stdinChunks = [];
|
|
17
|
+
const proc = {
|
|
18
|
+
stdin: {
|
|
19
|
+
write(data) { stdinChunks.push(data); },
|
|
20
|
+
end() { stdinChunks.push(null); },
|
|
21
|
+
on() {},
|
|
22
|
+
},
|
|
23
|
+
stdout: { on(event, cb) { listeners[`stdout:${event}`] = cb; } },
|
|
24
|
+
stderr: { on(event, cb) { listeners[`stderr:${event}`] = cb; } },
|
|
25
|
+
on(event, cb) { listeners[event] = cb; },
|
|
26
|
+
kill() { listeners._killed = true; },
|
|
27
|
+
_stdinChunks: stdinChunks,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Emit data and close asynchronously
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
if (stdout) listeners['stdout:data']?.(Buffer.from(stdout));
|
|
33
|
+
if (stderr) listeners['stderr:data']?.(Buffer.from(stderr));
|
|
34
|
+
if (!listeners._killed) {
|
|
35
|
+
listeners.close?.(exitCode);
|
|
36
|
+
}
|
|
37
|
+
}, delay);
|
|
38
|
+
|
|
39
|
+
return proc;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('cli-dispatcher', () => {
|
|
44
|
+
describe('dispatch', () => {
|
|
45
|
+
it('dispatches command and captures stdout', async () => {
|
|
46
|
+
const mockSpawn = createMockSpawn({ stdout: 'hello world' });
|
|
47
|
+
|
|
48
|
+
const result = await dispatch({
|
|
49
|
+
command: 'echo',
|
|
50
|
+
args: ['hello'],
|
|
51
|
+
prompt: '',
|
|
52
|
+
spawn: mockSpawn,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.stdout).toBe('hello world');
|
|
56
|
+
expect(result.exitCode).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('captures stderr separately', async () => {
|
|
60
|
+
const mockSpawn = createMockSpawn({ stdout: 'out', stderr: 'err' });
|
|
61
|
+
|
|
62
|
+
const result = await dispatch({
|
|
63
|
+
command: 'test-cmd',
|
|
64
|
+
args: [],
|
|
65
|
+
prompt: '',
|
|
66
|
+
spawn: mockSpawn,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.stdout).toBe('out');
|
|
70
|
+
expect(result.stderr).toBe('err');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('pipes prompt to stdin', async () => {
|
|
74
|
+
const mockSpawn = createMockSpawn({ stdout: 'response' });
|
|
75
|
+
|
|
76
|
+
await dispatch({
|
|
77
|
+
command: 'codex',
|
|
78
|
+
args: ['--quiet'],
|
|
79
|
+
prompt: 'Review this code',
|
|
80
|
+
spawn: mockSpawn,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const proc = mockSpawn.mock.results[0].value;
|
|
84
|
+
expect(proc._stdinChunks).toContain('Review this code');
|
|
85
|
+
// stdin should be ended (null sentinel)
|
|
86
|
+
expect(proc._stdinChunks).toContain(null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns exit code from process', async () => {
|
|
90
|
+
const mockSpawn = createMockSpawn({ exitCode: 42 });
|
|
91
|
+
|
|
92
|
+
const result = await dispatch({
|
|
93
|
+
command: 'failing-cmd',
|
|
94
|
+
args: [],
|
|
95
|
+
prompt: '',
|
|
96
|
+
spawn: mockSpawn,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.exitCode).toBe(42);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('timeout kills process and returns error result', async () => {
|
|
103
|
+
const mockSpawn = createMockSpawn({ stdout: 'slow', delay: 5000 });
|
|
104
|
+
|
|
105
|
+
const result = await dispatch({
|
|
106
|
+
command: 'slow-cmd',
|
|
107
|
+
args: [],
|
|
108
|
+
prompt: '',
|
|
109
|
+
timeout: 50,
|
|
110
|
+
spawn: mockSpawn,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.exitCode).toBe(-1);
|
|
114
|
+
expect(result.stderr).toBe('Process timed out');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('duration measured in milliseconds', async () => {
|
|
118
|
+
const mockSpawn = createMockSpawn({ stdout: 'fast', delay: 10 });
|
|
119
|
+
|
|
120
|
+
const result = await dispatch({
|
|
121
|
+
command: 'fast-cmd',
|
|
122
|
+
args: [],
|
|
123
|
+
prompt: '',
|
|
124
|
+
spawn: mockSpawn,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.duration).toBeTypeOf('number');
|
|
128
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('non-zero exit code captured not thrown', async () => {
|
|
132
|
+
const mockSpawn = createMockSpawn({ exitCode: 1, stderr: 'command failed' });
|
|
133
|
+
|
|
134
|
+
const result = await dispatch({
|
|
135
|
+
command: 'bad-cmd',
|
|
136
|
+
args: [],
|
|
137
|
+
prompt: '',
|
|
138
|
+
spawn: mockSpawn,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Should NOT throw
|
|
142
|
+
expect(result.exitCode).toBe(1);
|
|
143
|
+
expect(result.stderr).toBe('command failed');
|
|
144
|
+
expect(result.stdout).toBe('');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('empty prompt still works and closes stdin immediately', async () => {
|
|
148
|
+
const mockSpawn = createMockSpawn({ stdout: 'ok' });
|
|
149
|
+
|
|
150
|
+
const result = await dispatch({
|
|
151
|
+
command: 'cmd',
|
|
152
|
+
args: [],
|
|
153
|
+
prompt: '',
|
|
154
|
+
spawn: mockSpawn,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.stdout).toBe('ok');
|
|
158
|
+
expect(result.exitCode).toBe(0);
|
|
159
|
+
|
|
160
|
+
// stdin.end should have been called (null sentinel present)
|
|
161
|
+
const proc = mockSpawn.mock.results[0].value;
|
|
162
|
+
expect(proc._stdinChunks).toContain(null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles spawn error event (e.g., ENOENT) without hanging', async () => {
|
|
166
|
+
const mockSpawn = vi.fn(() => {
|
|
167
|
+
const listeners = {};
|
|
168
|
+
const proc = {
|
|
169
|
+
stdin: { write() {}, end() {}, on() {} },
|
|
170
|
+
stdout: { on(event, cb) { listeners[`stdout:${event}`] = cb; } },
|
|
171
|
+
stderr: { on(event, cb) { listeners[`stderr:${event}`] = cb; } },
|
|
172
|
+
on(event, cb) { listeners[event] = cb; },
|
|
173
|
+
kill() {},
|
|
174
|
+
};
|
|
175
|
+
// Emit error asynchronously (simulates ENOENT)
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
listeners.error?.(new Error('spawn codex ENOENT'));
|
|
178
|
+
}, 1);
|
|
179
|
+
return proc;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const result = await dispatch({
|
|
183
|
+
command: 'codex',
|
|
184
|
+
args: [],
|
|
185
|
+
prompt: 'test',
|
|
186
|
+
timeout: 5000,
|
|
187
|
+
spawn: mockSpawn,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result.exitCode).toBe(-1);
|
|
191
|
+
expect(result.stderr).toContain('ENOENT');
|
|
192
|
+
expect(result.duration).toBeLessThan(1000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('passes cwd to spawn options', async () => {
|
|
196
|
+
const mockSpawn = createMockSpawn({ stdout: 'ok' });
|
|
197
|
+
|
|
198
|
+
await dispatch({
|
|
199
|
+
command: 'cmd',
|
|
200
|
+
args: ['--flag'],
|
|
201
|
+
prompt: 'hello',
|
|
202
|
+
cwd: '/tmp/test',
|
|
203
|
+
spawn: mockSpawn,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
207
|
+
'cmd',
|
|
208
|
+
['--flag'],
|
|
209
|
+
expect.objectContaining({ cwd: '/tmp/test' })
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('buildProviderCommand', () => {
|
|
215
|
+
it('extracts command and flags', () => {
|
|
216
|
+
const result = buildProviderCommand({
|
|
217
|
+
type: 'cli',
|
|
218
|
+
command: 'codex',
|
|
219
|
+
flags: ['--dangerously-skip-permissions'],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result).toEqual({
|
|
223
|
+
command: 'codex',
|
|
224
|
+
args: ['--dangerously-skip-permissions'],
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('handles provider with no flags', () => {
|
|
229
|
+
const result = buildProviderCommand({
|
|
230
|
+
type: 'cli',
|
|
231
|
+
command: 'gemini',
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result).toEqual({
|
|
235
|
+
command: 'gemini',
|
|
236
|
+
args: [],
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('handles inline type and returns null', () => {
|
|
241
|
+
const result = buildProviderCommand({
|
|
242
|
+
type: 'inline',
|
|
243
|
+
command: 'claude',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|