tlc-claude-code 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Command Router
3
+ *
4
+ * Integration point for TLC command routing. Given a command name,
5
+ * resolves routing config, checks provider availability, packages
6
+ * prompts, and dispatches to the appropriate execution path.
7
+ *
8
+ * @module command-router
9
+ */
10
+
11
+ const { buildProviderCommand } = require('./cli-dispatcher.js');
12
+
13
+ /**
14
+ * Check whether a model is available in the router state.
15
+ * @param {string} model - Model name to check
16
+ * @param {Object} routerState - Parsed .tlc/.router-state.json
17
+ * @returns {boolean} True if the model's provider is available
18
+ */
19
+ function isModelAvailable(model, routerState, providerConfig) {
20
+ if (model === 'claude') return true;
21
+ // Check router state (probed CLIs) first
22
+ const probed = routerState?.providers?.[model];
23
+ if (probed?.available === true) return true;
24
+ // Also accept user-defined provider aliases from model_providers config
25
+ if (providerConfig?.[model]?.type === 'cli') return true;
26
+ return false;
27
+ }
28
+
29
+ /**
30
+ * Route a single model to its result.
31
+ * @param {string} model - Model name
32
+ * @param {Object} opts - Routing options
33
+ * @param {string} opts.agentPrompt - Command instruction text
34
+ * @param {Object} opts.context - Project context
35
+ * @param {Object|undefined} opts.providerConfig - Provider config for CLI models
36
+ * @param {Object} opts.routerState - Router state from .router-state.json
37
+ * @param {Object} deps - Injected dependencies
38
+ * @returns {Promise<Object>} Result for this model
39
+ */
40
+ async function routeModel(model, { agentPrompt, context, providerConfig, routerState }, deps) {
41
+ // Claude is always inline
42
+ if (model === 'claude') {
43
+ return { model: 'claude', type: 'inline' };
44
+ }
45
+
46
+ // Check availability — return skipped (not duplicate Claude) so parallel doesn't double-run
47
+ if (!isModelAvailable(model, routerState, providerConfig)) {
48
+ return {
49
+ type: 'skipped',
50
+ model,
51
+ warning: `${model} is unavailable`,
52
+ };
53
+ }
54
+
55
+ // Get provider command config
56
+ const provider = providerConfig?.[model];
57
+ if (!provider) {
58
+ return {
59
+ type: 'skipped',
60
+ model,
61
+ warning: `${model} has no provider config`,
62
+ };
63
+ }
64
+
65
+ const cmdSpec = buildProviderCommand(provider);
66
+ if (!cmdSpec) {
67
+ return {
68
+ type: 'skipped',
69
+ model,
70
+ warning: `${model} is not a CLI provider`,
71
+ };
72
+ }
73
+
74
+ // Package prompt with full context
75
+ const prompt = deps.packagePrompt({
76
+ agentPrompt,
77
+ projectDoc: context.projectDoc || null,
78
+ planDoc: context.planDoc || null,
79
+ codingStandards: context.codingStandards || null,
80
+ files: context.files || null,
81
+ });
82
+
83
+ // Dispatch to CLI
84
+ const dispatchResult = await deps.dispatch({
85
+ command: cmdSpec.command,
86
+ args: cmdSpec.args,
87
+ prompt,
88
+ });
89
+
90
+ return {
91
+ type: 'cli',
92
+ model,
93
+ output: dispatchResult.stdout,
94
+ stderr: dispatchResult.stderr,
95
+ exitCode: dispatchResult.exitCode,
96
+ duration: dispatchResult.duration,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Route a TLC command to the appropriate execution path.
102
+ * @param {Object} options
103
+ * @param {string} options.command - TLC command name (e.g., 'build', 'review')
104
+ * @param {string} options.agentPrompt - The command's instruction text
105
+ * @param {string|null} options.flagModel - --model flag override
106
+ * @param {Object} options.context - Project context for prompt packaging
107
+ * @param {string|null} options.context.projectDoc - PROJECT.md contents
108
+ * @param {string|null} options.context.planDoc - Current phase PLAN.md
109
+ * @param {string|null} options.context.codingStandards - CODING-STANDARDS.md
110
+ * @param {Array|null} options.context.files - [{path, content}]
111
+ * @param {Object} deps - Injected dependencies for testability
112
+ * @param {string} [options.projectDir] - Project directory for config loading
113
+ * @param {string} [options.homeDir] - Home directory for personal config
114
+ * @param {Object} deps - Injected dependencies for testability
115
+ * @param {Function} deps.resolveRouting - from task-router-config
116
+ * @param {Function} deps.packagePrompt - from prompt-packager
117
+ * @param {Function} deps.dispatch - from cli-dispatcher
118
+ * @param {Function} deps.readRouterState - reads .tlc/.router-state.json
119
+ * @returns {Promise<Object>} Result
120
+ */
121
+ async function routeCommand({ command, agentPrompt, flagModel, context, projectDir, homeDir }, deps) {
122
+ // Resolve routing config — pass full context so personal + project configs are loaded
123
+ const routing = deps.resolveRouting({ command, flagModel, projectDir, homeDir });
124
+
125
+ // Read router state for provider availability
126
+ const routerState = deps.readRouterState();
127
+
128
+ const { models, strategy, providers: providerConfig } = routing;
129
+
130
+ // Single strategy — fall back to Claude if target is unavailable
131
+ if (strategy === 'single') {
132
+ const model = models[0];
133
+ const result = await routeModel(model, {
134
+ agentPrompt,
135
+ context,
136
+ providerConfig,
137
+ routerState,
138
+ }, deps);
139
+
140
+ // For single strategy, skipped means fall back to inline
141
+ if (result.type === 'skipped') {
142
+ return { type: 'inline', model: 'claude', warning: result.warning + ', falling back to inline (claude)' };
143
+ }
144
+
145
+ return result;
146
+ }
147
+
148
+ // Parallel strategy
149
+ if (strategy === 'parallel') {
150
+ const promises = models.map((model) =>
151
+ routeModel(model, {
152
+ agentPrompt,
153
+ context,
154
+ providerConfig,
155
+ routerState,
156
+ }, deps)
157
+ );
158
+
159
+ const results = await Promise.all(promises);
160
+
161
+ return {
162
+ type: 'parallel',
163
+ results,
164
+ };
165
+ }
166
+
167
+ // Unknown strategy — fall back to inline
168
+ return { type: 'inline', model: 'claude' };
169
+ }
170
+
171
+ module.exports = { routeCommand };