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.
- 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 +146 -0
- package/server/lib/task-router-config.test.js +493 -0
- package/server/setup.sh +271 -271
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing Command - Config management for per-command model routing
|
|
3
|
+
*
|
|
4
|
+
* Shows effective routing config, installed providers, and saves personal config.
|
|
5
|
+
* Used by the `/tlc:llm` command.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Show effective routing table after precedence merge.
|
|
12
|
+
* @param {Object} deps - Injected dependencies
|
|
13
|
+
* @param {Function} deps.resolveRouting - from task-router-config
|
|
14
|
+
* @param {string[]} deps.commands - list of routable commands
|
|
15
|
+
* @returns {Array<{ command: string, models: string[], strategy: string, source: string }>}
|
|
16
|
+
*/
|
|
17
|
+
function showRouting({ resolveRouting, commands, projectDir, homeDir }) {
|
|
18
|
+
return commands.map((cmd) => {
|
|
19
|
+
const resolved = resolveRouting({ command: cmd, projectDir, homeDir });
|
|
20
|
+
return {
|
|
21
|
+
command: cmd,
|
|
22
|
+
models: resolved.models,
|
|
23
|
+
strategy: resolved.strategy,
|
|
24
|
+
source: resolved.source,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Show installed CLI providers with availability.
|
|
31
|
+
* @param {Object} deps - Injected dependencies
|
|
32
|
+
* @param {Function} deps.detectCLI - from cli-detector
|
|
33
|
+
* @param {string[]} deps.providerNames - provider names to check
|
|
34
|
+
* @returns {Promise<Array<{ name: string, found: boolean, path: string|null, version: string|null }>>}
|
|
35
|
+
*/
|
|
36
|
+
async function showProviders({ detectCLI, providerNames }) {
|
|
37
|
+
const results = await Promise.all(
|
|
38
|
+
providerNames.map(async (name) => {
|
|
39
|
+
const detection = await detectCLI(name);
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
found: detection.found,
|
|
43
|
+
path: detection.path,
|
|
44
|
+
version: detection.version,
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write task_routing config to personal config file.
|
|
53
|
+
* @param {Object} opts
|
|
54
|
+
* @param {Object} opts.routing - The task_routing object to save
|
|
55
|
+
* @param {string} opts.homeDir - Home directory
|
|
56
|
+
* @param {Object} opts.fs - Injected fs module
|
|
57
|
+
* @returns {{ saved: boolean, path: string }}
|
|
58
|
+
*/
|
|
59
|
+
function savePersonalRouting({ routing, homeDir, fs }) {
|
|
60
|
+
const dirPath = path.join(homeDir, '.tlc');
|
|
61
|
+
const configPath = path.join(dirPath, 'config.json');
|
|
62
|
+
|
|
63
|
+
// Ensure directory exists
|
|
64
|
+
if (!fs.existsSync(dirPath)) {
|
|
65
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load existing config or start fresh
|
|
69
|
+
let existing = {};
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
72
|
+
existing = JSON.parse(content);
|
|
73
|
+
} catch {
|
|
74
|
+
// No existing config, start fresh
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Merge: replace task_routing, keep everything else
|
|
78
|
+
existing.task_routing = routing;
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
|
|
81
|
+
|
|
82
|
+
return { saved: true, path: configPath };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format routing table for display.
|
|
87
|
+
* @param {Array} routingTable - Output from showRouting
|
|
88
|
+
* @returns {string} Formatted text table
|
|
89
|
+
*/
|
|
90
|
+
function formatRoutingTable(routingTable) {
|
|
91
|
+
if (!routingTable || routingTable.length === 0) {
|
|
92
|
+
return 'No routing entries configured.';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Calculate column widths
|
|
96
|
+
const headers = { command: 'Command', models: 'Models', strategy: 'Strategy', source: 'Source' };
|
|
97
|
+
let cmdW = headers.command.length;
|
|
98
|
+
let modW = headers.models.length;
|
|
99
|
+
let strW = headers.strategy.length;
|
|
100
|
+
let srcW = headers.source.length;
|
|
101
|
+
|
|
102
|
+
const rows = routingTable.map((entry) => {
|
|
103
|
+
const modelsStr = entry.models.join(' + ');
|
|
104
|
+
cmdW = Math.max(cmdW, entry.command.length);
|
|
105
|
+
modW = Math.max(modW, modelsStr.length);
|
|
106
|
+
strW = Math.max(strW, entry.strategy.length);
|
|
107
|
+
srcW = Math.max(srcW, entry.source.length);
|
|
108
|
+
return { command: entry.command, models: modelsStr, strategy: entry.strategy, source: entry.source };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const pad = (str, len) => str.padEnd(len);
|
|
112
|
+
const lines = [];
|
|
113
|
+
|
|
114
|
+
// Header
|
|
115
|
+
lines.push(
|
|
116
|
+
`${pad(headers.command, cmdW)} ${pad(headers.models, modW)} ${pad(headers.strategy, strW)} ${pad(headers.source, srcW)}`,
|
|
117
|
+
);
|
|
118
|
+
// Separator
|
|
119
|
+
lines.push(
|
|
120
|
+
`${'-'.repeat(cmdW)} ${'-'.repeat(modW)} ${'-'.repeat(strW)} ${'-'.repeat(srcW)}`,
|
|
121
|
+
);
|
|
122
|
+
// Rows
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
lines.push(
|
|
125
|
+
`${pad(row.command, cmdW)} ${pad(row.models, modW)} ${pad(row.strategy, strW)} ${pad(row.source, srcW)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format provider list for display.
|
|
134
|
+
* @param {Array} providers - Output from showProviders
|
|
135
|
+
* @returns {string} Formatted text list
|
|
136
|
+
*/
|
|
137
|
+
function formatProviderList(providers) {
|
|
138
|
+
if (!providers || providers.length === 0) {
|
|
139
|
+
return 'No providers configured.';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = providers.map((p) => {
|
|
143
|
+
const icon = p.found ? '✓' : '✗';
|
|
144
|
+
const status = p.found ? 'installed' : 'not found';
|
|
145
|
+
const version = p.version ? ` (${p.version})` : '';
|
|
146
|
+
const loc = p.path ? ` — ${p.path}` : '';
|
|
147
|
+
return ` ${icon} ${p.name}: ${status}${version}${loc}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
showRouting,
|
|
155
|
+
showProviders,
|
|
156
|
+
savePersonalRouting,
|
|
157
|
+
formatRoutingTable,
|
|
158
|
+
formatProviderList,
|
|
159
|
+
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
showRouting,
|
|
4
|
+
showProviders,
|
|
5
|
+
savePersonalRouting,
|
|
6
|
+
formatRoutingTable,
|
|
7
|
+
formatProviderList,
|
|
8
|
+
} from './routing-command.js';
|
|
9
|
+
|
|
10
|
+
describe('routing-command', () => {
|
|
11
|
+
// ── showRouting ───────────────────────────────────────────────────
|
|
12
|
+
describe('showRouting', () => {
|
|
13
|
+
it('returns routing for all routable commands', () => {
|
|
14
|
+
const commands = ['build', 'review', 'plan'];
|
|
15
|
+
const resolveRouting = vi.fn(() => ({
|
|
16
|
+
models: ['claude'],
|
|
17
|
+
strategy: 'single',
|
|
18
|
+
source: 'shipped-defaults',
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const result = showRouting({ resolveRouting, commands });
|
|
22
|
+
|
|
23
|
+
expect(result).toHaveLength(3);
|
|
24
|
+
expect(resolveRouting).toHaveBeenCalledTimes(3);
|
|
25
|
+
// Verify it passes an options object with command field
|
|
26
|
+
expect(resolveRouting).toHaveBeenCalledWith(expect.objectContaining({ command: 'build' }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('each entry has command, models, strategy, source', () => {
|
|
30
|
+
const commands = ['build'];
|
|
31
|
+
const resolveRouting = vi.fn(() => ({
|
|
32
|
+
models: ['claude', 'codex'],
|
|
33
|
+
strategy: 'parallel',
|
|
34
|
+
source: 'personal-config',
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const result = showRouting({ resolveRouting, commands });
|
|
38
|
+
|
|
39
|
+
expect(result[0]).toEqual({
|
|
40
|
+
command: 'build',
|
|
41
|
+
models: ['claude', 'codex'],
|
|
42
|
+
strategy: 'parallel',
|
|
43
|
+
source: 'personal-config',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses resolveRouting for each command', () => {
|
|
48
|
+
const commands = ['build', 'review'];
|
|
49
|
+
const resolveRouting = vi.fn((opts) => {
|
|
50
|
+
if (opts.command === 'build') {
|
|
51
|
+
return { models: ['codex'], strategy: 'single', source: 'personal-config' };
|
|
52
|
+
}
|
|
53
|
+
return { models: ['claude'], strategy: 'single', source: 'shipped-defaults' };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = showRouting({ resolveRouting, commands });
|
|
57
|
+
|
|
58
|
+
expect(result[0]).toEqual({
|
|
59
|
+
command: 'build',
|
|
60
|
+
models: ['codex'],
|
|
61
|
+
strategy: 'single',
|
|
62
|
+
source: 'personal-config',
|
|
63
|
+
});
|
|
64
|
+
expect(result[1]).toEqual({
|
|
65
|
+
command: 'review',
|
|
66
|
+
models: ['claude'],
|
|
67
|
+
strategy: 'single',
|
|
68
|
+
source: 'shipped-defaults',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── showProviders ─────────────────────────────────────────────────
|
|
74
|
+
describe('showProviders', () => {
|
|
75
|
+
it('returns status for each provider name', async () => {
|
|
76
|
+
const detectCLI = vi.fn(async () => ({
|
|
77
|
+
found: true,
|
|
78
|
+
path: '/usr/bin/claude',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const result = await showProviders({
|
|
83
|
+
detectCLI,
|
|
84
|
+
providerNames: ['claude', 'codex'],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result).toHaveLength(2);
|
|
88
|
+
expect(detectCLI).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('includes found, path, version fields', async () => {
|
|
92
|
+
const detectCLI = vi.fn(async () => ({
|
|
93
|
+
found: true,
|
|
94
|
+
path: '/usr/local/bin/claude',
|
|
95
|
+
version: '2.1.0',
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
const result = await showProviders({
|
|
99
|
+
detectCLI,
|
|
100
|
+
providerNames: ['claude'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result[0]).toEqual({
|
|
104
|
+
name: 'claude',
|
|
105
|
+
found: true,
|
|
106
|
+
path: '/usr/local/bin/claude',
|
|
107
|
+
version: '2.1.0',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('handles provider not found', async () => {
|
|
112
|
+
const detectCLI = vi.fn(async () => ({
|
|
113
|
+
found: false,
|
|
114
|
+
path: null,
|
|
115
|
+
version: null,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
const result = await showProviders({
|
|
119
|
+
detectCLI,
|
|
120
|
+
providerNames: ['gemini'],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result[0]).toEqual({
|
|
124
|
+
name: 'gemini',
|
|
125
|
+
found: false,
|
|
126
|
+
path: null,
|
|
127
|
+
version: null,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── savePersonalRouting ───────────────────────────────────────────
|
|
133
|
+
describe('savePersonalRouting', () => {
|
|
134
|
+
it('writes config to ~/.tlc/config.json', () => {
|
|
135
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
136
|
+
const written = {};
|
|
137
|
+
const fs = {
|
|
138
|
+
existsSync: vi.fn(() => true),
|
|
139
|
+
readFileSync: vi.fn(() => '{}'),
|
|
140
|
+
mkdirSync: vi.fn(),
|
|
141
|
+
writeFileSync: vi.fn((path, data) => { written.path = path; written.data = data; }),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const result = savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
145
|
+
|
|
146
|
+
expect(result.saved).toBe(true);
|
|
147
|
+
expect(result.path).toBe('/home/user/.tlc/config.json');
|
|
148
|
+
expect(written.path).toBe('/home/user/.tlc/config.json');
|
|
149
|
+
const parsed = JSON.parse(written.data);
|
|
150
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('creates directory if missing', () => {
|
|
154
|
+
const routing = { build: { models: ['claude'], strategy: 'single' } };
|
|
155
|
+
const fs = {
|
|
156
|
+
existsSync: vi.fn(() => false),
|
|
157
|
+
readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
|
|
158
|
+
mkdirSync: vi.fn(),
|
|
159
|
+
writeFileSync: vi.fn(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
163
|
+
|
|
164
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
165
|
+
'/home/user/.tlc',
|
|
166
|
+
{ recursive: true },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('merges with existing config without overwriting other keys', () => {
|
|
171
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
172
|
+
const existingConfig = {
|
|
173
|
+
model_providers: { claude: { type: 'inline' } },
|
|
174
|
+
task_routing: { review: { models: ['gemini'], strategy: 'single' } },
|
|
175
|
+
};
|
|
176
|
+
let writtenData;
|
|
177
|
+
const fs = {
|
|
178
|
+
existsSync: vi.fn(() => true),
|
|
179
|
+
readFileSync: vi.fn(() => JSON.stringify(existingConfig)),
|
|
180
|
+
mkdirSync: vi.fn(),
|
|
181
|
+
writeFileSync: vi.fn((_, data) => { writtenData = data; }),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
185
|
+
|
|
186
|
+
const parsed = JSON.parse(writtenData);
|
|
187
|
+
expect(parsed.model_providers).toEqual({ claude: { type: 'inline' } });
|
|
188
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('creates new file if none exists', () => {
|
|
192
|
+
const routing = { plan: { models: ['claude'], strategy: 'single' } };
|
|
193
|
+
let writtenData;
|
|
194
|
+
const fs = {
|
|
195
|
+
existsSync: vi.fn(() => false),
|
|
196
|
+
readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
|
|
197
|
+
mkdirSync: vi.fn(),
|
|
198
|
+
writeFileSync: vi.fn((_, data) => { writtenData = data; }),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const result = savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
202
|
+
|
|
203
|
+
expect(result.saved).toBe(true);
|
|
204
|
+
const parsed = JSON.parse(writtenData);
|
|
205
|
+
expect(parsed.task_routing).toEqual(routing);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── formatRoutingTable ────────────────────────────────────────────
|
|
210
|
+
describe('formatRoutingTable', () => {
|
|
211
|
+
it('formats entries as aligned text table', () => {
|
|
212
|
+
const table = [
|
|
213
|
+
{ command: 'build', models: ['claude'], strategy: 'single', source: 'shipped-defaults' },
|
|
214
|
+
{ command: 'review', models: ['claude', 'codex'], strategy: 'parallel', source: 'personal-config' },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const output = formatRoutingTable(table);
|
|
218
|
+
|
|
219
|
+
expect(output).toContain('build');
|
|
220
|
+
expect(output).toContain('review');
|
|
221
|
+
expect(output).toContain('single');
|
|
222
|
+
expect(output).toContain('parallel');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('shows command, models joined with +, strategy, source', () => {
|
|
226
|
+
const table = [
|
|
227
|
+
{ command: 'review', models: ['claude', 'codex'], strategy: 'parallel', source: 'personal-config' },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const output = formatRoutingTable(table);
|
|
231
|
+
|
|
232
|
+
expect(output).toContain('claude + codex');
|
|
233
|
+
expect(output).toContain('parallel');
|
|
234
|
+
expect(output).toContain('personal-config');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('handles empty table', () => {
|
|
238
|
+
const output = formatRoutingTable([]);
|
|
239
|
+
|
|
240
|
+
expect(output).toContain('No routing');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── formatProviderList ────────────────────────────────────────────
|
|
245
|
+
describe('formatProviderList', () => {
|
|
246
|
+
it('shows checkmark for found providers', () => {
|
|
247
|
+
const providers = [
|
|
248
|
+
{ name: 'claude', found: true, path: '/usr/bin/claude', version: '1.0.0' },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const output = formatProviderList(providers);
|
|
252
|
+
|
|
253
|
+
// Use a checkmark symbol
|
|
254
|
+
expect(output).toMatch(/[✓✔]/);
|
|
255
|
+
expect(output).toContain('claude');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('shows X for missing providers', () => {
|
|
259
|
+
const providers = [
|
|
260
|
+
{ name: 'codex', found: false, path: null, version: null },
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const output = formatProviderList(providers);
|
|
264
|
+
|
|
265
|
+
// Use an X symbol
|
|
266
|
+
expect(output).toMatch(/[✗✘×]/);
|
|
267
|
+
expect(output).toContain('codex');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('includes version when available', () => {
|
|
271
|
+
const providers = [
|
|
272
|
+
{ name: 'claude', found: true, path: '/usr/bin/claude', version: '2.1.0' },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const output = formatProviderList(providers);
|
|
276
|
+
|
|
277
|
+
expect(output).toContain('2.1.0');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('shows path', () => {
|
|
281
|
+
const providers = [
|
|
282
|
+
{ name: 'claude', found: true, path: '/usr/local/bin/claude', version: '1.0.0' },
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const output = formatProviderList(providers);
|
|
286
|
+
|
|
287
|
+
expect(output).toContain('/usr/local/bin/claude');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Router Config - Unified config loader for per-command model routing
|
|
3
|
+
*
|
|
4
|
+
* Merges personal -> project -> flag overrides into a single resolved routing table.
|
|
5
|
+
*
|
|
6
|
+
* Precedence (most specific wins):
|
|
7
|
+
* 1. --model flag (one-off override)
|
|
8
|
+
* 2. .tlc.json -> task_routing_override (project level)
|
|
9
|
+
* 3. ~/.tlc/config.json -> task_routing (personal level)
|
|
10
|
+
* 4. Shipped defaults (all commands -> claude, single strategy)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Commands that support model routing.
|
|
17
|
+
* @type {string[]}
|
|
18
|
+
*/
|
|
19
|
+
const ROUTABLE_COMMANDS = [
|
|
20
|
+
'build', 'plan', 'review', 'test', 'coverage',
|
|
21
|
+
'autofix', 'discuss', 'docs', 'edge-cases', 'quick',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default routing: every routable command maps to claude with single strategy.
|
|
26
|
+
* @type {Record<string, {models: string[], strategy: string}>}
|
|
27
|
+
*/
|
|
28
|
+
const SHIPPED_DEFAULTS = {};
|
|
29
|
+
for (const cmd of ROUTABLE_COMMANDS) {
|
|
30
|
+
SHIPPED_DEFAULTS[cmd] = { models: ['claude'], strategy: 'single' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load personal config from ~/.tlc/config.json
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {string} options.homeDir - Home directory path
|
|
37
|
+
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
38
|
+
* @returns {object|null} Parsed config or null on failure
|
|
39
|
+
*/
|
|
40
|
+
function loadPersonalConfig({ homeDir, fs }) {
|
|
41
|
+
try {
|
|
42
|
+
const configPath = path.join(homeDir, '.tlc', 'config.json');
|
|
43
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
44
|
+
return JSON.parse(content);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load project-level task routing override from .tlc.json
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @param {string} options.projectDir - Project directory path
|
|
54
|
+
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
55
|
+
* @returns {object|null} The task_routing_override section or null
|
|
56
|
+
*/
|
|
57
|
+
function loadProjectOverride({ projectDir, fs }) {
|
|
58
|
+
try {
|
|
59
|
+
const configPath = path.join(projectDir, '.tlc.json');
|
|
60
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
61
|
+
const data = JSON.parse(content);
|
|
62
|
+
return data.task_routing_override || null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the routing configuration for a given command.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} options
|
|
72
|
+
* @param {string} options.command - The TLC command name (e.g. 'build', 'review')
|
|
73
|
+
* @param {string} [options.flagModel] - Model name from --model flag override
|
|
74
|
+
* @param {string} options.projectDir - Project directory path
|
|
75
|
+
* @param {string} options.homeDir - Home directory path
|
|
76
|
+
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
77
|
+
* @returns {{ models: string[], strategy: string, source: string, providers?: object }}
|
|
78
|
+
*/
|
|
79
|
+
function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDir = require('os').homedir(), fs = require('fs') }) {
|
|
80
|
+
// Start with shipped defaults
|
|
81
|
+
const defaultEntry = SHIPPED_DEFAULTS[command] || { models: ['claude'], strategy: 'single' };
|
|
82
|
+
let models = [...defaultEntry.models];
|
|
83
|
+
let strategy = defaultEntry.strategy;
|
|
84
|
+
let source = 'shipped-defaults';
|
|
85
|
+
let providers;
|
|
86
|
+
|
|
87
|
+
// Layer 1: personal config (~/.tlc/config.json)
|
|
88
|
+
const personal = loadPersonalConfig({ homeDir, fs });
|
|
89
|
+
if (personal) {
|
|
90
|
+
// Capture model_providers if present
|
|
91
|
+
if (personal.model_providers) {
|
|
92
|
+
providers = personal.model_providers;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const personalRouting = personal.task_routing?.[command];
|
|
96
|
+
if (personalRouting) {
|
|
97
|
+
if (personalRouting.models) {
|
|
98
|
+
models = [...personalRouting.models];
|
|
99
|
+
} else if (personalRouting.model) {
|
|
100
|
+
models = [personalRouting.model];
|
|
101
|
+
}
|
|
102
|
+
if (personalRouting.strategy) {
|
|
103
|
+
strategy = personalRouting.strategy;
|
|
104
|
+
}
|
|
105
|
+
source = 'personal-config';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Layer 2: project override (.tlc.json -> task_routing_override)
|
|
110
|
+
const projectOverride = loadProjectOverride({ projectDir, fs });
|
|
111
|
+
if (projectOverride) {
|
|
112
|
+
const overrideEntry = projectOverride[command];
|
|
113
|
+
if (overrideEntry) {
|
|
114
|
+
if (overrideEntry.models) {
|
|
115
|
+
models = [...overrideEntry.models];
|
|
116
|
+
} else if (overrideEntry.model) {
|
|
117
|
+
models = [overrideEntry.model];
|
|
118
|
+
}
|
|
119
|
+
if (overrideEntry.strategy) {
|
|
120
|
+
strategy = overrideEntry.strategy;
|
|
121
|
+
}
|
|
122
|
+
source = 'project-override';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Layer 3: --model flag (highest priority)
|
|
127
|
+
if (flagModel) {
|
|
128
|
+
models = [flagModel];
|
|
129
|
+
strategy = 'single';
|
|
130
|
+
source = 'flag-override';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result = { models, strategy, source };
|
|
134
|
+
if (providers) {
|
|
135
|
+
result.providers = providers;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
resolveRouting,
|
|
142
|
+
loadPersonalConfig,
|
|
143
|
+
loadProjectOverride,
|
|
144
|
+
SHIPPED_DEFAULTS,
|
|
145
|
+
ROUTABLE_COMMANDS,
|
|
146
|
+
};
|