tlc-claude-code 2.3.0 → 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/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +31 -0
- 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/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +31 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- 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/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/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/setup.sh +271 -271
|
@@ -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 };
|