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,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
+ };