10 KiB
Backend Switch Command Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add /backend slash command to switch between local LLM providers (ollama, llamacpp) at runtime.
Architecture: Extend config schema with local_providers object, add setLocalClient() to ModelRouter, add command parsing and handling in TUI.
Tech Stack: TypeScript, Zod (config validation), Vitest (testing)
Task 1: Extend Config Schema
Files:
- Modify:
src/config/schema.ts
Step 1: Add local_providers to schema
Update src/config/schema.ts - add local_providers field to modelsSchema:
const modelsSchema = z.object({
local: modelConfigSchema.optional(),
fast: modelConfigSchema.optional(),
default: modelConfigSchema,
complex: modelConfigSchema.optional(),
fallback_chain: z.array(z.string()).default(['anthropic']),
local_providers: z.record(z.string(), modelConfigSchema).optional(),
});
Step 2: Verify build
Run: npx tsc --noEmit
Expected: No errors
Step 3: Commit
git add src/config/schema.ts
git commit -m "feat: add local_providers to config schema"
Task 2: Add ModelRouter Methods
Files:
- Modify:
src/models/router.ts - Modify:
src/models/router.test.ts
Step 1: Write the failing test
Add to src/models/router.test.ts:
describe('ModelRouter local client switching', () => {
it('allows setting a new local client', () => {
const mockDefault = { chat: vi.fn() } as unknown as ModelClient;
const mockLocal1 = { chat: vi.fn() } as unknown as ModelClient;
const mockLocal2 = { chat: vi.fn() } as unknown as ModelClient;
const router = new ModelRouter({
default: mockDefault,
local: mockLocal1,
fallbackChain: [],
});
expect(router.getLocalProviderName()).toBe(undefined);
router.setLocalClient(mockLocal2, 'llamacpp');
expect(router.getLocalProviderName()).toBe('llamacpp');
expect(router.getClient('local')).toBe(mockLocal2);
});
});
Step 2: Run test to verify it fails
Run: npm test -- src/models/router.test.ts
Expected: FAIL with "setLocalClient is not a function" or "getLocalProviderName is not a function"
Step 3: Write minimal implementation
Add to src/models/router.ts:
- Add private field after line 17:
private localProviderName?: string;
- Add methods after
getClient()(after line 113):
setLocalClient(client: ModelClient, providerName: string): void {
this.clients.set('local', client);
this.localProviderName = providerName;
}
getLocalProviderName(): string | undefined {
return this.localProviderName;
}
Step 4: Run test to verify it passes
Run: npm test -- src/models/router.test.ts
Expected: PASS
Step 5: Commit
git add src/models/router.ts src/models/router.test.ts
git commit -m "feat: add setLocalClient and getLocalProviderName to ModelRouter"
Task 3: Add Backend Command Parsing
Files:
- Modify:
src/frontends/tui/commands.ts - Modify:
src/frontends/tui/commands.test.ts
Step 1: Write the failing tests
Add to src/frontends/tui/commands.test.ts:
it('parses /backend command without argument', () => {
expect(parseCommand('/backend')).toEqual({ type: 'backend' });
});
it('parses /backend command with argument', () => {
expect(parseCommand('/backend llamacpp')).toEqual({ type: 'backend', provider: 'llamacpp' });
expect(parseCommand('/backend ollama')).toEqual({ type: 'backend', provider: 'ollama' });
});
Step 2: Run test to verify it fails
Run: npm test -- src/frontends/tui/commands.test.ts
Expected: FAIL - returns message type instead of backend type
Step 3: Implement command parsing
Update src/frontends/tui/commands.ts:
- Add to Command type (after line 8):
| { type: 'backend'; provider?: string }
- Add parsing after the model block (after line 47):
// Backend (with optional argument)
if (trimmed === '/backend') {
return { type: 'backend' };
}
if (trimmed.startsWith('/backend ')) {
const provider = trimmed.slice('/backend '.length).trim();
return { type: 'backend', provider };
}
- Add to getHelpText() (after line 63, before /reset):
/backend [provider] Show or switch local backend (ollama, llamacpp)
- Add to SLASH_COMMANDS array (after '/model'):
'/backend',
- Add to COMMAND_TOOLTIPS (after '/model'):
'/backend': 'Show or switch local backend (ollama, llamacpp)',
Step 4: Run test to verify it passes
Run: npm test -- src/frontends/tui/commands.test.ts
Expected: PASS
Step 5: Commit
git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts
git commit -m "feat: add /backend command parsing"
Task 4: Add Backend Command Handler to MinimalTui
Files:
- Modify:
src/frontends/tui/minimal.ts - Modify:
src/frontends/tui/minimal.test.ts
Step 1: Write the failing test
Add to src/frontends/tui/minimal.test.ts (import ModelConfig type first):
import type { ModelConfig } from '../../config/schema.js';
describe('MinimalTui backend command', () => {
it('switches local backend when provider is configured', async () => {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
};
const mockRouter = {
getTier: () => 'default' as const,
getAvailableTiers: () => ['default', 'local'],
setTier: vi.fn(() => true),
getLocalProviderName: () => 'ollama',
setLocalClient: vi.fn(),
chat: vi.fn(),
getClient: vi.fn(),
};
const localProviders: Record<string, ModelConfig> = {
llamacpp: {
provider: 'llamacpp',
model: '',
endpoint: 'http://localhost:8080',
},
};
const tui = new MinimalTui({
session: mockSession as any,
modelClient: mockRouter as any,
modelRouter: mockRouter as any,
systemPrompt: 'test',
localProviders,
});
// Access private method for testing
await (tui as any).handleBackendCommand('llamacpp');
expect(mockRouter.setLocalClient).toHaveBeenCalled();
});
});
Step 2: Run test to verify it fails
Run: npm test -- src/frontends/tui/minimal.test.ts
Expected: FAIL - handleBackendCommand doesn't exist or localProviders not accepted
Step 3: Implement handler
Update src/frontends/tui/minimal.ts:
- Add import at top:
import type { ModelConfig } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
- Add to MinimalTuiConfig interface (after line 23):
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
- Add case in handleCommand switch (after line 100, before 'message'):
case 'backend':
this.handleBackendCommand(command.provider);
break;
- Add handler method (after handleModelCommand, around line 130):
private handleBackendCommand(provider?: string): void {
const router = this.config.modelRouter;
if (!router) {
console.log('Backend switching not available.\n');
return;
}
if (!provider) {
const current = router.getLocalProviderName() ?? this.config.currentLocalProvider ?? 'unknown';
const available = this.getAvailableBackends();
console.log(`Current local backend: ${current}`);
console.log(`Available: ${available.join(', ')}\n`);
return;
}
const providerConfig = this.config.localProviders?.[provider];
if (!providerConfig) {
const available = this.getAvailableBackends();
console.log(`Backend '${provider}' not configured.`);
console.log(`Available: ${available.join(', ')}\n`);
return;
}
const client = this.createLocalClient(providerConfig);
if (!client) {
console.log(`Failed to create client for '${provider}'.\n`);
return;
}
router.setLocalClient(client, provider);
console.log(`Switched to backend: ${provider}\n`);
}
private getAvailableBackends(): string[] {
const backends: string[] = [];
if (this.config.currentLocalProvider) {
backends.push(this.config.currentLocalProvider);
}
if (this.config.localProviders) {
backends.push(...Object.keys(this.config.localProviders));
}
return [...new Set(backends)];
}
private createLocalClient(config: ModelConfig): ModelClient | null {
if (config.provider === 'ollama') {
return new OllamaClient({
model: config.model,
host: config.endpoint,
});
}
if (config.provider === 'llamacpp') {
return new LlamaCppClient({
endpoint: config.endpoint ?? 'http://localhost:8080',
authToken: config.auth_token,
});
}
return null;
}
Step 4: Run test to verify it passes
Run: npm test -- src/frontends/tui/minimal.test.ts
Expected: PASS
Step 5: Commit
git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.test.ts
git commit -m "feat: add /backend command handler to MinimalTui"
Task 5: Wire Up Config in TUI Entry Point
Files:
- Modify:
src/tui.ts
Step 1: Read current file and update
Read src/tui.ts to understand current structure, then update to pass localProviders and currentLocalProvider to MinimalTui.
The changes needed:
- Extract
local_providersfrom config - Extract current local provider name from
config.models.local.provider - Pass both to MinimalTui config
Step 2: Run full test suite
Run: npm test
Expected: All tests pass
Step 3: Commit
git add src/tui.ts
git commit -m "feat: wire up localProviders config to TUI"
Task 6: Verify and Final Test
Files: None (verification only)
Step 1: Run full test suite
Run: npm test
Expected: All tests pass
Step 2: Type check
Run: npx tsc --noEmit
Expected: No errors
Step 3: Build
Run: npm run build
Expected: Build succeeds
Summary
| Task | Description | Tests Added |
|---|---|---|
| 1 | Config schema extension | 0 |
| 2 | ModelRouter methods | 1 |
| 3 | Command parsing | 2 |
| 4 | MinimalTui handler | 1 |
| 5 | Wire up config | 0 |
| 6 | Verification | 0 |
Total new tests: 4 Files modified: 7