diff --git a/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md b/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md new file mode 100644 index 00000000..9dd01122 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md @@ -0,0 +1,1333 @@ +# Provider Preset Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace manual provider configuration with preset-based system — users pick a provider, enter API key, done. + +**Architecture:** Frontend preset definitions hardcoded in a shared config file. Backend stores saved providers in `~/.claude/cc-haha/providers.json` (lightweight index). On activation, backend writes 6 env keys to `~/.claude/settings.json`. Official preset clears those keys. + +**Tech Stack:** TypeScript, Zod, Zustand, React, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-04-07-provider-preset-redesign.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `src/server/config/providerPresets.ts` | Preset definitions (shared backend) | +| Create | `desktop/src/config/providerPresets.ts` | Preset definitions (frontend copy) | +| Rewrite | `src/server/types/provider.ts` | Simplified schemas — models as record not array | +| Rewrite | `src/server/services/providerService.ts` | New storage path, full env write, official clear | +| Modify | `src/server/api/providers.ts` | Simplified activate (no modelId), add presets endpoint | +| Rewrite | `desktop/src/types/provider.ts` | Simplified frontend types | +| Rewrite | `desktop/src/api/providers.ts` | Updated API calls | +| Rewrite | `desktop/src/stores/providerStore.ts` | Aligned with new API shape | +| Rewrite | `desktop/src/pages/Settings.tsx` (ProviderSettings section) | Preset chips + simplified form | + +--- + +### Task 1: Create Preset Definitions + +**Files:** +- Create: `src/server/config/providerPresets.ts` +- Create: `desktop/src/config/providerPresets.ts` + +- [ ] **Step 1: Create backend preset file** + +```typescript +// src/server/config/providerPresets.ts + +// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) +// Original work by Jason Young, MIT License + +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string +} + +export type ProviderPreset = { + id: string + name: string + baseUrl: string + defaultModels: ModelMapping + needsApiKey: boolean + websiteUrl: string +} + +export const PROVIDER_PRESETS: ProviderPreset[] = [ + { + id: 'official', + name: 'Claude Official', + baseUrl: '', + defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, + needsApiKey: false, + websiteUrl: 'https://www.anthropic.com/claude-code', + }, + { + id: 'deepseek', + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' }, + needsApiKey: true, + websiteUrl: 'https://platform.deepseek.com', + }, + { + id: 'zhipuglm', + name: 'Zhipu GLM', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' }, + needsApiKey: true, + websiteUrl: 'https://open.bigmodel.cn', + }, + { + id: 'kimi', + name: 'Kimi', + baseUrl: 'https://api.moonshot.cn/anthropic', + defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' }, + needsApiKey: true, + websiteUrl: 'https://platform.moonshot.cn', + }, + { + id: 'minimax', + name: 'MiniMax', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' }, + needsApiKey: true, + websiteUrl: 'https://platform.minimaxi.com', + }, + { + id: 'custom', + name: 'Custom', + baseUrl: '', + defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, + needsApiKey: true, + websiteUrl: '', + }, +] +``` + +- [ ] **Step 2: Create frontend preset file (identical content)** + +Copy `src/server/config/providerPresets.ts` to `desktop/src/config/providerPresets.ts`. Same content — the type + array are plain data, no server dependencies. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/config/providerPresets.ts desktop/src/config/providerPresets.ts +git commit -m "feat: add provider preset definitions (Official, DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom)" +``` + +--- + +### Task 2: Rewrite Backend Types + +**Files:** +- Rewrite: `src/server/types/provider.ts` + +- [ ] **Step 1: Rewrite provider types with simplified model structure** + +Replace `src/server/types/provider.ts` entirely: + +```typescript +// src/server/types/provider.ts + +/** + * Provider types — preset-based provider configuration. + * + * Providers are stored in ~/.claude/cc-haha/providers.json as a lightweight index. + * The active provider's env vars are written to ~/.claude/settings.json. + */ + +import { z } from 'zod' + +export const ModelMappingSchema = z.object({ + main: z.string(), + haiku: z.string(), + sonnet: z.string(), + opus: z.string(), +}) + +export const SavedProviderSchema = z.object({ + id: z.string(), + presetId: z.string(), + name: z.string().min(1), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, + notes: z.string().optional(), +}) + +export const ProvidersIndexSchema = z.object({ + activeId: z.string().nullable(), + providers: z.array(SavedProviderSchema), +}) + +export const CreateProviderSchema = z.object({ + presetId: z.string().min(1), + name: z.string().min(1), + apiKey: z.string(), + baseUrl: z.string(), + models: ModelMappingSchema, + notes: z.string().optional(), +}) + +export const UpdateProviderSchema = z.object({ + name: z.string().min(1).optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + models: ModelMappingSchema.optional(), + notes: z.string().optional(), +}) + +export const TestProviderSchema = z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + modelId: z.string().min(1), +}) + +// TypeScript types +export type ModelMapping = z.infer +export type SavedProvider = z.infer +export type ProvidersIndex = z.infer +export type CreateProviderInput = z.infer +export type UpdateProviderInput = z.infer +export type TestProviderInput = z.infer + +export interface ProviderTestResult { + success: boolean + latencyMs: number + error?: string + modelUsed?: string + httpStatus?: number +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/types/provider.ts +git commit -m "refactor: rewrite provider types — simplified model mapping, preset-based structure" +``` + +--- + +### Task 3: Rewrite Backend Provider Service + +**Files:** +- Rewrite: `src/server/services/providerService.ts` + +- [ ] **Step 1: Rewrite providerService.ts** + +Replace `src/server/services/providerService.ts` entirely: + +```typescript +// src/server/services/providerService.ts + +/** + * Provider Service — preset-based provider configuration + * + * Storage: ~/.claude/cc-haha/providers.json (lightweight index) + * Active provider env vars written to ~/.claude/settings.json + */ + +import * as fs from 'fs/promises' +import * as path from 'path' +import * as os from 'os' +import { ApiError } from '../middleware/errorHandler.js' +import type { + SavedProvider, + ProvidersIndex, + CreateProviderInput, + UpdateProviderInput, + TestProviderInput, + ProviderTestResult, +} from '../types/provider.js' + +/** The 6 env keys we manage in settings.json */ +const MANAGED_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', +] as const + +const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } + +export class ProviderService { + private getConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + } + + private getCcHahaDir(): string { + return path.join(this.getConfigDir(), 'cc-haha') + } + + private getIndexPath(): string { + return path.join(this.getCcHahaDir(), 'providers.json') + } + + private getSettingsPath(): string { + return path.join(this.getConfigDir(), 'settings.json') + } + + // --- File I/O (atomic write) --- + + private async readIndex(): Promise { + try { + const raw = await fs.readFile(this.getIndexPath(), 'utf-8') + return JSON.parse(raw) as ProvidersIndex + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ...DEFAULT_INDEX, providers: [] } + } + throw ApiError.internal(`Failed to read providers index: ${err}`) + } + } + + private async writeIndex(index: ProvidersIndex): Promise { + const filePath = this.getIndexPath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${filePath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write providers index: ${err}`) + } + } + + private async readSettings(): Promise> { + try { + const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') + return JSON.parse(raw) as Record + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw ApiError.internal(`Failed to read settings.json: ${err}`) + } + } + + private async writeSettings(settings: Record): Promise { + const filePath = this.getSettingsPath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + + const tmpFile = `${filePath}.tmp.${Date.now()}` + try { + await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (err) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write settings.json: ${err}`) + } + } + + // --- CRUD --- + + async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { + const index = await this.readIndex() + return { providers: index.providers, activeId: index.activeId } + } + + async getProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + return provider + } + + async addProvider(input: CreateProviderInput): Promise { + const index = await this.readIndex() + + const provider: SavedProvider = { + id: crypto.randomUUID(), + presetId: input.presetId, + name: input.name, + apiKey: input.apiKey, + baseUrl: input.baseUrl, + models: input.models, + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers.push(provider) + await this.writeIndex(index) + return provider + } + + async updateProvider(id: string, input: UpdateProviderInput): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + const existing = index.providers[idx] + const updated: SavedProvider = { + ...existing, + ...(input.name !== undefined && { name: input.name }), + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), + ...(input.models !== undefined && { models: input.models }), + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers[idx] = updated + await this.writeIndex(index) + + // Re-sync if this is the active provider + if (index.activeId === id) { + await this.syncToSettings(updated) + } + + return updated + } + + async deleteProvider(id: string): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + if (index.activeId === id) { + throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.') + } + + index.providers.splice(idx, 1) + await this.writeIndex(index) + } + + // --- Activation --- + + async activateProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + + index.activeId = id + await this.writeIndex(index) + + if (provider.presetId === 'official') { + await this.clearProviderFromSettings() + } else { + await this.syncToSettings(provider) + } + } + + /** Activate official — clear all managed env keys */ + async activateOfficial(): Promise { + const index = await this.readIndex() + index.activeId = null + await this.writeIndex(index) + await this.clearProviderFromSettings() + } + + // --- Settings sync --- + + private async syncToSettings(provider: SavedProvider): Promise { + const settings = await this.readSettings() + const existingEnv = (settings.env as Record) || {} + + settings.env = { + ...existingEnv, + ANTHROPIC_BASE_URL: provider.baseUrl, + ANTHROPIC_AUTH_TOKEN: provider.apiKey, + ANTHROPIC_MODEL: provider.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + } + + await this.writeSettings(settings) + } + + private async clearProviderFromSettings(): Promise { + const settings = await this.readSettings() + const env = (settings.env as Record) || {} + + for (const key of MANAGED_ENV_KEYS) { + delete env[key] + } + + settings.env = env + // Clean up empty env object + if (Object.keys(env).length === 0) { + delete settings.env + } + + await this.writeSettings(settings) + } + + // --- Test --- + + async testProvider(id: string): Promise { + const provider = await this.getProvider(id) + if (!provider.baseUrl || !provider.apiKey) { + return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } + } + return this.testProviderConfig({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + modelId: provider.models.main, + }) + } + + async testProviderConfig(input: TestProviderInput): Promise { + const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` + const start = Date.now() + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': input.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: input.modelId, + max_tokens: 1, + messages: [{ role: 'user', content: 'Hi' }], + }), + signal: AbortSignal.timeout(15000), + }) + + const latencyMs = Date.now() - start + + if (response.ok) { + return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } + } + + let errorMessage = `HTTP ${response.status}` + try { + const body = (await response.json()) as Record + if (body.error && typeof body.error === 'object') { + errorMessage = ((body.error as Record).message as string) || errorMessage + } else if (typeof body.message === 'string') { + errorMessage = body.message + } + } catch { + errorMessage = `HTTP ${response.status} ${response.statusText}` + } + + return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status } + } catch (err: unknown) { + const latencyMs = Date.now() - start + if (err instanceof DOMException && err.name === 'TimeoutError') { + return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId } + } + return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/services/providerService.ts +git commit -m "refactor: rewrite providerService — cc-haha storage, full env sync, official clear" +``` + +--- + +### Task 4: Update Backend API Routes + +**Files:** +- Modify: `src/server/api/providers.ts` + +- [ ] **Step 1: Rewrite providers API** + +Replace `src/server/api/providers.ts` entirely: + +```typescript +// src/server/api/providers.ts + +/** + * Providers REST API + * + * GET /api/providers — list all saved providers + activeId + * GET /api/providers/presets — list available presets + * POST /api/providers — add a provider + * PUT /api/providers/:id — update a provider + * DELETE /api/providers/:id — delete a provider + * POST /api/providers/:id/activate — activate a saved provider + * POST /api/providers/official — activate official (clear env) + * POST /api/providers/:id/test — test a saved provider + * POST /api/providers/test — test unsaved config + */ + +import { z } from 'zod' +import { ProviderService } from '../services/providerService.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' +import { + CreateProviderSchema, + UpdateProviderSchema, + TestProviderSchema, +} from '../types/provider.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const providerService = new ProviderService() + +function maskApiKey(key: string): string { + if (key.length <= 8) return '****' + return key.slice(0, 4) + '****' + key.slice(-4) +} + +function sanitizeProvider(provider: Record): Record { + if (typeof provider.apiKey === 'string') { + return { ...provider, apiKey: maskApiKey(provider.apiKey) } + } + return provider +} + +export async function handleProvidersApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + const id = segments[2] + const action = segments[3] + + // POST /api/providers/test + if (id === 'test' && req.method === 'POST') { + return await handleTestUnsaved(req) + } + + // GET /api/providers/presets + if (id === 'presets' && req.method === 'GET') { + return Response.json({ presets: PROVIDER_PRESETS }) + } + + // POST /api/providers/official + if (id === 'official' && req.method === 'POST') { + await providerService.activateOfficial() + return Response.json({ ok: true }) + } + + // /api/providers (no ID) + if (!id) { + if (req.method === 'GET') { + const { providers, activeId } = await providerService.listProviders() + return Response.json({ providers: providers.map(sanitizeProvider), activeId }) + } + if (req.method === 'POST') { + return await handleCreate(req) + } + throw methodNotAllowed(req.method) + } + + // /api/providers/:id/activate + if (action === 'activate') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + await providerService.activateProvider(id) + return Response.json({ ok: true }) + } + + // /api/providers/:id/test + if (action === 'test') { + if (req.method !== 'POST') throw methodNotAllowed(req.method) + const result = await providerService.testProvider(id) + return Response.json({ result }) + } + + // /api/providers/:id + if (req.method === 'GET') { + const provider = await providerService.getProvider(id) + return Response.json({ provider: sanitizeProvider(provider) }) + } + if (req.method === 'PUT') { + return await handleUpdate(req, id) + } + if (req.method === 'DELETE') { + await providerService.deleteProvider(id) + return Response.json({ ok: true }) + } + + throw methodNotAllowed(req.method) + } catch (error) { + return errorResponse(error) + } +} + +async function handleCreate(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = CreateProviderSchema.parse(body) + const provider = await providerService.addProvider(input) + return Response.json({ provider }, { status: 201 }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function handleUpdate(req: Request, id: string): Promise { + const body = await parseJsonBody(req) + try { + const input = UpdateProviderSchema.parse(body) + const provider = await providerService.updateProvider(id, input) + return Response.json({ provider }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function handleTestUnsaved(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = TestProviderSchema.parse(body) + const result = await providerService.testProviderConfig(input) + return Response.json({ result }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + +async function parseJsonBody(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/server/api/providers.ts +git commit -m "refactor: update provider API — add presets endpoint, simplify activate, add official route" +``` + +--- + +### Task 5: Rewrite Frontend Types + API + Store + +**Files:** +- Rewrite: `desktop/src/types/provider.ts` +- Rewrite: `desktop/src/api/providers.ts` +- Rewrite: `desktop/src/stores/providerStore.ts` + +- [ ] **Step 1: Rewrite frontend types** + +Replace `desktop/src/types/provider.ts`: + +```typescript +// desktop/src/types/provider.ts + +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string +} + +export type SavedProvider = { + id: string + presetId: string + name: string + apiKey: string // masked from server + baseUrl: string + models: ModelMapping + notes?: string +} + +export type CreateProviderInput = { + presetId: string + name: string + apiKey: string + baseUrl: string + models: ModelMapping + notes?: string +} + +export type UpdateProviderInput = { + name?: string + apiKey?: string + baseUrl?: string + models?: ModelMapping + notes?: string +} + +export type TestProviderConfigInput = { + baseUrl: string + apiKey: string + modelId: string +} + +export type ProviderTestResult = { + success: boolean + latencyMs: number + error?: string + modelUsed?: string + httpStatus?: number +} +``` + +- [ ] **Step 2: Rewrite frontend API client** + +Replace `desktop/src/api/providers.ts`: + +```typescript +// desktop/src/api/providers.ts + +import { api } from './client' +import type { + SavedProvider, + CreateProviderInput, + UpdateProviderInput, + TestProviderConfigInput, + ProviderTestResult, +} from '../types/provider' +import type { ProviderPreset } from '../config/providerPresets' + +type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } +type ProviderResponse = { provider: SavedProvider } +type PresetsResponse = { presets: ProviderPreset[] } +type TestResultResponse = { result: ProviderTestResult } + +export const providersApi = { + list() { + return api.get('/api/providers') + }, + + presets() { + return api.get('/api/providers/presets') + }, + + create(input: CreateProviderInput) { + return api.post('/api/providers', input) + }, + + update(id: string, input: UpdateProviderInput) { + return api.put(`/api/providers/${id}`, input) + }, + + delete(id: string) { + return api.delete<{ ok: true }>(`/api/providers/${id}`) + }, + + activate(id: string) { + return api.post<{ ok: true }>(`/api/providers/${id}/activate`) + }, + + activateOfficial() { + return api.post<{ ok: true }>('/api/providers/official') + }, + + test(id: string) { + return api.post(`/api/providers/${id}/test`) + }, + + testConfig(input: TestProviderConfigInput) { + return api.post('/api/providers/test', input) + }, +} +``` + +- [ ] **Step 3: Rewrite frontend store** + +Replace `desktop/src/stores/providerStore.ts`: + +```typescript +// desktop/src/stores/providerStore.ts + +import { create } from 'zustand' +import { providersApi } from '../api/providers' +import type { + SavedProvider, + CreateProviderInput, + UpdateProviderInput, + TestProviderConfigInput, + ProviderTestResult, +} from '../types/provider' + +type ProviderStore = { + providers: SavedProvider[] + activeId: string | null + isLoading: boolean + + fetchProviders: () => Promise + createProvider: (input: CreateProviderInput) => Promise + updateProvider: (id: string, input: UpdateProviderInput) => Promise + deleteProvider: (id: string) => Promise + activateProvider: (id: string) => Promise + activateOfficial: () => Promise + testProvider: (id: string) => Promise + testConfig: (input: TestProviderConfigInput) => Promise +} + +export const useProviderStore = create((set, get) => ({ + providers: [], + activeId: null, + isLoading: false, + + fetchProviders: async () => { + set({ isLoading: true }) + try { + const { providers, activeId } = await providersApi.list() + set({ providers, activeId, isLoading: false }) + } catch { + set({ isLoading: false }) + } + }, + + createProvider: async (input) => { + const { provider } = await providersApi.create(input) + await get().fetchProviders() + return provider + }, + + updateProvider: async (id, input) => { + const { provider } = await providersApi.update(id, input) + await get().fetchProviders() + return provider + }, + + deleteProvider: async (id) => { + await providersApi.delete(id) + await get().fetchProviders() + }, + + activateProvider: async (id) => { + await providersApi.activate(id) + await get().fetchProviders() + }, + + activateOfficial: async () => { + await providersApi.activateOfficial() + await get().fetchProviders() + }, + + testProvider: async (id) => { + const { result } = await providersApi.test(id) + return result + }, + + testConfig: async (input) => { + const { result } = await providersApi.testConfig(input) + return result + }, +})) +``` + +- [ ] **Step 4: Commit** + +```bash +git add desktop/src/types/provider.ts desktop/src/api/providers.ts desktop/src/stores/providerStore.ts +git commit -m "refactor: rewrite frontend provider types, API client, and store for preset system" +``` + +--- + +### Task 6: Rewrite ProviderSettings UI in Settings.tsx + +**Files:** +- Rewrite: `desktop/src/pages/Settings.tsx` (ProviderSettings + ProviderFormModal sections only, lines 65-396) + +- [ ] **Step 1: Rewrite ProviderSettings and ProviderFormModal** + +Replace the `ProviderSettings` function and `ProviderFormModal` function in `desktop/src/pages/Settings.tsx`. Keep the imports, `Settings` component, `TabButton`, `PermissionSettings`, and `GeneralSettings` unchanged. + +Update the imports at the top of the file: + +```typescript +import { useState, useEffect } from 'react' +import { useSettingsStore } from '../stores/settingsStore' +import { useProviderStore } from '../stores/providerStore' +import { useUIStore } from '../stores/uiStore' +import { Modal } from '../components/shared/Modal' +import { Input } from '../components/shared/Input' +import { Button } from '../components/shared/Button' +import { PROVIDER_PRESETS } from '../config/providerPresets' +import type { PermissionMode, EffortLevel } from '../types/settings' +import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' +``` + +New `ProviderSettings`: + +```typescript +function ProviderSettings() { + const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore() + const fetchSettings = useSettingsStore((s) => s.fetchAll) + const [editingProvider, setEditingProvider] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [testResults, setTestResults] = useState>({}) + + useEffect(() => { fetchProviders() }, [fetchProviders]) + + const handleDelete = async (provider: SavedProvider) => { + if (activeId === provider.id) return + if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return + await deleteProvider(provider.id).catch(console.error) + } + + const handleTest = async (provider: SavedProvider) => { + setTestResults((r) => ({ ...r, [provider.id]: { loading: true } })) + try { + const result = await testProvider(provider.id) + setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } })) + } catch { + setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } })) + } + } + + const handleActivate = async (id: string) => { + await activateProvider(id) + await fetchSettings() + } + + const handleActivateOfficial = async () => { + await activateOfficial() + await fetchSettings() + } + + const isOfficialActive = activeId === null + + return ( +
+
+
+

Providers

+

Manage API providers for model access.

+
+ +
+ + {/* Official provider card */} +
!isOfficialActive && handleActivateOfficial()} + > + +
+
+ Claude Official + {isOfficialActive && ( + ACTIVE + )} +
+
Anthropic native — no API key required
+
+
+ + {/* Saved providers */} + {isLoading && providers.length === 0 ? ( +
+
+
+ ) : ( +
+ {providers.map((provider) => { + const isActive = activeId === provider.id + const test = testResults[provider.id] + const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId) + return ( +
+ +
+
+ {provider.name} + {preset && preset.id !== 'custom' && ( + {preset.name} + )} + {isActive && ( + ACTIVE + )} +
+
+ {provider.baseUrl} · {provider.models.main} +
+ {test && !test.loading && test.result && ( +
+ {test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`} +
+ )} +
+
+ {!isActive && ( + + )} + + + {!isActive && ( + + )} +
+
+ ) + })} +
+ )} + + {/* Create Modal */} + setShowCreateModal(false)} mode="create" /> + + {/* Edit Modal */} + {editingProvider && ( + setEditingProvider(null)} mode="edit" provider={editingProvider} /> + )} +
+ ) +} +``` + +New `ProviderFormModal`: + +```typescript +type ProviderFormProps = { + open: boolean + onClose: () => void + mode: 'create' | 'edit' + provider?: SavedProvider +} + +function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { + const { createProvider, updateProvider, testConfig } = useProviderStore() + const fetchSettings = useSettingsStore((s) => s.fetchAll) + + // Exclude 'official' from create presets — it has its own card + const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') + const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0] + + const [selectedPreset, setSelectedPreset] = useState(initialPreset) + const [name, setName] = useState(provider?.name ?? initialPreset.name) + const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) + const [apiKey, setApiKey] = useState('') + const [notes, setNotes] = useState(provider?.notes ?? '') + const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) + const [showAdvanced, setShowAdvanced] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [testResult, setTestResult] = useState(null) + const [isTesting, setIsTesting] = useState(false) + + const handlePresetChange = (preset: typeof initialPreset) => { + setSelectedPreset(preset) + setName(preset.name) + setBaseUrl(preset.baseUrl) + setModels({ ...preset.defaultModels }) + setTestResult(null) + } + + const isCustom = selectedPreset.id === 'custom' + const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() + + const handleSubmit = async () => { + if (!canSubmit) return + setIsSubmitting(true) + try { + if (mode === 'create') { + await createProvider({ + presetId: selectedPreset.id, + name: name.trim(), + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim(), + models, + notes: notes.trim() || undefined, + }) + } else if (provider) { + const input: UpdateProviderInput = { + name: name.trim(), + baseUrl: baseUrl.trim(), + models, + notes: notes.trim() || undefined, + } + if (apiKey.trim()) input.apiKey = apiKey.trim() + await updateProvider(provider.id, input) + if (useProviderStore.getState().activeId === provider.id) await fetchSettings() + } + onClose() + } catch (err) { + console.error('Failed to save provider:', err) + } finally { + setIsSubmitting(false) + } + } + + const handleTest = async () => { + if (!baseUrl.trim() || !models.main.trim()) return + setIsTesting(true) + setTestResult(null) + try { + let result: ProviderTestResult + if (mode === 'edit' && provider && !apiKey.trim()) { + result = await useProviderStore.getState().testProvider(provider.id) + } else { + if (!apiKey.trim()) return + result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() }) + } + setTestResult(result) + } catch { + setTestResult({ success: false, latencyMs: 0, error: 'Request failed' }) + } finally { + setIsTesting(false) + } + } + + return ( + + + + + } + > +
+ {/* Preset chips (create mode only) */} + {mode === 'create' && ( +
+ +
+ {availablePresets.map((preset) => ( + + ))} +
+
+ )} + + setName(e.target.value)} placeholder="Provider name" /> + + {/* Base URL — always visible for custom, read-only hint for presets */} + {isCustom || mode === 'edit' ? ( + setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> + ) : ( +
+ +
+ {baseUrl} +
+
+ )} + + setApiKey(e.target.value)} + placeholder={mode === 'edit' ? '****' : 'sk-...'} + /> + + setNotes(e.target.value)} placeholder="Optional notes..." /> + + {/* Advanced: Model Mapping */} +
+ + {showAdvanced && ( +
+ setModels({ ...models, main: e.target.value })} placeholder="Model ID" /> + setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" /> + setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" /> + setModels({ ...models, opus: e.target.value })} placeholder="Same as main" /> +
+ )} +
+ + {/* Test connection */} +
+ + {testResult && ( + + {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} + + )} +
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/src/pages/Settings.tsx +git commit -m "feat: rewrite ProviderSettings UI — preset chips, simplified form, official card" +``` + +--- + +### Task 7: Clean Up Unused Code + +**Files:** +- Modify: `desktop/src/pages/Settings.tsx` (remove unused imports) + +- [ ] **Step 1: Verify no unused imports** + +Check that the imports at the top of Settings.tsx match what's used. The old `Provider`, `ProviderModel` imports should be removed, replaced with `SavedProvider`, `ModelMapping`. `ProviderTestResult` and `UpdateProviderInput` should remain. + +Remove `ProviderModel` from type imports if still present (it no longer exists). + +- [ ] **Step 2: Remove old `~/.claude/providers.json`** + +No code change needed — the old file is just ignored. The new storage path is `~/.claude/cc-haha/providers.json`. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/src/pages/Settings.tsx +git commit -m "chore: clean up unused imports after provider preset migration" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-04-07-provider-preset-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session, batch execution with checkpoints + +Which approach? \ No newline at end of file