mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(cliproxy): claude pool gap closure - model-neutral launch, shadow warning, provider ban copy
- claude provider launch env no longer pins ANTHROPIC_MODEL/tier defaults; one-shot migration strips CCS-written stale pins across all historically shipped default generations while preserving explicit user pins - TTY-gated once-per-install warning when a user profile named claude or anthropic is shadowed by the built-in provider, with rename guidance - account-safety ban messaging parameterized by provider; Anthropic patterns gated to claude accounts only - first-run notice that ccs claude routes through the local CLIProxy instance Part of #1464 account pools (phase 2).
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
|
||||
}
|
||||
|
||||
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
|
||||
// claude is model-neutral: model keys are absent from its base config so that
|
||||
// Claude Code's own /model selection is respected end-to-end. Return empty
|
||||
// strings here; createSettingsFile skips writing empty model entries.
|
||||
if (provider === 'claude') {
|
||||
return { default: '', opus: '', sonnet: '', haiku: '' };
|
||||
}
|
||||
const mapping = getModelMappingFromConfig(provider);
|
||||
return {
|
||||
default: mapping.defaultModel,
|
||||
|
||||
@@ -109,6 +109,15 @@ function createSettingsFile(
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
// Model-neutral providers (e.g. claude built-in) pass empty strings to signal
|
||||
// "omit this key". Filter them out so the written settings file does not
|
||||
// contain ANTHROPIC_MODEL:'' which could be treated as an unintended override.
|
||||
const modelEnv = {
|
||||
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
|
||||
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
|
||||
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
|
||||
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
|
||||
};
|
||||
const settings = {
|
||||
env: {
|
||||
// Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN
|
||||
@@ -120,10 +129,7 @@ function createSettingsFile(
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
...modelEnv,
|
||||
...(extraModels && extraModels.length > 0
|
||||
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
|
||||
: {}),
|
||||
@@ -202,6 +208,13 @@ function createApiProfileUnified(
|
||||
});
|
||||
|
||||
const isNative = isAnthropicDirect(baseUrl, apiKey);
|
||||
// Model-neutral providers pass empty strings; omit those keys.
|
||||
const modelEnvUnified = {
|
||||
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
|
||||
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
|
||||
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
|
||||
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
|
||||
};
|
||||
const settings = {
|
||||
env: {
|
||||
...(isNative
|
||||
@@ -211,10 +224,7 @@ function createApiProfileUnified(
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
|
||||
}),
|
||||
ANTHROPIC_MODEL: models.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
...modelEnvUnified,
|
||||
...(extraModels && extraModels.length > 0
|
||||
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for account-safety ban copy parameterization (Gap 3).
|
||||
*
|
||||
* Verifies that handleBanDetection uses provider-appropriate copy:
|
||||
* - "Anthropic" for the claude provider
|
||||
* - "Google" for gemini / agy / codex
|
||||
* and that isBanResponse matches both shared and Anthropic-specific patterns.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it, spyOn, afterEach, beforeEach } from 'bun:test';
|
||||
import { isBanResponse, handleBanDetection } from '../accounts/account-safety';
|
||||
|
||||
// ── isBanResponse ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isBanResponse', () => {
|
||||
it.each([
|
||||
'disabled in this account',
|
||||
'violation of terms of service',
|
||||
'account has been disabled',
|
||||
'account is disabled',
|
||||
'account has been suspended',
|
||||
'account has been banned',
|
||||
])('matches shared ban pattern for all providers: %s', (pattern) => {
|
||||
expect(isBanResponse(pattern)).toBe(true);
|
||||
expect(isBanResponse(pattern.toUpperCase())).toBe(true);
|
||||
// Also matches when provider is specified
|
||||
expect(isBanResponse(pattern, 'gemini')).toBe(true);
|
||||
expect(isBanResponse(pattern, 'claude')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['your account has been blocked', 'account is blocked'])(
|
||||
'matches Anthropic-specific ban pattern for claude provider: %s',
|
||||
(pattern) => {
|
||||
expect(isBanResponse(pattern, 'claude')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['your account has been blocked', 'account is blocked'])(
|
||||
'does NOT match Anthropic-specific ban pattern for non-claude providers: %s',
|
||||
(pattern) => {
|
||||
expect(isBanResponse(pattern, 'gemini')).toBe(false);
|
||||
expect(isBanResponse(pattern, 'agy')).toBe(false);
|
||||
expect(isBanResponse(pattern, 'codex')).toBe(false);
|
||||
// Without provider argument also should not match
|
||||
expect(isBanResponse(pattern)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not match bare "usage policy" substring for any provider (avoids false positives)', () => {
|
||||
// A rate-limit message mentioning "usage policy" should not trigger ban
|
||||
const msg = 'Request exceeds usage policy limits for your plan';
|
||||
expect(isBanResponse(msg)).toBe(false);
|
||||
expect(isBanResponse(msg, 'gemini')).toBe(false);
|
||||
expect(isBanResponse(msg, 'claude')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a benign error message', () => {
|
||||
expect(isBanResponse('rate limit exceeded')).toBe(false);
|
||||
expect(isBanResponse('network timeout')).toBe(false);
|
||||
expect(isBanResponse('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleBanDetection ban copy ────────────────────────────────────────────────
|
||||
|
||||
describe('handleBanDetection ban copy', () => {
|
||||
const stderrLines: string[] = [];
|
||||
let writeSpy: ReturnType<typeof spyOn>;
|
||||
let consoleSpy: ReturnType<typeof spyOn>;
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ban-copy-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
stderrLines.length = 0;
|
||||
writeSpy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(chunk: string | Uint8Array): boolean => {
|
||||
stderrLines.push(typeof chunk === 'string' ? chunk : '');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// Also capture console.error calls
|
||||
consoleSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
stderrLines.push(String(args[0] ?? ''));
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy?.mockRestore();
|
||||
consoleSpy?.mockRestore();
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses "Anthropic" actor copy for the claude provider', () => {
|
||||
setup();
|
||||
handleBanDetection('claude', 'test@example.com', 'account has been disabled');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Anthropic');
|
||||
expect(output).not.toContain('Google');
|
||||
});
|
||||
|
||||
it('uses "Google" actor copy for the gemini provider', () => {
|
||||
setup();
|
||||
handleBanDetection('gemini', 'test@example.com', 'account has been disabled');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Google');
|
||||
expect(output).not.toContain('Anthropic');
|
||||
});
|
||||
|
||||
it('uses "Google" actor copy for the agy provider', () => {
|
||||
setup();
|
||||
handleBanDetection('agy', 'test@example.com', 'account has been banned');
|
||||
const output = stderrLines.join(' ');
|
||||
expect(output).toContain('Google');
|
||||
});
|
||||
|
||||
it('returns false for a non-ban error (no actor copy emitted)', () => {
|
||||
setup();
|
||||
const result = handleBanDetection('claude', 'test@example.com', 'rate limit exceeded');
|
||||
expect(result).toBe(false);
|
||||
// No ban message written
|
||||
expect(stderrLines.join(' ')).not.toContain('Anthropic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Tests for claude-shadow-warning.ts
|
||||
*
|
||||
* Gap 2: shadow warning — emitted once when a user profile named 'claude' or
|
||||
* 'anthropic' is present; not repeated; not emitted without a collision.
|
||||
* Gap 4: routing notice — emitted once on first claude provider launch.
|
||||
*
|
||||
* These tests use a temp CCS_HOME and a non-TTY stderr mock to confirm
|
||||
* write behaviour without relying on an interactive terminal.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
|
||||
// Module under test — imported after CCS_HOME is set up in beforeEach via dynamic re-import.
|
||||
// Because Bun caches modules, we test the exported functions in isolation by
|
||||
// controlling the file-system state they depend on.
|
||||
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeLegacyConfig(ccsDir: string, profiles: Record<string, string>): void {
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles }), 'utf-8');
|
||||
}
|
||||
|
||||
function markerDir(ccsDir: string): string {
|
||||
return path.join(ccsDir, 'cliproxy');
|
||||
}
|
||||
|
||||
function shadowMarker(ccsDir: string): string {
|
||||
return path.join(markerDir(ccsDir), '.claude-shadow-warned');
|
||||
}
|
||||
|
||||
function routingMarker(ccsDir: string): string {
|
||||
return path.join(markerDir(ccsDir), '.claude-routing-noticed');
|
||||
}
|
||||
|
||||
// ── Test setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let stderrLines: string[];
|
||||
let stderrSpy: ReturnType<typeof spyOn>;
|
||||
let originalIsTTYDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shadow-warn-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
// Collect stderr writes without printing them
|
||||
stderrLines = [];
|
||||
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(chunk: string | Uint8Array): boolean => {
|
||||
stderrLines.push(typeof chunk === 'string' ? chunk : '');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Save exact isTTY property descriptor so afterEach can restore it precisely
|
||||
originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
|
||||
|
||||
// Simulate a TTY so the TTY guard does not short-circuit
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
stderrSpy.mockRestore();
|
||||
// Restore isTTY to exactly what it was before the test
|
||||
if (originalIsTTYDescriptor !== undefined) {
|
||||
Object.defineProperty(process.stderr, 'isTTY', originalIsTTYDescriptor);
|
||||
} else {
|
||||
delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shadow warning (Gap 2) ────────────────────────────────────────────────────
|
||||
|
||||
describe('maybeWarnClaudeShadow', () => {
|
||||
it('does not emit a warning when no shadowed profiles exist', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('shadowed');
|
||||
expect(stderrLines.join('')).not.toContain('claude');
|
||||
});
|
||||
|
||||
it('emits a warning when a profile named "claude" exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/claude.settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('claude');
|
||||
expect(output).toContain('shadowed');
|
||||
// Ensure warn() prefix is not doubled ([!] [!] ...)
|
||||
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
|
||||
});
|
||||
|
||||
it('emits a warning when a profile named "anthropic" exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { anthropic: '/path/to/anthropic.settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('anthropic');
|
||||
expect(output).toContain('shadowed');
|
||||
// Ensure warn() prefix is not doubled
|
||||
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
|
||||
});
|
||||
|
||||
it('creates the dismissal marker after warning', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(fs.existsSync(shadowMarker(ccsDir))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeat the warning when the marker already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
// Pre-create marker
|
||||
const mDir = markerDir(ccsDir);
|
||||
fs.mkdirSync(mDir, { recursive: true });
|
||||
fs.writeFileSync(shadowMarker(ccsDir), 'already-shown');
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).not.toContain('shadowed');
|
||||
});
|
||||
|
||||
it('does not warn when stderr is not a TTY', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('shadowed');
|
||||
});
|
||||
|
||||
it('emits a warning when an account profile named "claude" exists in profiles.json (legacy mode)', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
// Write an empty settings config (no settings profiles named claude)
|
||||
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
|
||||
// Write profiles.json with an account profile named 'claude'
|
||||
const profilesData = {
|
||||
version: '2.0.0',
|
||||
profiles: { claude: { type: 'account', created: new Date().toISOString(), last_used: null } },
|
||||
default: null,
|
||||
};
|
||||
fs.writeFileSync(path.join(ccsDir, 'profiles.json'), JSON.stringify(profilesData), 'utf-8');
|
||||
|
||||
maybeWarnClaudeShadow();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('claude');
|
||||
expect(output).toContain('shadowed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Routing notice (Gap 4) ────────────────────────────────────────────────────
|
||||
|
||||
describe('maybeShowClaudeRoutingNotice', () => {
|
||||
it('emits routing notice on first call', () => {
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
const output = stderrLines.join('');
|
||||
expect(output).toContain('CLIProxy');
|
||||
// Ensure info() prefix is not doubled ([i] [i] ...)
|
||||
expect(output).not.toMatch(/\[i\]\s*\[i\]/);
|
||||
});
|
||||
|
||||
it('creates the routing notice marker after first call', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
expect(fs.existsSync(routingMarker(ccsDir))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not repeat the routing notice when the marker already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
const mDir = markerDir(ccsDir);
|
||||
fs.mkdirSync(mDir, { recursive: true });
|
||||
fs.writeFileSync(routingMarker(ccsDir), 'already-shown');
|
||||
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
// No output at all
|
||||
expect(stderrLines.join('')).not.toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('does not emit when stderr is not a TTY', () => {
|
||||
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
|
||||
|
||||
maybeShowClaudeRoutingNotice();
|
||||
|
||||
expect(stderrLines.join('')).not.toContain('CLIProxy');
|
||||
});
|
||||
});
|
||||
@@ -568,8 +568,9 @@ export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
|
||||
saveAutoPaused(data);
|
||||
}
|
||||
|
||||
// Error patterns that indicate Google has disabled/banned an account
|
||||
const BAN_PATTERNS = [
|
||||
// Error patterns that indicate a provider has disabled/banned an account.
|
||||
// Shared patterns apply to all providers (Google and Anthropic OAuth flows).
|
||||
const SHARED_BAN_PATTERNS = [
|
||||
'disabled in this account',
|
||||
'violation of terms of service',
|
||||
'account has been disabled',
|
||||
@@ -578,12 +579,28 @@ const BAN_PATTERNS = [
|
||||
'account has been banned',
|
||||
];
|
||||
|
||||
// Anthropic-specific disable patterns. Only applied when provider === 'claude'
|
||||
// to avoid false-positive auto-pause on Google/Codex errors that may reference
|
||||
// "policy" in rate-limit or scope messages.
|
||||
const ANTHROPIC_BAN_PATTERNS = ['your account has been blocked', 'account is blocked'];
|
||||
|
||||
/**
|
||||
* Check if an error message indicates an account ban/disable.
|
||||
* Pass the provider so Anthropic-only patterns cannot trip Google providers.
|
||||
*/
|
||||
export function isBanResponse(errorMessage: string): boolean {
|
||||
export function isBanResponse(errorMessage: string, provider?: CLIProxyProvider): boolean {
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
|
||||
if (SHARED_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) return true;
|
||||
if (provider === 'claude' && ANTHROPIC_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return the actor name (Google, Anthropic, etc.) for ban copy. */
|
||||
function banActor(provider: CLIProxyProvider): string {
|
||||
if (provider === 'claude') return 'Anthropic';
|
||||
return 'Google';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,10 +612,11 @@ export function handleBanDetection(
|
||||
accountId: string,
|
||||
errorMessage: string
|
||||
): boolean {
|
||||
if (!isBanResponse(errorMessage)) return false;
|
||||
if (!isBanResponse(errorMessage, provider)) return false;
|
||||
|
||||
const actor = banActor(provider);
|
||||
console.error('');
|
||||
console.error(warn('Account safety: account appears disabled by Google'));
|
||||
console.error(warn(`Account safety: account appears disabled by ${actor}`));
|
||||
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
|
||||
console.error(` "${truncate(errorMessage, 120)}"`);
|
||||
console.error('');
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Claude built-in provider — shadow warning and first-run routing notice
|
||||
*
|
||||
* Shadow warning
|
||||
* --------------
|
||||
* The claude and anthropic provider names are reserved built-ins (priority 0.5).
|
||||
* A user who has a settings profile or account profile named 'claude' or 'anthropic'
|
||||
* will silently get the built-in rather than their own profile. This module emits
|
||||
* a one-line, TTY-only, once-per-install warning with a rename hint in that case.
|
||||
*
|
||||
* First-run routing notice
|
||||
* ------------------------
|
||||
* On the very first launch of `ccs claude` a one-line notice is printed to stderr
|
||||
* reminding the user that traffic routes through the local CLIProxy instance and
|
||||
* that bare `ccs` still launches native Claude Code.
|
||||
*
|
||||
* Persistence: marker files inside ~/.ccs/cliproxy/ are used for both notices.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { warn, info } from '../utils/ui';
|
||||
import {
|
||||
getCcsDir,
|
||||
loadOrCreateUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../config/config-loader-facade';
|
||||
import { readConfig } from '../utils/config-manager';
|
||||
import { ProfileRegistry } from '../auth/profile-registry';
|
||||
|
||||
/** Marker file name inside ~/.ccs/cliproxy/ */
|
||||
const SHADOW_WARNED_MARKER = '.claude-shadow-warned';
|
||||
|
||||
/** Names that the claude built-in shadows */
|
||||
const SHADOWED_NAMES = new Set(['claude', 'anthropic']);
|
||||
|
||||
/** Get path to the once-per-install dismissal marker. */
|
||||
function getShadowWarnedMarkerPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', SHADOW_WARNED_MARKER);
|
||||
}
|
||||
|
||||
/** Return true if this install has already shown the shadow warning. */
|
||||
function shadowWarnAlreadyShown(): boolean {
|
||||
try {
|
||||
return fs.existsSync(getShadowWarnedMarkerPath());
|
||||
} catch {
|
||||
return true; // If we can't read, skip warning.
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the warning dismissal. */
|
||||
function markShadowWarnShown(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(getShadowWarnedMarkerPath(), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — failure to persist is not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a settings or account profile named 'claude' or 'anthropic'
|
||||
* that is being shadowed by the built-in provider.
|
||||
*/
|
||||
function detectShadowedProfile(): string | null {
|
||||
try {
|
||||
if (isUnifiedMode()) {
|
||||
const unified = loadOrCreateUnifiedConfig();
|
||||
const profileNames = Object.keys(unified.profiles || {});
|
||||
const accountNames = Object.keys(unified.accounts || {});
|
||||
const variantNames = Object.keys(unified.cliproxy?.variants || {});
|
||||
for (const name of [...profileNames, ...accountNames, ...variantNames]) {
|
||||
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
|
||||
}
|
||||
} else {
|
||||
const config = readConfig();
|
||||
const profileNames = Object.keys(config.profiles || {});
|
||||
const cliproxyNames = Object.keys(config.cliproxy || {});
|
||||
// Also check account-based profiles from profiles.json (mirror unified accounts check).
|
||||
const registry = new ProfileRegistry();
|
||||
const accountProfileNames = registry.listProfiles();
|
||||
for (const name of [...profileNames, ...cliproxyNames, ...accountProfileNames]) {
|
||||
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If config is unreadable, skip the check silently.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the shadow warning if conditions are met.
|
||||
*
|
||||
* Conditions:
|
||||
* - Running in a TTY (no warning in pipes/CI)
|
||||
* - Warning not already shown for this install
|
||||
* - User has a profile named 'claude' or 'anthropic' that the built-in shadows
|
||||
*
|
||||
* Writes to stderr so it does not pollute stdout piped output.
|
||||
*/
|
||||
export function maybeWarnClaudeShadow(): void {
|
||||
// TTY guard — no warning in non-interactive or CI environments
|
||||
if (!process.stderr.isTTY) return;
|
||||
|
||||
if (shadowWarnAlreadyShown()) return;
|
||||
|
||||
const shadowedName = detectShadowedProfile();
|
||||
if (!shadowedName) return;
|
||||
|
||||
markShadowWarnShown();
|
||||
|
||||
process.stderr.write('\n');
|
||||
process.stderr.write(
|
||||
warn(
|
||||
`Profile '${shadowedName}' is shadowed by the built-in claude provider and cannot be reached via 'ccs ${shadowedName}'.`
|
||||
) + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
` Rename it to continue using it: ccs config (or edit ~/.ccs/config.yaml / config.json)\n`
|
||||
);
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
// ── First-run routing notice ──────────────────────────────────────────────────
|
||||
|
||||
/** Marker file for the once-per-install routing notice. */
|
||||
const ROUTING_NOTICE_MARKER = '.claude-routing-noticed';
|
||||
|
||||
function getRoutingNoticeMarkerPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', ROUTING_NOTICE_MARKER);
|
||||
}
|
||||
|
||||
function routingNoticeAlreadyShown(): boolean {
|
||||
try {
|
||||
return fs.existsSync(getRoutingNoticeMarkerPath());
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markRoutingNoticeShown(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(getRoutingNoticeMarkerPath(), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a one-time routing notice when the claude built-in provider first launches.
|
||||
*
|
||||
* Informs the user that traffic is routed through the local CLIProxy instance
|
||||
* (not the Anthropic API directly) and that bare `ccs` still uses native Claude Code.
|
||||
*
|
||||
* TTY-only; written to stderr.
|
||||
*/
|
||||
export function maybeShowClaudeRoutingNotice(): void {
|
||||
if (!process.stderr.isTTY) return;
|
||||
if (routingNoticeAlreadyShown()) return;
|
||||
|
||||
markRoutingNoticeShown();
|
||||
|
||||
process.stderr.write(
|
||||
info('ccs claude: traffic routes through the local CLIProxy instance.') + '\n'
|
||||
);
|
||||
process.stderr.write(
|
||||
` Native Claude Code (direct Anthropic API) is still available via bare \`ccs\`.\n`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Gap 1: claude provider is model-neutral (no ANTHROPIC_MODEL pins in env output).
|
||||
* Snapshot test proving:
|
||||
* - getClaudeEnvVars('claude') emits no model env vars
|
||||
* - getClaudeEnvVars('gemini') still emits model env vars (no spillover)
|
||||
* - ensureProviderSettings does not write model pins for claude
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { getClaudeEnvVars, ensureProviderSettings } from '../env-builder';
|
||||
import { clearConfigCache } from '../base-config-loader';
|
||||
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
|
||||
describe('claude provider model-neutral passthrough (Gap 1)', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-neutral-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
it('emits no model env vars for the claude provider', () => {
|
||||
const env = getClaudeEnvVars('claude');
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
expect(env[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
|
||||
const env = getClaudeEnvVars('claude');
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
it('still emits model env vars for gemini provider (no spillover)', () => {
|
||||
const env = getClaudeEnvVars('gemini');
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
expect(typeof env[key]).toBe('string');
|
||||
expect((env[key] as string).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('ensureProviderSettings does not write model pins for claude', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
// Non-cursor providers use the legacy top-level path: ~/.ccs/claude.settings.json
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
// Model keys must not be present (or must be undefined/empty)
|
||||
const value = written.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
|
||||
// Transport keys must be present
|
||||
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Upgrade-path: existing claude.settings.json with stale default model pins ──
|
||||
|
||||
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Simulate a user who ran `ccs claude` before the model-neutral change.
|
||||
// These are the exact default values that were auto-written by older CCS.
|
||||
const stalePins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
...stalePins,
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// All stale pins must be removed
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = repaired.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
|
||||
// Transport keys must still be present
|
||||
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
|
||||
expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves user-customised model pin that differs from the stale default', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
// User has customised ANTHROPIC_MODEL to a non-default value
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// Custom value must be preserved
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-7');
|
||||
});
|
||||
|
||||
// ── One-shot migration guard (launch N+1) ─────────────────────────────────────
|
||||
|
||||
it('stale-pin migration runs at most once (marker prevents re-strip on launch N+1)', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Pre-place the migration marker so migration is considered done.
|
||||
const markerDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(markerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
|
||||
|
||||
// Write a settings file that contains stale default pins.
|
||||
const stalePins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
|
||||
};
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
...stalePins,
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// Migration already marked done — stale pins must NOT be stripped again.
|
||||
// This proves a user re-pin that equals a stale default value survives on launch N+1.
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7');
|
||||
});
|
||||
|
||||
it('settings file without ANTHROPIC_MODEL triggers no rewrite after migration marker set', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
// Pre-place the migration marker.
|
||||
const markerDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(markerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const originalContent = JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
fs.writeFileSync(settingsPath, originalContent + '\n', 'utf-8');
|
||||
const statBefore = fs.statSync(settingsPath);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const statAfter = fs.statSync(settingsPath);
|
||||
// mtime should not change — no rewrite triggered
|
||||
expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
// ANTHROPIC_MODEL must still be absent
|
||||
expect(result.env.ANTHROPIC_MODEL).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Historical-default set coverage ──────────────────────────────────────────
|
||||
|
||||
it('strips gen-2 pins (sonnet-4-5-20250929 / opus-4-5-20251101) on migration', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = result.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('strips gen-3 mixed pin (opus-4-6) on migration', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-3-5-20241022',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = result.env[key];
|
||||
expect(!value || value.trim().length === 0).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves an explicit user pin to a value not in any stale-defaults set (e.g. claude-opus-4-8)', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-8',
|
||||
},
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
// claude-opus-4-8 is not in any stale-defaults set; must survive migration
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
|
||||
});
|
||||
|
||||
// ── Fresh-file migration marker and tier-pin survival ─────────────────────────
|
||||
|
||||
it('marks migration done on fresh-file creation so tier pins written by ccs claude --config survive', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'claude.settings.json');
|
||||
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
|
||||
|
||||
// Step 1: fresh ensureProviderSettings creates the file and the marker.
|
||||
expect(fs.existsSync(settingsPath)).toBe(false);
|
||||
ensureProviderSettings('claude');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
|
||||
// Step 2: simulate 'ccs claude --config' pinning all four tier models.
|
||||
const tierPins = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
|
||||
};
|
||||
const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ ...existing, env: { ...existing.env, ...tierPins } }, null, 2) + '\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Step 3: second ensureProviderSettings must NOT strip the user-written pins
|
||||
// because the migration marker already exists.
|
||||
ensureProviderSettings('claude');
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,10 @@ interface BaseSettings {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||
ANTHROPIC_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,16 +66,20 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
|
||||
throw new Error('Missing or invalid "env" object');
|
||||
}
|
||||
|
||||
const required = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
// claude provider is model-neutral: it does not pin model env vars so that
|
||||
// the user's own Claude Code model selection is respected end-to-end.
|
||||
if (provider !== 'claude') {
|
||||
const required = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
for (const field of required) {
|
||||
if (!settings.env[field as keyof BaseSettings['env']]) {
|
||||
throw new Error(`Missing required field: env.${field}`);
|
||||
for (const field of required) {
|
||||
if (!settings.env[field as keyof BaseSettings['env']]) {
|
||||
throw new Error(`Missing required field: env.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +94,17 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
|
||||
|
||||
/**
|
||||
* Get model mapping from base config
|
||||
* Extracts model names from env vars
|
||||
* Extracts model names from env vars.
|
||||
* Returns undefined model fields for the claude provider (model-neutral passthrough).
|
||||
*/
|
||||
export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping {
|
||||
const config = loadBaseConfig(provider);
|
||||
|
||||
// claude is model-neutral: ANTHROPIC_MODEL is absent from its config; callers
|
||||
// that need model IDs (e.g. getClaudeEnvVars) guard on provider !== 'claude'.
|
||||
return {
|
||||
defaultModel: config.env.ANTHROPIC_MODEL,
|
||||
claudeModel: config.env.ANTHROPIC_MODEL,
|
||||
defaultModel: config.env.ANTHROPIC_MODEL ?? '',
|
||||
claudeModel: config.env.ANTHROPIC_MODEL ?? '',
|
||||
opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
normalizeIFlowLegacyModelAliases,
|
||||
normalizeModelIdForProvider,
|
||||
} from '../ai-providers/model-id-normalizer';
|
||||
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
|
||||
import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
@@ -52,6 +52,9 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
|
||||
/** Minimum required env vars for the claude built-in provider (model-neutral). */
|
||||
const REQUIRED_CLAUDE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
@@ -200,28 +203,18 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
|
||||
* Get environment variables for Claude CLI (bundled defaults)
|
||||
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
|
||||
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
|
||||
*
|
||||
* For the claude built-in provider the model env vars are intentionally omitted so that
|
||||
* the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
|
||||
*/
|
||||
export function getClaudeEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): NodeJS.ProcessEnv {
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
|
||||
// Core env vars that we always set dynamically
|
||||
const coreEnvVars = {
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
// Filter out model pins and URL/auth from base config (we set them dynamically)
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
@@ -232,6 +225,22 @@ export function getClaudeEnvVars(
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
|
||||
// Core transport env vars set dynamically for all providers
|
||||
const coreEnvVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
};
|
||||
|
||||
// Model pins: omitted for claude provider (model-neutral passthrough).
|
||||
// For all other providers, set model vars from the base config model mapping.
|
||||
if (provider !== 'claude') {
|
||||
const models = getModelMapping(provider);
|
||||
coreEnvVars.ANTHROPIC_MODEL = models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opusModel || models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnetModel || models.claudeModel;
|
||||
coreEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haikuModel || models.claudeModel;
|
||||
}
|
||||
|
||||
// Merge core env vars with additional env vars from base config
|
||||
const mergedEnv = {
|
||||
...coreEnvVars,
|
||||
@@ -509,6 +518,78 @@ export function getEffectiveEnvVars(
|
||||
return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
|
||||
}
|
||||
|
||||
/**
|
||||
* All historically-shipped default model pins that CCS auto-wrote into
|
||||
* claude.settings.json before the model-neutral passthrough change.
|
||||
* A key is removed only when the stored value exactly matches one of the values
|
||||
* in that key's set, so user-customised values are always preserved.
|
||||
*/
|
||||
const CLAUDE_STALE_MODEL_DEFAULTS: Record<string, Set<string>> = {
|
||||
ANTHROPIC_MODEL: new Set([
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-6',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: new Set([
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-7',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: new Set([
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-6',
|
||||
]),
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: new Set([
|
||||
'claude-haiku-3-5-20241022',
|
||||
'claude-haiku-4-5-20251001',
|
||||
]),
|
||||
};
|
||||
|
||||
/** Marker file that records when the one-time stale-pin migration has run. */
|
||||
const CLAUDE_MODEL_MIGRATED_MARKER = '.claude-model-migrated';
|
||||
|
||||
/** Return true if the one-time stale-pin migration has already been applied. */
|
||||
function claudeModelMigrationDone(): boolean {
|
||||
try {
|
||||
return fs.existsSync(path.join(getCcsDir(), 'cliproxy', CLAUDE_MODEL_MIGRATED_MARKER));
|
||||
} catch {
|
||||
return true; // Cannot read — treat as done to avoid repeated rewrites.
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that the one-time stale-pin migration has been applied. */
|
||||
function markClaudeModelMigrationDone(): void {
|
||||
try {
|
||||
const dir = path.join(getCcsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, CLAUDE_MODEL_MIGRATED_MARKER), new Date().toISOString(), {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
} catch {
|
||||
// Best-effort — failure to persist is not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale model pins from an existing claude.settings.json env block.
|
||||
* Only removes keys whose values appear in the set of historically-shipped
|
||||
* defaults for that key, preserving user-customised values.
|
||||
* Returns true when at least one key was removed (signals file needs rewriting).
|
||||
*/
|
||||
function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
|
||||
let mutated = false;
|
||||
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
|
||||
if (staleValues.has(env[key])) {
|
||||
delete env[key];
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy bundled settings template to user directory if not exists
|
||||
* Called during installation/first run
|
||||
@@ -525,8 +606,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
};
|
||||
|
||||
// Create initial file when missing.
|
||||
// A freshly created file has no stale pins by construction, so mark migration
|
||||
// done immediately to prevent the one-time strip from running unnecessarily.
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
writeSettings({ env: defaultEnv });
|
||||
if (provider === 'claude') {
|
||||
markClaudeModelMigrationDone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,11 +650,28 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
: {};
|
||||
|
||||
let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate));
|
||||
for (const key of REQUIRED_PROVIDER_ENV_KEYS) {
|
||||
|
||||
// One-time migration: strip stale model pins written by older CCS versions into
|
||||
// claude.settings.json. Guarded by a marker file so a user-re-pin that happens
|
||||
// to equal a stale default value is not silently stripped on every subsequent launch.
|
||||
if (provider === 'claude' && !claudeModelMigrationDone()) {
|
||||
if (migrateClaudeStaleModelPins(mergedEnv)) {
|
||||
mutated = true;
|
||||
}
|
||||
markClaudeModelMigrationDone();
|
||||
}
|
||||
|
||||
// claude is model-neutral: only transport keys (URL + auth) are required; model pins are omitted.
|
||||
const requiredKeys =
|
||||
provider === 'claude' ? REQUIRED_CLAUDE_ENV_KEYS : REQUIRED_PROVIDER_ENV_KEYS;
|
||||
for (const key of requiredKeys) {
|
||||
const current = mergedEnv[key];
|
||||
if (typeof current !== 'string' || current.trim().length === 0) {
|
||||
mergedEnv[key] = defaultEnv[key] || '';
|
||||
mutated = true;
|
||||
const fallback = defaultEnv[key];
|
||||
if (fallback) {
|
||||
mergedEnv[key] = fallback;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +792,6 @@ export function getRemoteEnvVars(
|
||||
|
||||
// Priority 3: Base config defaults
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const models = getModelMapping(provider);
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Filter out URL/auth from base config (we'll set those from remote config)
|
||||
const {
|
||||
@@ -697,13 +799,25 @@ export function getRemoteEnvVars(
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
// claude is model-neutral: omit model pins so Claude Code's own /model
|
||||
// selection is respected on remote launches too.
|
||||
if (provider === 'claude') {
|
||||
// Filter out undefined values coming from NodeJS.ProcessEnv spread.
|
||||
userEnvVars = Object.fromEntries(
|
||||
Object.entries(additionalEnvVars).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const models = getModelMapping(provider);
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build final env: global + user settings + remote URL/auth override
|
||||
|
||||
@@ -484,4 +484,17 @@ describe('ensureModelConfiguration', () => {
|
||||
await ensureModelConfiguration('codex', cfg, false);
|
||||
expect(mockReconcileCodexModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// claude is model-neutral passthrough — must never auto-prompt at launch
|
||||
it('claude non-composite → configureProviderModel NOT called (model-neutral)', async () => {
|
||||
const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig;
|
||||
await ensureModelConfiguration('claude', cfg, false);
|
||||
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claude composite → configureProviderModel NOT called', async () => {
|
||||
const cfg = { isComposite: true } as ExecutorConfig;
|
||||
await ensureModelConfiguration('claude', cfg, false);
|
||||
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,13 +369,17 @@ export function runAccountSafetyGuards(
|
||||
* Ensure provider model is configured on first run.
|
||||
* Skipped for composite variants and remote proxy mode.
|
||||
* Also reconciles Codex model for active plan.
|
||||
*
|
||||
* claude is model-neutral passthrough: the user's own /model selection inside
|
||||
* Claude Code governs which model is used, so no auto-prompt at launch.
|
||||
* Use `ccs claude --config` for an explicit pin opt-in.
|
||||
*/
|
||||
export async function ensureModelConfiguration(
|
||||
provider: CLIProxyProvider,
|
||||
cfg: ExecutorConfig,
|
||||
verbose: boolean
|
||||
): Promise<void> {
|
||||
if (!cfg.isComposite && supportsModelConfig(provider)) {
|
||||
if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) {
|
||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolv
|
||||
import { buildProxyChain } from './proxy-chain-builder';
|
||||
import { warnBrokenModels } from './model-warnings';
|
||||
import { launchClaude } from './claude-launcher';
|
||||
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
|
||||
|
||||
/** Local alias so internal call sites need no change */
|
||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||
@@ -161,6 +162,11 @@ export async function execClaudeWithCLIProxy(
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
// claude built-in: warn once if a user profile is being shadowed
|
||||
if (provider === 'claude') {
|
||||
maybeWarnClaudeShadow();
|
||||
}
|
||||
|
||||
// Variables for local proxy mode
|
||||
let sessionId: string | undefined;
|
||||
|
||||
@@ -279,6 +285,11 @@ export async function execClaudeWithCLIProxy(
|
||||
// 5. Check for broken models (multi-tier for composite)
|
||||
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
|
||||
|
||||
// 5a. claude built-in: one-time routing notice (first launch only)
|
||||
if (provider === 'claude') {
|
||||
maybeShowClaudeRoutingNotice();
|
||||
}
|
||||
|
||||
// 6. Ensure user settings file exists
|
||||
ensureProviderSettingsFile(provider);
|
||||
|
||||
|
||||
@@ -122,12 +122,17 @@ export async function showProviderShortcutHelp(
|
||||
writeLine('');
|
||||
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
|
||||
writeLine('');
|
||||
const configSummary =
|
||||
provider === 'claude'
|
||||
? 'Pin a model for this session (optional; use /model inside Claude Code instead)'
|
||||
: 'Open the provider config flow';
|
||||
|
||||
writeCommandTable(
|
||||
'Common Commands',
|
||||
[
|
||||
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
|
||||
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
|
||||
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
|
||||
{ name: `ccs ${provider} --config`, summary: configSummary },
|
||||
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
|
||||
],
|
||||
writeLine
|
||||
|
||||
Reference in New Issue
Block a user