diff --git a/docs/features/mirror-claude.md b/docs/features/mirror-claude.md index ea34617..c5803b7 100644 --- a/docs/features/mirror-claude.md +++ b/docs/features/mirror-claude.md @@ -70,20 +70,20 @@ No API key required at setup. When you run `mclaude`, authenticate via: ## 📊 Provider Comparison ``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Feature │ zai │ minimax │ openrouter │ mirror │ -│ ─────────────────────┼──────────┼──────────┼────────────┼─────────────────│ -│ Model │ GLM-4.7 │ M2.1 │ You choose │ Claude (native) │ -│ Auth Mode │ API Key │ API Key │ Auth Token │ OAuth or Key │ -│ ANTHROPIC_BASE_URL │ ✓ Set │ ✓ Set │ ✓ Set │ ✗ Not set │ -│ ANTHROPIC_API_KEY │ ✓ Set │ ✓ Set │ ✗ │ ✗ Not set │ -│ Model Mappings │ ✓ Auto │ ✓ Auto │ ✓ Required │ ✗ Not set │ -│ Prompt Pack │ ✓ Full │ ✓ Full │ ✗ │ ✗ Pure │ -│ Team Mode │ Optional │ Optional │ Optional │ ✓ Default │ -│ Config Isolation │ ✓ │ ✓ │ ✓ │ ✓ │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Feature │ zai │ minimax │ openrouter │ poe │ mirror │ +│ ─────────────────────┼──────────┼──────────┼────────────┼────────────┼─────────────────│ +│ Model │ GLM-4.7 │ M2.1 │ You choose │ Claude │ Claude (native) │ +│ Auth Mode │ API Key │ API Key │ Auth Token │ Auth Token │ OAuth or Key │ +│ ANTHROPIC_BASE_URL │ ✓ Set │ ✓ Set │ ✓ Set │ ✓ Set │ ✗ Not set │ +│ ANTHROPIC_API_KEY │ ✓ Set │ ✓ Set │ ✗ │ ✗ │ ✗ Not set │ +│ Model Mappings │ ✓ Auto │ ✓ Auto │ ✓ Required │ ✗ None │ ✗ Not set │ +│ Prompt Pack │ ✓ Full │ ✓ Full │ ✗ │ ✗ Pure │ ✗ Pure │ +│ Team Mode │ Optional │ Optional │ Optional │ Optional │ ✓ Default │ +│ Config Isolation │ ✓ │ ✓ │ ✓ │ ✓ │ ✓ │ +│ │ +└───────────────────────────────────────────────────────────────────────────────────────────┘ ``` --- diff --git a/src/brands/index.ts b/src/brands/index.ts index 0c1db3d..550906b 100644 --- a/src/brands/index.ts +++ b/src/brands/index.ts @@ -4,6 +4,7 @@ import { buildMinimaxTweakccConfig } from './minimax.js'; import { buildOpenRouterTweakccConfig } from './openrouter.js'; import { buildCCRouterTweakccConfig } from './ccrouter.js'; import { buildMirrorTweakccConfig } from './mirror.js'; +import { buildPoeTweakccConfig } from './poe.js'; export interface BrandPreset { key: string; @@ -43,6 +44,12 @@ const BRAND_PRESETS: Record = { description: 'Reflective silver/chrome theme for pure Claude Code experience.', buildTweakccConfig: buildMirrorTweakccConfig, }, + poe: { + key: 'poe', + label: 'Poe Violet', + description: 'Violet/purple theme for Poe API integration.', + buildTweakccConfig: buildPoeTweakccConfig, + }, }; export const listBrandPresets = (): BrandPreset[] => Object.values(BRAND_PRESETS); diff --git a/src/brands/poe.ts b/src/brands/poe.ts new file mode 100644 index 0000000..9a96f1f --- /dev/null +++ b/src/brands/poe.ts @@ -0,0 +1,208 @@ +import type { TweakccConfig, Theme } from './types.js'; +import { DEFAULT_THEMES } from './defaultThemes.js'; +import { formatUserMessage, getUserLabel } from './userLabel.js'; + +/** + * Poe blocked tools - currently none + * Poe provides a clean Claude API interface without restrictions + */ +export const POE_BLOCKED_TOOLS: string[] = []; + +type Rgb = { r: number; g: number; b: number }; + +const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value))); + +const hexToRgb = (hex: string): Rgb => { + const normalized = hex.replace('#', '').trim(); + if (normalized.length === 3) { + const [r, g, b] = normalized.split(''); + return { + r: clamp(parseInt(r + r, 16)), + g: clamp(parseInt(g + g, 16)), + b: clamp(parseInt(b + b, 16)), + }; + } + if (normalized.length !== 6) { + throw new Error(`Unsupported hex color: ${hex}`); + } + return { + r: clamp(parseInt(normalized.slice(0, 2), 16)), + g: clamp(parseInt(normalized.slice(2, 4), 16)), + b: clamp(parseInt(normalized.slice(4, 6), 16)), + }; +}; + +const rgb = (hex: string) => { + const { r, g, b } = hexToRgb(hex); + return `rgb(${r},${g},${b})`; +}; + +const mix = (hexA: string, hexB: string, weight: number) => { + const a = hexToRgb(hexA); + const b = hexToRgb(hexB); + const w = Math.max(0, Math.min(1, weight)); + return `rgb(${clamp(a.r + (b.r - a.r) * w)},${clamp(a.g + (b.g - a.g) * w)},${clamp(a.b + (b.b - a.b) * w)})`; +}; + +const lighten = (hex: string, weight: number) => mix(hex, '#ffffff', weight); + +const palette = { + base: '#0f0a1a', + surface: '#1a1428', + panel: '#221c33', + border: '#3a2f52', + borderStrong: '#4d3f6b', + text: '#ede9f5', + textMuted: '#d1c4e3', + textDim: '#9b88bc', + violet: '#8b5cf6', + violetSoft: '#a78bfa', + violetDeep: '#6d28d9', + blue: '#7c3aed', + blueSoft: '#a78bfa', + blueDeep: '#5b21b6', + green: '#34d399', + red: '#f43f5e', + orange: '#fb923c', + purple: '#c084fc', +}; + +const theme: Theme = { + name: 'Poe Violet', + id: 'poe-violet', + colors: { + autoAccept: rgb(palette.purple), + bashBorder: rgb(palette.violet), + claude: rgb(palette.violet), + claudeShimmer: rgb(palette.violetSoft), + claudeBlue_FOR_SYSTEM_SPINNER: rgb(palette.blue), + claudeBlueShimmer_FOR_SYSTEM_SPINNER: rgb(palette.blueSoft), + permission: rgb(palette.blue), + permissionShimmer: rgb(palette.blueSoft), + planMode: rgb(palette.green), + ide: rgb(palette.blueSoft), + promptBorder: rgb(palette.border), + promptBorderShimmer: rgb(palette.borderStrong), + text: rgb(palette.text), + inverseText: rgb(palette.base), + inactive: rgb(palette.textDim), + subtle: rgb(palette.border), + suggestion: rgb(palette.blueSoft), + remember: rgb(palette.violet), + background: rgb(palette.base), + success: rgb(palette.green), + error: rgb(palette.red), + warning: rgb(palette.orange), + warningShimmer: rgb(palette.violetSoft), + diffAdded: mix(palette.base, palette.green, 0.18), + diffRemoved: mix(palette.base, palette.red, 0.18), + diffAddedDimmed: mix(palette.base, palette.green, 0.1), + diffRemovedDimmed: mix(palette.base, palette.red, 0.1), + diffAddedWord: mix(palette.base, palette.green, 0.45), + diffRemovedWord: mix(palette.base, palette.red, 0.45), + diffAddedWordDimmed: mix(palette.base, palette.green, 0.3), + diffRemovedWordDimmed: mix(palette.base, palette.red, 0.3), + red_FOR_SUBAGENTS_ONLY: rgb(palette.red), + blue_FOR_SUBAGENTS_ONLY: rgb(palette.blueDeep), + green_FOR_SUBAGENTS_ONLY: rgb(palette.green), + yellow_FOR_SUBAGENTS_ONLY: rgb(palette.orange), + purple_FOR_SUBAGENTS_ONLY: rgb(palette.purple), + orange_FOR_SUBAGENTS_ONLY: rgb(palette.orange), + pink_FOR_SUBAGENTS_ONLY: rgb(palette.violetSoft), + cyan_FOR_SUBAGENTS_ONLY: rgb(palette.blueSoft), + professionalBlue: rgb(palette.blueSoft), + rainbow_red: rgb(palette.red), + rainbow_orange: rgb(palette.orange), + rainbow_yellow: rgb(palette.orange), + rainbow_green: rgb(palette.green), + rainbow_blue: rgb(palette.blue), + rainbow_indigo: rgb(palette.blueDeep), + rainbow_violet: rgb(palette.purple), + rainbow_red_shimmer: lighten(palette.red, 0.35), + rainbow_orange_shimmer: lighten(palette.orange, 0.35), + rainbow_yellow_shimmer: lighten(palette.orange, 0.25), + rainbow_green_shimmer: lighten(palette.green, 0.35), + rainbow_blue_shimmer: lighten(palette.blue, 0.35), + rainbow_indigo_shimmer: lighten(palette.blueDeep, 0.35), + rainbow_violet_shimmer: lighten(palette.purple, 0.35), + clawd_body: rgb(palette.violet), + clawd_background: rgb(palette.base), + userMessageBackground: rgb(palette.panel), + bashMessageBackgroundColor: rgb(palette.surface), + memoryBackgroundColor: rgb(palette.panel), + rate_limit_fill: rgb(palette.violet), + rate_limit_empty: rgb(palette.borderStrong), + }, +}; + +export const buildPoeTweakccConfig = (): TweakccConfig => ({ + ccVersion: '', + ccInstallationPath: null, + lastModified: new Date().toISOString(), + changesApplied: false, + hidePiebaldAnnouncement: true, + settings: { + themes: [theme, ...DEFAULT_THEMES], + thinkingVerbs: { + format: '{}... ', + verbs: [ + 'Channeling', + 'Conjuring', + 'Manifesting', + 'Synthesizing', + 'Crystallizing', + 'Transmuting', + 'Distilling', + 'Weaving', + 'Harmonizing', + 'Resonating', + 'Illuminating', + 'Transcending', + 'Converging', + 'Materializing', + 'Orchestrating', + 'Attuning', + 'Aligning', + 'Unifying', + ], + }, + thinkingStyle: { + updateInterval: 110, + phases: ['◇', '◆', '◇', '◆'], + reverseMirror: false, + }, + userMessageDisplay: { + format: formatUserMessage(getUserLabel()), + styling: ['bold'], + foregroundColor: 'default', + backgroundColor: 'default', + borderStyle: 'topBottomBold', + borderColor: rgb(palette.violet), + paddingX: 1, + paddingY: 0, + fitBoxToContent: true, + }, + inputBox: { + removeBorder: true, + }, + misc: { + showTweakccVersion: false, + showPatchesApplied: false, + expandThinkingBlocks: true, + enableConversationTitle: true, + hideStartupBanner: true, + hideCtrlGToEditPrompt: true, + hideStartupClawd: true, + increaseFileReadLimit: true, + }, + toolsets: [ + { + name: 'poe', + allowedTools: '*', + blockedTools: POE_BLOCKED_TOOLS, + }, + ], + defaultToolset: 'poe', + planModeToolset: 'poe', + }, +}); diff --git a/src/core/wrapper.ts b/src/core/wrapper.ts index 734cb5c..e3606ac 100644 --- a/src/core/wrapper.ts +++ b/src/core/wrapper.ts @@ -34,6 +34,11 @@ const C = { mirSecondary: '\x1b[38;5;250m', // Platinum mirAccent: '\x1b[38;5;45m', // Electric cyan mirDim: '\x1b[38;5;243m', // Muted silver + // Poe: Violet/Purple gradient + poePrimary: '\x1b[38;5;135m', // Violet + poeSecondary: '\x1b[38;5;141m', // Light violet + poeAccent: '\x1b[38;5;99m', // Deep purple + poeDim: '\x1b[38;5;97m', // Muted violet // Default: White/Gray defPrimary: '\x1b[38;5;255m', // White defDim: '\x1b[38;5;245m', // Gray @@ -111,6 +116,19 @@ const SPLASH_ART: SplashArt = { `${C.mirSecondary} Claude ${C.mirDim}━${C.mirSecondary} Pure Reflection${C.reset}`, '', ], + poe: [ + '', + `${C.poePrimary} ██████╗ ██████╗ ███████╗${C.reset}`, + `${C.poePrimary} ██╔══██╗██╔═══██╗██╔════╝${C.reset}`, + `${C.poeSecondary} ██████╔╝██║ ██║█████╗${C.reset}`, + `${C.poeSecondary} ██╔═══╝ ██║ ██║██╔══╝${C.reset}`, + `${C.poeAccent} ██║ ╚██████╔╝███████╗${C.reset}`, + `${C.poeAccent} ╚═╝ ╚═════╝ ╚══════╝${C.reset}`, + '', + `${C.poeDim} ━━━━━━━━━${C.poePrimary}◆${C.poeDim}━━━━━━━━━${C.reset}`, + `${C.poeSecondary} Claude via Poe${C.reset}`, + '', + ], default: [ '', `${C.defPrimary} ██████╗ ██████╗ ${C.defDim}━━ M I R R O R${C.reset}`, @@ -123,7 +141,7 @@ const SPLASH_ART: SplashArt = { ], }; -const KNOWN_SPLASH_STYLES = ['zai', 'minimax', 'openrouter', 'ccrouter', 'mirror']; +const KNOWN_SPLASH_STYLES = ['zai', 'minimax', 'openrouter', 'ccrouter', 'mirror', 'poe']; const buildWindowsWrapperScript = (opts: { configDir: string; @@ -343,6 +361,12 @@ export const writeWrapper = ( 'CCMMIR', ' __cc_show_label="0"', ' ;;', + ' poe)', + " cat <<'CCMPOE'", + ...SPLASH_ART.poe, + 'CCMPOE', + ' __cc_show_label="0"', + ' ;;', ' *)', " cat <<'CCMGEN'", ...SPLASH_ART.default, diff --git a/src/providers/index.ts b/src/providers/index.ts index 32cc7b7..cd31781 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -116,6 +116,21 @@ const PROVIDERS: Record = { requiresModelMapping: false, // Models configured in ~/.claude-code-router/config.json credentialOptional: true, // No API key needed - CCRouter handles auth }, + poe: { + key: 'poe', + label: 'Poe', + description: 'Claude via Poe - save up to 15% with unified billing', + baseUrl: 'https://api.poe.com', + env: { + API_TIMEOUT_MS: DEFAULT_TIMEOUT_MS, + CC_MIRROR_SPLASH: 1, + CC_MIRROR_PROVIDER_LABEL: 'Poe', + CC_MIRROR_SPLASH_STYLE: 'poe', + }, + apiKeyLabel: 'Poe API key', + authMode: 'authToken', + noPromptPack: true, + }, custom: { key: 'custom', label: 'Custom', diff --git a/test/e2e/providers.ts b/test/e2e/providers.ts index b4a3c1b..9fce946 100644 --- a/test/e2e/providers.ts +++ b/test/e2e/providers.ts @@ -35,4 +35,12 @@ export const PROVIDERS = [ expectedSplashStyle: 'ccrouter', colorCode: '\\x1b[38;5;39m', // Sky blue }, + { + key: 'poe', + name: 'Poe', + apiKey: 'test-poe-key', + expectedThemeId: 'poe-violet', + expectedSplashStyle: 'poe', + colorCode: '\\x1b[38;5;135m', // Violet + }, ]; diff --git a/test/unit/brands/index.test.ts b/test/unit/brands/index.test.ts new file mode 100644 index 0000000..7b4f2f6 --- /dev/null +++ b/test/unit/brands/index.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getBrandPreset, listBrandPresets, resolveBrandKey } from '../../../src/brands/index.js'; + +test('Brand index includes poe', async (t) => { + await t.test('getBrandPreset returns poe preset', () => { + const preset = getBrandPreset('poe'); + assert.ok(preset, 'poe preset should exist'); + assert.equal(preset.key, 'poe'); + assert.equal(preset.label, 'Poe Violet'); + }); + + await t.test('listBrandPresets includes poe', () => { + const presets = listBrandPresets(); + const poe = presets.find((p) => p.key === 'poe'); + assert.ok(poe, 'poe should be in preset list'); + }); + + await t.test('resolveBrandKey auto-resolves poe', () => { + const key = resolveBrandKey('poe', 'auto'); + assert.equal(key, 'poe'); + }); +}); diff --git a/test/unit/brands/poe.test.ts b/test/unit/brands/poe.test.ts new file mode 100644 index 0000000..8f38c8e --- /dev/null +++ b/test/unit/brands/poe.test.ts @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPoeTweakccConfig, POE_BLOCKED_TOOLS } from '../../../src/brands/poe.js'; + +test('Poe brand configuration', async (t) => { + await t.test('buildPoeTweakccConfig returns valid config', () => { + const config = buildPoeTweakccConfig(); + assert.ok(config.settings, 'config should have settings'); + assert.ok(config.settings.themes, 'config should have themes'); + assert.ok(config.settings.themes.length > 0, 'should have at least one theme'); + assert.equal(config.settings.themes[0].id, 'poe-violet'); + assert.equal(config.settings.themes[0].name, 'Poe Violet'); + }); + + await t.test('POE_BLOCKED_TOOLS is empty array', () => { + assert.ok(Array.isArray(POE_BLOCKED_TOOLS)); + assert.equal(POE_BLOCKED_TOOLS.length, 0, 'Poe should not block any tools'); + }); + + await t.test('config has poe toolset', () => { + const config = buildPoeTweakccConfig(); + const toolsets = config.settings.toolsets; + assert.ok( + toolsets.some((t) => t.name === 'poe'), + 'should have poe toolset' + ); + }); +}); diff --git a/test/unit/providers/poe.test.ts b/test/unit/providers/poe.test.ts new file mode 100644 index 0000000..4971746 --- /dev/null +++ b/test/unit/providers/poe.test.ts @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getProvider, buildEnv } from '../../../src/providers/index.js'; + +test('Poe provider definition', async (t) => { + await t.test('getProvider returns poe provider', () => { + const provider = getProvider('poe'); + assert.ok(provider, 'poe provider should exist'); + assert.equal(provider.key, 'poe'); + assert.equal(provider.baseUrl, 'https://api.poe.com'); + assert.equal(provider.authMode, 'authToken'); + }); + + await t.test('buildEnv sets ANTHROPIC_AUTH_TOKEN for poe', () => { + const env = buildEnv({ + providerKey: 'poe', + baseUrl: 'https://api.poe.com', + apiKey: 'test-poe-key', + }); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'test-poe-key'); + assert.ok(!('ANTHROPIC_API_KEY' in env), 'ANTHROPIC_API_KEY should not be set'); + assert.equal(env.ANTHROPIC_BASE_URL, 'https://api.poe.com'); + }); +});