diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8452b0f..95efb13 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,12 +12,6 @@ on: jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest permissions: contents: read @@ -32,6 +26,8 @@ jobs: fetch-depth: 1 - name: Run Claude Code Review + # Only run if CLAUDE_CODE_OAUTH_TOKEN is configured + if: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} id: claude-review uses: anthropics/claude-code-action@v1 with: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..fdd2938 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -31,6 +31,8 @@ jobs: fetch-depth: 1 - name: Run Claude Code + # Only run if CLAUDE_CODE_OAUTH_TOKEN is configured + if: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} id: claude uses: anthropics/claude-code-action@v1 with: diff --git a/README.md b/README.md index b82dadf..2c58a2e 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,12 @@ npx cc-mirror quick --provider minimax --api-key "$MINIMAX_API_KEY" npx cc-mirror quick --provider openrouter --api-key "$OPENROUTER_API_KEY" \ --model-sonnet "anthropic/claude-sonnet-4-20250514" +# GatewayZ (OneRouter gateway) +npx cc-mirror quick --provider gatewayz --api-key "$GATEWAYZ_API_KEY" \ + --model-sonnet "claude-sonnet-4-20250514" \ + --model-opus "claude-opus-4-5-20251101" \ + --model-haiku "claude-haiku-3-5-20241022" + # Claude Code Router (local LLMs) npx cc-mirror quick --provider ccrouter ``` @@ -298,14 +304,14 @@ minimax # Run MiniMax variant ## CLI Options ``` ---provider mirror | zai | minimax | openrouter | ccrouter | custom +--provider mirror | zai | minimax | gatewayz | openrouter | ccrouter | custom --name Variant name (becomes the CLI command) --api-key Provider API key --base-url Custom API endpoint ---model-sonnet Map to sonnet model ---model-opus Map to opus model ---model-haiku Map to haiku model ---brand Theme: auto | zai | minimax | openrouter | ccrouter | mirror +--model-sonnet Map to sonnet model (for providers requiring model mapping) +--model-opus Map to opus model (for providers requiring model mapping) +--model-haiku Map to haiku model (for providers requiring model mapping) +--brand Theme: auto | zai | minimax | gatewayz | openrouter | ccrouter | mirror --no-tweak Skip tweakcc theme --no-prompt-pack Skip provider prompt pack --verbose Show full tweakcc output during update @@ -322,6 +328,7 @@ Each provider includes a custom color theme via [tweakcc](https://github.com/Pie | **mirror** | Silver/chrome with electric blue | | **zai** | Dark carbon with gold accents | | **minimax** | Coral/red/orange spectrum | +| **gatewayz** | Dark portal with violet/purple | | **openrouter** | Teal/cyan gradient | | **ccrouter** | Sky blue accents | diff --git a/package-lock.json b/package-lock.json index 6adf83e..c955f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1175,7 +1174,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1225,7 +1223,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1465,7 +1462,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1821,7 +1817,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2621,7 +2616,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3644,7 +3638,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -5338,7 +5331,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6273,7 +6265,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6511,7 +6502,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7080,7 +7070,6 @@ "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "devOptional": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/brands/gatewayz.ts b/src/brands/gatewayz.ts new file mode 100644 index 0000000..fe87654 --- /dev/null +++ b/src/brands/gatewayz.ts @@ -0,0 +1,199 @@ +import type { TweakccConfig, Theme } from './types.js'; +import { DEFAULT_THEMES } from './defaultThemes.js'; +import { formatUserMessage, getUserLabel } from './userLabel.js'; + +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); + +// GatewayZ palette - violet/purple gateway theme with tech accents +const palette = { + base: '#1a1625', + surface: '#221d2e', + panel: '#2a2438', + border: '#3d3650', + borderStrong: '#524a68', + text: '#e8e6ec', + textMuted: '#c4c0cc', + textDim: '#8a8494', + // Primary violet/purple gateway colors + violet: '#8b5cf6', + violetSoft: '#a78bfa', + violetDeep: '#6d28d9', + // Accent colors + cyan: '#22d3ee', + cyanSoft: '#67e8f9', + green: '#34d399', + red: '#f87171', + orange: '#fb923c', + pink: '#f472b6', +}; + +const theme: Theme = { + name: 'GatewayZ Portal', + id: 'gatewayz-portal', + colors: { + autoAccept: rgb(palette.green), + bashBorder: rgb(palette.violet), + claude: rgb(palette.violet), + claudeShimmer: rgb(palette.violetSoft), + claudeBlue_FOR_SYSTEM_SPINNER: rgb(palette.cyan), + claudeBlueShimmer_FOR_SYSTEM_SPINNER: rgb(palette.cyanSoft), + permission: rgb(palette.cyan), + permissionShimmer: rgb(palette.cyanSoft), + planMode: rgb(palette.green), + ide: rgb(palette.cyanSoft), + 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.cyanSoft), + remember: rgb(palette.violet), + background: rgb(palette.base), + success: rgb(palette.green), + error: rgb(palette.red), + warning: rgb(palette.orange), + warningShimmer: lighten(palette.orange, 0.25), + 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.violetDeep), + green_FOR_SUBAGENTS_ONLY: rgb(palette.green), + yellow_FOR_SUBAGENTS_ONLY: rgb(palette.orange), + purple_FOR_SUBAGENTS_ONLY: rgb(palette.violet), + orange_FOR_SUBAGENTS_ONLY: rgb(palette.orange), + pink_FOR_SUBAGENTS_ONLY: rgb(palette.pink), + cyan_FOR_SUBAGENTS_ONLY: rgb(palette.cyan), + professionalBlue: rgb(palette.cyanSoft), + rainbow_red: rgb(palette.red), + rainbow_orange: rgb(palette.orange), + rainbow_yellow: lighten(palette.orange, 0.2), + rainbow_green: rgb(palette.green), + rainbow_blue: rgb(palette.cyan), + rainbow_indigo: rgb(palette.violetDeep), + rainbow_violet: rgb(palette.violet), + rainbow_red_shimmer: lighten(palette.red, 0.35), + rainbow_orange_shimmer: lighten(palette.orange, 0.35), + rainbow_yellow_shimmer: lighten(palette.orange, 0.4), + rainbow_green_shimmer: lighten(palette.green, 0.35), + rainbow_blue_shimmer: lighten(palette.cyan, 0.35), + rainbow_indigo_shimmer: lighten(palette.violetDeep, 0.35), + rainbow_violet_shimmer: lighten(palette.violet, 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 buildGatewayZTweakccConfig = (): TweakccConfig => ({ + ccVersion: '', + ccInstallationPath: null, + lastModified: new Date().toISOString(), + changesApplied: false, + hidePiebaldAnnouncement: true, + settings: { + themes: [theme, ...DEFAULT_THEMES], + thinkingVerbs: { + format: '{}... ', + verbs: [ + 'Routing', + 'Tunneling', + 'Bridging', + 'Connecting', + 'Relaying', + 'Forwarding', + 'Proxying', + 'Streaming', + 'Syncing', + 'Processing', + 'Resolving', + 'Mapping', + 'Transferring', + 'Linking', + ], + }, + thinkingStyle: { + updateInterval: 115, + 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: 'gatewayz', + allowedTools: '*', + }, + ], + defaultToolset: 'gatewayz', + planModeToolset: 'gatewayz', + }, +}); diff --git a/src/brands/index.ts b/src/brands/index.ts index 0c1db3d..acb861e 100644 --- a/src/brands/index.ts +++ b/src/brands/index.ts @@ -1,6 +1,7 @@ import type { TweakccConfig } from './types.js'; import { buildZaiTweakccConfig } from './zai.js'; import { buildMinimaxTweakccConfig } from './minimax.js'; +import { buildGatewayZTweakccConfig } from './gatewayz.js'; import { buildOpenRouterTweakccConfig } from './openrouter.js'; import { buildCCRouterTweakccConfig } from './ccrouter.js'; import { buildMirrorTweakccConfig } from './mirror.js'; @@ -25,6 +26,12 @@ const BRAND_PRESETS: Record = { description: 'Vibrant spectrum accents (red/orange/pink/violet) with MiniMax toolset label.', buildTweakccConfig: buildMinimaxTweakccConfig, }, + gatewayz: { + key: 'gatewayz', + label: 'GatewayZ Portal', + description: 'Dark portal palette with violet/purple accents and cyan highlights.', + buildTweakccConfig: buildGatewayZTweakccConfig, + }, openrouter: { key: 'openrouter', label: 'OpenRouter Teal', diff --git a/src/cli/commands/tasks.ts b/src/cli/commands/tasks.ts index c37968f..a60f41a 100644 --- a/src/cli/commands/tasks.ts +++ b/src/cli/commands/tasks.ts @@ -214,6 +214,15 @@ export async function runTasksCommand({ opts }: TasksCommandOptions): Promise = { + gatewayz: { + sonnet: 'claude-sonnet-4-20250514', + opus: 'claude-opus-4-5-20251101', + haiku: 'claude-haiku-3-5-20241022', + }, +}; + /** - * Ensure model mapping for providers that require it (e.g., OpenRouter, LiteLLM) + * Ensure model mapping for providers that require explicit model mapping * Prompts for missing models if not in --yes mode */ export async function ensureModelMapping( @@ -32,6 +41,15 @@ export async function ensureModelMapping( ): Promise { const provider = getProvider(providerKey); if (!provider?.requiresModelMapping) return overrides; + + // Apply provider-specific defaults if available + const defaults = PROVIDER_MODEL_DEFAULTS[providerKey]; + if (defaults) { + if (!overrides.sonnet?.trim()) overrides.sonnet = defaults.sonnet; + if (!overrides.opus?.trim()) overrides.opus = defaults.opus; + if (!overrides.haiku?.trim()) overrides.haiku = defaults.haiku; + } + const missing = { sonnet: (overrides.sonnet ?? '').trim().length === 0, opus: (overrides.opus ?? '').trim().length === 0, diff --git a/src/core/variant-builder/steps/WriteConfigStep.ts b/src/core/variant-builder/steps/WriteConfigStep.ts index 162db81..5e5c1cc 100644 --- a/src/core/variant-builder/steps/WriteConfigStep.ts +++ b/src/core/variant-builder/steps/WriteConfigStep.ts @@ -56,8 +56,8 @@ export class WriteConfigStep implements BuildStep { state.notes.push('ANTHROPIC_AUTH_TOKEN not set; provider auth may fail.'); } - // OpenRouter needs model mapping; CCRouter handles routing internally via its own config - if (params.providerKey === 'openrouter') { + // Providers with requiresModelMapping need model configuration + if (provider.requiresModelMapping) { const missing: string[] = []; if (!env.ANTHROPIC_DEFAULT_SONNET_MODEL) missing.push('ANTHROPIC_DEFAULT_SONNET_MODEL'); if (!env.ANTHROPIC_DEFAULT_OPUS_MODEL) missing.push('ANTHROPIC_DEFAULT_OPUS_MODEL'); diff --git a/src/core/variant-builder/team-mode-patch.ts b/src/core/variant-builder/team-mode-patch.ts index c1c6009..77887fb 100644 --- a/src/core/variant-builder/team-mode-patch.ts +++ b/src/core/variant-builder/team-mode-patch.ts @@ -3,6 +3,8 @@ export type TeamModeState = 'enabled' | 'disabled' | 'unknown'; const TODO_WRITE_MARKER = /(var|let|const)\s+[A-Za-z_$][\w$]*="TodoWrite";/; const IS_ENABLED_FN_RE = /isEnabled\(\)\{return!([A-Za-z_$][\w$]*)\(\)\}/; +const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const findTodoWriteGate = (content: string): { fnName: string } | null => { const markerIndex = content.search(TODO_WRITE_MARKER); if (markerIndex === -1) return null; @@ -15,7 +17,8 @@ const findTodoWriteGate = (content: string): { fnName: string } | null => { }; const findGateDefinition = (content: string, fnName: string): RegExpMatchArray | null => { - const fnDefRe = new RegExp(`function\\s+${fnName}\\(\\)\\{return(!0|!1)\\}`); + const escapedName = escapeRegExp(fnName); + const fnDefRe = new RegExp(`function\\s+${escapedName}\\(\\)\\{return(!0|!1)\\}`); return content.match(fnDefRe); }; @@ -36,7 +39,8 @@ export const setTeamModeEnabled = ( const gate = findTodoWriteGate(content); if (!gate) return { content, changed: false, state: 'unknown' }; - const fnDefRe = new RegExp(`function\\s+${gate.fnName}\\(\\)\\{return(!0|!1)\\}`); + const escapedName = escapeRegExp(gate.fnName); + const fnDefRe = new RegExp(`function\\s+${escapedName}\\(\\)\\{return(!0|!1)\\}`); const match = content.match(fnDefRe); if (!match) return { content, changed: false, state: 'unknown' }; diff --git a/src/core/wrapper.ts b/src/core/wrapper.ts index 734cb5c..06086cc 100644 --- a/src/core/wrapper.ts +++ b/src/core/wrapper.ts @@ -34,6 +34,12 @@ const C = { mirSecondary: '\x1b[38;5;250m', // Platinum mirAccent: '\x1b[38;5;45m', // Electric cyan mirDim: '\x1b[38;5;243m', // Muted silver + // GatewayZ: Purple/Violet gradient + gzPrimary: '\x1b[38;5;135m', // Violet + gzSecondary: '\x1b[38;5;141m', // Light violet + gzAccent: '\x1b[38;5;99m', // Deep purple + gzDim: '\x1b[38;5;97m', // Muted purple + gzCyan: '\x1b[38;5;51m', // Cyan accent // Default: White/Gray defPrimary: '\x1b[38;5;255m', // White defDim: '\x1b[38;5;245m', // Gray @@ -343,6 +349,22 @@ export const writeWrapper = ( 'CCMMIR', ' __cc_show_label="0"', ' ;;', + ' gatewayz)', + " cat <<'CCMGWZ'", + '', + `${C.gzPrimary} ██████╗ █████╗ ████████╗███████╗██╗ ██╗ █████╗ ██╗ ██╗${C.gzCyan}███████╗${C.reset}`, + `${C.gzPrimary} ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝██║ ██║██╔══██╗╚██╗ ██╔╝${C.gzCyan}╚══███╔╝${C.reset}`, + `${C.gzSecondary} ██║ ███╗███████║ ██║ █████╗ ██║ █╗ ██║███████║ ╚████╔╝ ${C.gzCyan} ███╔╝${C.reset}`, + `${C.gzSecondary} ██║ ██║██╔══██║ ██║ ██╔══╝ ██║███╗██║██╔══██║ ╚██╔╝ ${C.gzCyan} ███╔╝${C.reset}`, + `${C.gzAccent} ╚██████╔╝██║ ██║ ██║ ███████╗╚███╔███╔╝██║ ██║ ██║ ${C.gzCyan}███████╗${C.reset}`, + `${C.gzAccent} ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝ ${C.gzCyan}╚══════╝${C.reset}`, + '', + `${C.gzDim} ━━━━━━━━━━━━━━━━${C.gzPrimary}◆${C.gzDim}━━━━━━━━━━━━━━━━${C.reset}`, + `${C.gzSecondary} Your Gateway to AI${C.reset}`, + '', + 'CCMGWZ', + ' __cc_show_label="0"', + ' ;;', ' *)', " cat <<'CCMGEN'", ...SPLASH_ART.default, diff --git a/src/providers/index.ts b/src/providers/index.ts index 32cc7b7..b4122f4 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -85,6 +85,22 @@ const PROVIDERS: Record = { }, apiKeyLabel: 'MiniMax API key', }, + gatewayz: { + key: 'gatewayz', + label: 'GatewayZ', + description: 'OneRouter-compatible gateway for multiple AI models', + baseUrl: 'https://api.gatewayz.ai/v1', + env: { + API_TIMEOUT_MS: DEFAULT_TIMEOUT_MS, + ANTHROPIC_BASE_URL: 'https://api.gatewayz.ai/v1', + CC_MIRROR_SPLASH: 1, + CC_MIRROR_PROVIDER_LABEL: 'GatewayZ', + CC_MIRROR_SPLASH_STYLE: 'gatewayz', + }, + apiKeyLabel: 'GatewayZ API key', + authMode: 'authToken', + requiresModelMapping: true, + }, openrouter: { key: 'openrouter', label: 'OpenRouter', diff --git a/src/tui/app.tsx b/src/tui/app.tsx index ae5a952..1edd6bb 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -262,6 +262,18 @@ export const App: React.FC = ({ shellEnv: key === 'zai', }); + // Default model mappings for providers that have standard models + const getDefaultModels = (key?: string | null): { opus: string; sonnet: string; haiku: string } => { + if (key === 'gatewayz') { + return { + opus: 'claude-opus-4-5-20251101', + sonnet: 'claude-sonnet-4-20250514', + haiku: 'claude-haiku-3-5-20241022', + }; + } + return { opus: '', sonnet: '', haiku: '' }; + }; + const resolveZaiApiKey = (): { value: string; detectedFrom: string | null; @@ -565,6 +577,7 @@ export const App: React.FC = ({ onSelect={(value) => { const selected = providers.getProvider(value); const defaults = providerDefaults(value); + const modelDefaults = getDefaultModels(value); const keyDefaults = value === 'zai' ? resolveZaiApiKey() : { value: '', detectedFrom: null, skipPrompt: false }; setProviderKey(value); @@ -572,9 +585,9 @@ export const App: React.FC = ({ setBaseUrl(selected?.baseUrl || ''); setApiKey(keyDefaults.value); setApiKeyDetectedFrom(keyDefaults.detectedFrom); - setModelSonnet(''); - setModelOpus(''); - setModelHaiku(''); + setModelSonnet(modelDefaults.sonnet); + setModelOpus(modelDefaults.opus); + setModelHaiku(modelDefaults.haiku); setExtraEnv([]); setBrandKey('auto'); setUsePromptPack(defaults.promptPack); @@ -615,7 +628,7 @@ export const App: React.FC = ({ setScreen(provider?.requiresModelMapping ? 'quick-models' : 'quick-name')} @@ -691,6 +704,7 @@ export const App: React.FC = ({ onSelect={(value) => { const selected = providers.getProvider(value); const defaults = providerDefaults(value); + const modelDefaults = getDefaultModels(value); const keyDefaults = value === 'zai' ? resolveZaiApiKey() : { value: '', detectedFrom: null, skipPrompt: false }; setProviderKey(value); @@ -698,9 +712,9 @@ export const App: React.FC = ({ setBaseUrl(selected?.baseUrl || ''); setApiKey(keyDefaults.value); setApiKeyDetectedFrom(keyDefaults.detectedFrom); - setModelSonnet(''); - setModelOpus(''); - setModelHaiku(''); + setModelSonnet(modelDefaults.sonnet); + setModelOpus(modelDefaults.opus); + setModelHaiku(modelDefaults.haiku); setExtraEnv([]); setBrandKey('auto'); setUsePromptPack(defaults.promptPack); @@ -826,7 +840,7 @@ export const App: React.FC = ({ setScreen(provider?.requiresModelMapping ? 'create-models' : nextScreen)} diff --git a/src/tui/content/providers.ts b/src/tui/content/providers.ts index 62a129d..27412b7 100644 --- a/src/tui/content/providers.ts +++ b/src/tui/content/providers.ts @@ -76,6 +76,25 @@ export const PROVIDER_EDUCATION: Record = { setupNote: 'Subscribe to MiniMax Coding Plan, then get your API key from the payment page.', }, + gatewayz: { + headline: 'GatewayZ — Your Gateway to AI', + tagline: 'Your Gateway to AI', + features: [ + 'Access to 10,000 models via GatewayZ API', + 'Maximum throughput and lowest model latency metrics', + 'Lowest Pay-per-use pricing on the market', + ], + bestFor: 'Accessing multiple AI models through a unified gateway', + requiresMapping: true, + hasPromptPack: false, + setupLinks: { + subscribe: 'https://gatewayz.ai', + apiKey: 'https://gatewayz.ai', + docs: 'https://api.gatewayz.ai/docs', + }, + setupNote: 'Get your API key from GatewayZ. You must set model aliases (e.g., claude-opus-4-5-20251101).', + }, + openrouter: { headline: 'OpenRouter — One API, Any Model', tagline: 'Many paths, one door', @@ -144,7 +163,7 @@ export const getProviderEducation = (providerKey: string): ProviderEducation | n */ export const PROVIDER_COMPARISON = { fullySupported: ['mirror', 'zai', 'minimax'], - requiresMapping: ['openrouter'], + requiresMapping: ['gatewayz', 'openrouter'], hasPromptPack: ['zai', 'minimax'], localFirst: ['ccrouter'], pureClaudeCode: ['mirror'], diff --git a/src/tui/screens/ModelConfigScreen.tsx b/src/tui/screens/ModelConfigScreen.tsx index e38bd2f..4a5adcd 100644 --- a/src/tui/screens/ModelConfigScreen.tsx +++ b/src/tui/screens/ModelConfigScreen.tsx @@ -62,6 +62,11 @@ function getPlaceholder(providerKey: string | undefined, model: 'opus' | 'sonnet sonnet: 'anthropic/claude-3.5-sonnet', haiku: 'anthropic/claude-3-haiku', }, + gatewayz: { + opus: 'claude-opus-4-5-20251101', + sonnet: 'claude-sonnet-4-20250514', + haiku: 'claude-haiku-3-5-20241022', + }, ccrouter: { opus: 'deepseek,deepseek-reasoner', sonnet: 'deepseek,deepseek-chat', diff --git a/src/tui/screens/VariantActionsScreen.tsx b/src/tui/screens/VariantActionsScreen.tsx index 88aab18..c118feb 100644 --- a/src/tui/screens/VariantActionsScreen.tsx +++ b/src/tui/screens/VariantActionsScreen.tsx @@ -31,7 +31,7 @@ interface VariantActionsScreenProps { } // Providers that require model mapping -const MODEL_MAPPING_PROVIDERS = ['openrouter', 'ccrouter']; +const MODEL_MAPPING_PROVIDERS = ['openrouter', 'ccrouter', 'gatewayz']; export const VariantActionsScreen: React.FC = ({ meta, diff --git a/test/cli/tasks-validation.test.ts b/test/cli/tasks-validation.test.ts new file mode 100644 index 0000000..b9e0082 --- /dev/null +++ b/test/cli/tasks-validation.test.ts @@ -0,0 +1,65 @@ +/** + * Tasks CLI Validation Tests + * + * Tests for CLI argument validation in tasks command. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Test that invalid --older-than values are rejected +test('tasks clean --older-than validates numeric input', async (t) => { + await t.test('NaN value should be rejected', async () => { + const olderThan = Number('abc'); + assert.equal(Number.isNaN(olderThan), true, 'abc should parse to NaN'); + }); + + await t.test('negative values should be rejected', async () => { + const olderThan = Number('-5'); + assert.equal(olderThan < 0, true, 'negative numbers should be rejected'); + }); + + await t.test('valid positive numbers should be accepted', async () => { + const olderThan = Number('7'); + const isValid = !Number.isNaN(olderThan) && olderThan >= 0; + assert.equal(isValid, true, 'valid positive numbers should pass'); + }); + + await t.test('zero should be accepted', async () => { + const olderThan = Number('0'); + const isValid = !Number.isNaN(olderThan) && olderThan >= 0; + assert.equal(isValid, true, 'zero should be valid'); + }); +}); + +// Test that invalid --limit values are rejected +test('tasks list --limit validates numeric input', async (t) => { + await t.test('NaN value should be rejected', async () => { + const limit = Number('abc'); + assert.equal(Number.isNaN(limit), true, 'abc should parse to NaN'); + }); + + await t.test('zero should be rejected', async () => { + const limit = Number('0'); + const isValid = !Number.isNaN(limit) && limit >= 1 && Number.isInteger(limit); + assert.equal(isValid, false, 'zero should be rejected for limit'); + }); + + await t.test('negative values should be rejected', async () => { + const limit = Number('-5'); + const isValid = !Number.isNaN(limit) && limit >= 1 && Number.isInteger(limit); + assert.equal(isValid, false, 'negative numbers should be rejected'); + }); + + await t.test('floating point values should be rejected', async () => { + const limit = Number('5.5'); + const isValid = !Number.isNaN(limit) && limit >= 1 && Number.isInteger(limit); + assert.equal(isValid, false, 'floating point numbers should be rejected'); + }); + + await t.test('valid positive integers should be accepted', async () => { + const limit = Number('50'); + const isValid = !Number.isNaN(limit) && limit >= 1 && Number.isInteger(limit); + assert.equal(isValid, true, 'valid positive integers should pass'); + }); +}); diff --git a/test/core.test.ts b/test/core.test.ts index 1b43218..70e88f4 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -232,6 +232,39 @@ test('mirror brand preset writes tweakcc config and respects team mode support', cleanup(binDir); }); +test('gatewayz brand preset writes tweakcc config', () => { + const rootDir = makeTempDir(); + const binDir = makeTempDir(); + + core.createVariant({ + name: 'gatewayz', + providerKey: 'gatewayz', + apiKey: 'gz-test-key', + rootDir, + binDir, + brand: 'gatewayz', + promptPack: false, + skillInstall: false, + noTweak: true, + tweakccStdio: 'pipe', + }); + + const tweakConfigPath = path.join(rootDir, 'gatewayz', 'tweakcc', 'config.json'); + assert.ok(fs.existsSync(tweakConfigPath)); + const tweakConfig = JSON.parse(readFile(tweakConfigPath)) as { settings?: { themes?: { id?: string }[] } }; + assert.equal(tweakConfig.settings?.themes?.[0]?.id, 'gatewayz-portal'); + + // Verify settings.json has correct auth token and base URL (GatewayZ uses authToken mode) + const configPath = path.join(rootDir, 'gatewayz', 'config', 'settings.json'); + const configJson = JSON.parse(readFile(configPath)) as { env: Record }; + assert.equal(configJson.env.ANTHROPIC_AUTH_TOKEN, 'gz-test-key'); + assert.equal(configJson.env.ANTHROPIC_BASE_URL, 'https://api.gatewayz.ai/v1'); + assert.equal(configJson.env.CC_MIRROR_PROVIDER_LABEL, 'GatewayZ'); + + cleanup(rootDir); + cleanup(binDir); +}); + test('api key approvals are written to .claude.json', () => { const rootDir = makeTempDir(); const binDir = makeTempDir();