diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fcfd1b3..05355c12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,52 @@
+## [6.7.1-dev.1](https://github.com/kaitranntt/ccs/compare/v6.7.0...v6.7.1-dev.1) (2025-12-20)
+
+### Documentation
+
+* add comprehensive documentation suite for modular architecture ([1ffd169](https://github.com/kaitranntt/ccs/commit/1ffd169b98560bf59b03653937ff479e96b47800))
+* **readme:** update providers, websearch, and add star history ([0dc2da6](https://github.com/kaitranntt/ccs/commit/0dc2da6e5ae55c2c99de92037bdc9f1f43a3eeec))
+
+### Code Refactoring
+
+* add barrel exports for commands and utils directories ([50c427d](https://github.com/kaitranntt/ccs/commit/50c427d339f008e628c05b3f150843425174b425))
+* add barrel exports to api/, glmt/, management/ ([6372b3d](https://github.com/kaitranntt/ccs/commit/6372b3d303fbd1eced272526b882984c784f0358))
+* **api:** extract service layer from api-command ([ecb6bb4](https://github.com/kaitranntt/ccs/commit/ecb6bb448a74c1f9bc4220c3674e9b4669093a3e))
+* **auth:** modularize auth-commands into commands/ directory ([0341f4f](https://github.com/kaitranntt/ccs/commit/0341f4f86f5598dc3a86c06d2a05e22b2af3342d))
+* **cliproxy:** extract service layer from cliproxy-command ([b49b7d1](https://github.com/kaitranntt/ccs/commit/b49b7d17b20e6f470e0315ad777f61e0f266b246))
+* **cliproxy:** modularize auth handler and binary ([5c28935](https://github.com/kaitranntt/ccs/commit/5c28935d1e893b643556daf4ff4127d543082fab))
+* **cliproxy:** modularize binary-manager into binary/ directory ([d3c94fe](https://github.com/kaitranntt/ccs/commit/d3c94fe6a2344aac6215fb12952e42ac17837daa))
+* **delegation:** modularize headless-executor into executor/ directory ([c3baaa8](https://github.com/kaitranntt/ccs/commit/c3baaa8251e2f4f54b10e68a0a5eb886ec271ace))
+* **errors:** centralize error handling infrastructure ([22dbfd9](https://github.com/kaitranntt/ccs/commit/22dbfd91c5862c91988cba6cd07eef22e6bf97bf))
+* **glmt:** modularize transformer pipeline ([cd107e3](https://github.com/kaitranntt/ccs/commit/cd107e354c0faff7582d55fadccee0135ea685fe))
+* **management:** modularize doctor health checks ([0eb2030](https://github.com/kaitranntt/ccs/commit/0eb2030dc2af6e351a88801dc42ce739208bfc2e))
+* remove unused deprecated code ([4a5b832](https://github.com/kaitranntt/ccs/commit/4a5b832a6ed5990d4621e79f17f5f81e8a0c87d1))
+* **types:** add generic types and consolidate ExecutionResult ([6c78b63](https://github.com/kaitranntt/ccs/commit/6c78b63908dd258770beb74abc25b62b56f6fcd5))
+* **ui:** add barrel exports for analytics and components root ([b911db8](https://github.com/kaitranntt/ccs/commit/b911db8b5fd66e4b4a9e1a9261dfcfa4d74bb1ba))
+* **ui:** organize health components into health/ directory ([a106aa2](https://github.com/kaitranntt/ccs/commit/a106aa2ee63178e1635df09be5bc2cf2a9da04d7))
+* **ui:** organize layout components into layout/ directory ([bef9955](https://github.com/kaitranntt/ccs/commit/bef99551230a04831e9a45c5f765ef341ee48b0d))
+* **ui:** organize shared components into shared/ directory ([3c7b0e7](https://github.com/kaitranntt/ccs/commit/3c7b0e7a651cd81fc38a907c9f480f173df5c785))
+* **ui:** remove old flat component files after reorganization ([e1fd394](https://github.com/kaitranntt/ccs/commit/e1fd3945fc146997691aea2dcac1b03e3005dd69))
+* **ui:** split account-flow-viz into account/flow-viz/ directory ([8fd35c8](https://github.com/kaitranntt/ccs/commit/8fd35c8dd619c56a7562c34172a8de3e736be4c8))
+* **ui:** split copilot-config-form into copilot/config-form/ directory ([1b1015c](https://github.com/kaitranntt/ccs/commit/1b1015cf506a55393e6a48c99711c3ffa7dac37c))
+* **ui:** split error-logs-monitor into monitoring/error-logs/ directory ([946030c](https://github.com/kaitranntt/ccs/commit/946030c8363ed5e4ac6ebe8b6860d0f8fb006c41))
+* **ui:** split profile-editor into profiles/editor/ directory ([6778c4d](https://github.com/kaitranntt/ccs/commit/6778c4d637ca936110107672ff65c02b1366a607))
+* **ui:** split provider-editor into cliproxy/provider-editor/ directory ([4bea5a3](https://github.com/kaitranntt/ccs/commit/4bea5a33468813d9382d6e7cc4270ca97fc965f8))
+* **ui:** split quick-setup-wizard into setup/wizard/ directory ([81196b0](https://github.com/kaitranntt/ccs/commit/81196b0ff14a2b119cdd55d49f8a7eeb63abc8f6))
+* **ui:** update imports to use new domain directories ([c70ba89](https://github.com/kaitranntt/ccs/commit/c70ba89b43a177907592d6dd62ef35cf39e691e0))
+* **utils:** extract formatRelativeTime to utils/time.ts ([e1f135a](https://github.com/kaitranntt/ccs/commit/e1f135a93a77f66947ac95b76017e00a5a750c5f))
+* **utils:** modularize ui.ts into ui/ directory ([c1e5ec7](https://github.com/kaitranntt/ccs/commit/c1e5ec70b5052f19dc6a2d339cd4317b44e10e34))
+* **utils:** modularize websearch-manager into websearch/ directory ([5e4fa20](https://github.com/kaitranntt/ccs/commit/5e4fa200df87861e9b078f1696b95569c570ea84))
+* **utils:** remove deprecated color and error functions from helpers.ts ([99afb3e](https://github.com/kaitranntt/ccs/commit/99afb3e011cae46369753e36b2fe1ef231c2f535))
+* **utils:** use canonical ValidationResult from types/utils ([18424cb](https://github.com/kaitranntt/ccs/commit/18424cba89120c61348d03195d00c00aa8cfcbe4))
+* **web-server:** extract usage aggregator service ([9346ff2](https://github.com/kaitranntt/ccs/commit/9346ff2be96bc5b8660774a814f1113de6b6ee36))
+* **web-server:** extract usage module to usage/ directory ([bae323c](https://github.com/kaitranntt/ccs/commit/bae323c0d35051c75cc0224a3000823c43f5d875))
+* **web-server:** modularize health-service into health/ directory ([c1f30ae](https://github.com/kaitranntt/ccs/commit/c1f30ae80076e12d1ed536d992c3dc8fad8248ca))
+* **web-server:** modularize routes into dedicated files ([a12c3d8](https://github.com/kaitranntt/ccs/commit/a12c3d800aedbfd232651cd69c9fcad7b702087d))
+* **websearch:** unify CLI status types with ComponentStatus ([a8f7dad](https://github.com/kaitranntt/ccs/commit/a8f7dad4e39cadf7453059fb982561170d7efb3b))
+
+### Performance Improvements
+
+* **assets:** convert screenshots to WebP, add new feature images ([a9d21c2](https://github.com/kaitranntt/ccs/commit/a9d21c21f2bb94b83d829286ad0338fc81f27975))
+
## [6.7.0](https://github.com/kaitranntt/ccs/compare/v6.6.1...v6.7.0) (2025-12-19)
### Features
diff --git a/README.md b/README.md
index 0a6620c7..fecfb7ce 100644
--- a/README.md
+++ b/README.md
@@ -62,17 +62,23 @@ The dashboard provides visual management for all account types:
- **API Profiles**: Configure GLM, Kimi with your keys
- **Health Monitor**: Real-time status across all profiles
-**Analytics (Light/Dark Theme)**
+**Analytics Dashboard**
-
+
-
+**Live Auth Monitor**
-**API Profiles & OAuth Providers**
+
-
+**CLI Proxy API & Copilot Integration**
-
+
+
+
+
+**WebSearch Fallback**
+
+
@@ -83,12 +89,17 @@ The dashboard provides visual management for all account types:
| **Claude** | Subscription | `ccs` | Default, strategic planning |
| **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration |
| **Codex** | OAuth | `ccs codex` | Code generation |
+| **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models |
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
+**Powered by:**
+- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - OAuth proxy for Gemini, Codex, Antigravity
+- [copilot-api](https://github.com/ericc-ch/copilot-api) - GitHub Copilot API integration
+
> [!TIP]
> **Need more?** CCS supports **any Anthropic-compatible API**. Create custom profiles for self-hosted LLMs, enterprise gateways, or alternative providers. See [API Profiles documentation](https://docs.ccs.kaitran.ca/providers/api-profiles).
@@ -197,15 +208,24 @@ Without Developer Mode, CCS falls back to copying directories.
## WebSearch
-Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback.
+Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically provides web search via CLI tools with automatic fallback.
### How It Works
| Profile Type | WebSearch Method |
|--------------|------------------|
| Claude (native) | Anthropic WebSearch API |
-| OAuth providers | MCP web-search-prime (auto-configured) |
-| API profiles | MCP web-search-prime (auto-configured) |
+| Third-party profiles | CLI Tool Fallback Chain |
+
+### CLI Tool Fallback Chain
+
+CCS intercepts WebSearch requests and routes them through available CLI tools:
+
+| Priority | Tool | Auth | Install |
+|----------|------|------|---------|
+| 1st | Gemini CLI | OAuth (free) | `npm install -g @google/gemini-cli` |
+| 2nd | OpenCode | OAuth (free) | `curl -fsSL https://opencode.ai/install \| bash` |
+| 3rd | Grok CLI | API Key | `npm install -g @vibe-kit/grok-cli` |
### Configuration
@@ -213,22 +233,18 @@ Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`:
```yaml
websearch:
- enabled: true # Enable/disable auto-config
- provider: auto # auto | web-search-prime | brave | tavily
- fallback: true # Enable fallback chain
+ enabled: true # Enable/disable (default: true)
+ gemini:
+ enabled: true # Use Gemini CLI (default: true)
+ model: gemini-2.5-flash # Model to use
+ opencode:
+ enabled: true # Use OpenCode as fallback
+ grok:
+ enabled: false # Requires XAI_API_KEY
```
-### Optional API Keys
-
-For additional search providers, set environment variables:
-
-```bash
-export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month
-export TAVILY_API_KEY="your-key" # AI-optimized search (paid)
-```
-
-> [!NOTE]
-> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks.
+> [!TIP]
+> **Gemini CLI** is recommended - free OAuth authentication with 1000 requests/day. Just run `gemini` once to authenticate via browser.
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
@@ -287,8 +303,14 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md).
MIT License - see [LICENSE](LICENSE).
+
+
+## Star History
+
+[](https://www.star-history.com/#kaitranntt/ccs&type=date&legend=top-left)
+
---
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | [Report Issues](https://github.com/kaitranntt/ccs/issues) | [Star on GitHub](https://github.com/kaitranntt/ccs)
diff --git a/assets/screenshots/analytics-light.png b/assets/screenshots/analytics-light.png
deleted file mode 100644
index 3ab3f969..00000000
Binary files a/assets/screenshots/analytics-light.png and /dev/null differ
diff --git a/assets/screenshots/analytics.png b/assets/screenshots/analytics.png
deleted file mode 100644
index 34bd1563..00000000
Binary files a/assets/screenshots/analytics.png and /dev/null differ
diff --git a/assets/screenshots/analytics.webp b/assets/screenshots/analytics.webp
new file mode 100644
index 00000000..bbd9c8f5
Binary files /dev/null and b/assets/screenshots/analytics.webp differ
diff --git a/assets/screenshots/api_profiles.png b/assets/screenshots/api_profiles.png
deleted file mode 100644
index b6243bc1..00000000
Binary files a/assets/screenshots/api_profiles.png and /dev/null differ
diff --git a/assets/screenshots/cliproxy.png b/assets/screenshots/cliproxy.png
deleted file mode 100644
index a292e6a0..00000000
Binary files a/assets/screenshots/cliproxy.png and /dev/null differ
diff --git a/assets/screenshots/cliproxyapi.webp b/assets/screenshots/cliproxyapi.webp
new file mode 100644
index 00000000..6e84533b
Binary files /dev/null and b/assets/screenshots/cliproxyapi.webp differ
diff --git a/assets/screenshots/copilot-api.webp b/assets/screenshots/copilot-api.webp
new file mode 100644
index 00000000..1cf85573
Binary files /dev/null and b/assets/screenshots/copilot-api.webp differ
diff --git a/assets/screenshots/live-auth-monitor.webp b/assets/screenshots/live-auth-monitor.webp
new file mode 100644
index 00000000..8bd4d02e
Binary files /dev/null and b/assets/screenshots/live-auth-monitor.webp differ
diff --git a/assets/screenshots/websearch.webp b/assets/screenshots/websearch.webp
new file mode 100644
index 00000000..63bf8204
Binary files /dev/null and b/assets/screenshots/websearch.webp differ
diff --git a/docs/code-standards.md b/docs/code-standards.md
new file mode 100644
index 00000000..14059628
--- /dev/null
+++ b/docs/code-standards.md
@@ -0,0 +1,503 @@
+# CCS Code Standards
+
+Last Updated: 2025-12-19
+
+Code standards, modularization patterns, and conventions for the CCS codebase.
+
+---
+
+## Core Principles
+
+### YAGNI (You Aren't Gonna Need It)
+- No features "just in case"
+- Only implement what is currently needed
+- Delete unused code rather than commenting it out
+
+### KISS (Keep It Simple, Stupid)
+- Prefer simple solutions over clever ones
+- Reduce complexity at every opportunity
+- Use established patterns over custom implementations
+
+### DRY (Don't Repeat Yourself)
+- One source of truth for configuration
+- Extract common logic into shared utilities
+- Use barrel exports to centralize imports
+
+---
+
+## File Organization
+
+### Directory Structure Rules
+
+1. **Domain-based organization**: Group files by business domain, not by file type
+2. **Barrel exports required**: Every directory must have an `index.ts` aggregating exports
+3. **Flat within depth**: Keep nesting to 3 levels maximum
+4. **Co-location**: Keep related files together (component + hooks + utils)
+
+### File Naming Conventions
+
+| Convention | Example | When to Use |
+|------------|---------|-------------|
+| kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files |
+| kebab-case | `profile-detector.ts` | Multi-word file names |
+| PascalCase | `BinaryManager` | Class exports only |
+| camelCase | `detectProfile` | Function exports |
+
+**File names should be descriptive**: LLMs should understand the file's purpose from its name alone without reading content.
+
+### Correct Examples
+
+```
+src/cliproxy/binary-manager.ts # Binary management logic
+src/commands/doctor-command.ts # Doctor CLI command handler
+ui/src/components/cliproxy/provider-editor/index.tsx
+```
+
+### Incorrect Examples
+
+```
+src/utils/helper.ts # Too vague
+src/cliproxy/manager.ts # Which manager?
+ui/src/components/Editor.tsx # Not kebab-case
+```
+
+---
+
+## File Size Limit: 200 Lines
+
+**Target**: All code files should be under 200 lines.
+
+**Exceptions** (with justification):
+- Data files (model-pricing.ts, model-catalog.ts)
+- Entry points with routing logic (ccs.ts)
+- Complex transformation logic that cannot be meaningfully split
+
+### Why 200 Lines?
+
+1. **Context efficiency**: LLMs process smaller files faster
+2. **Single responsibility**: Forces focused, testable modules
+3. **Navigation**: Easier to scan and understand
+4. **Maintainability**: Reduces merge conflicts
+
+### When Files Exceed 200 Lines
+
+If a file grows beyond 200 lines:
+
+1. **Identify extraction candidates**:
+ - Helper functions that could be utilities
+ - Constants and type definitions
+ - Subcomponents within React components
+ - Related logic that forms a cohesive unit
+
+2. **Create subdirectory structure**:
+ ```
+ # Before
+ provider-editor.tsx (921 lines)
+
+ # After
+ provider-editor/
+ ├── index.tsx # Main component (200 lines)
+ ├── model-mapping-form.tsx
+ ├── endpoint-config.tsx
+ ├── auth-section.tsx
+ ├── hooks.ts
+ ├── types.ts
+ └── utils.ts
+ ```
+
+3. **Preserve public API**: Main export remains the same through barrel export
+
+---
+
+## Barrel Export Pattern
+
+### What is a Barrel Export?
+
+An `index.ts` file that aggregates and re-exports module contents:
+
+```typescript
+// src/cliproxy/index.ts
+
+// Types (with explicit type keyword)
+export type { PlatformInfo, BinaryInfo } from './types';
+
+// Functions
+export { detectPlatform } from './platform-detector';
+export { BinaryManager } from './binary-manager';
+
+// From subdirectories
+export * from './auth';
+export * from './services';
+```
+
+### Rules for Barrel Exports
+
+1. **Every domain directory must have `index.ts`**
+2. **Export types with `export type`** for tree-shaking
+3. **Re-export subdirectories** for deep access
+4. **Keep barrel exports flat** - no logic, only exports
+
+### Import Patterns
+
+```typescript
+// CORRECT: Import from domain barrel
+import { execClaudeWithCLIProxy, CLIProxyProvider } from '../cliproxy';
+import { Config, Settings } from '../types';
+
+// INCORRECT: Import from specific file (bypasses barrel)
+import { execClaudeWithCLIProxy } from '../cliproxy/cliproxy-executor';
+```
+
+### Exception: Deep Imports
+
+Allowed when:
+- Importing private utilities not exposed in barrel
+- Circular dependency avoidance
+- Performance-critical tree-shaking
+
+---
+
+## Monster File Splitting Methodology
+
+When splitting large files (500+ lines), follow this process:
+
+### Step 1: Analyze Structure
+
+Identify logical boundaries:
+- Render sections in React components
+- Handler groups in route files
+- Related utility functions
+- Constants and types
+
+### Step 2: Extract Types First
+
+```typescript
+// types.ts
+export interface ProviderEditorProps {
+ providerId: string;
+ onSave: (config: ProviderConfig) => void;
+}
+
+export interface ModelMappingValues {
+ model: string;
+ endpoint: string;
+}
+```
+
+### Step 3: Extract Utilities
+
+```typescript
+// utils.ts
+export function validateEndpoint(url: string): boolean { ... }
+export function formatModelName(name: string): string { ... }
+```
+
+### Step 4: Extract Hooks
+
+```typescript
+// hooks.ts
+export function useProviderConfig(providerId: string) { ... }
+export function useModelValidation() { ... }
+```
+
+### Step 5: Extract Subcomponents
+
+```typescript
+// model-mapping-form.tsx
+export function ModelMappingForm({ values, onChange }: Props) { ... }
+```
+
+### Step 6: Compose in Index
+
+```typescript
+// index.tsx
+import { ModelMappingForm } from './model-mapping-form';
+import { useProviderConfig } from './hooks';
+import type { ProviderEditorProps } from './types';
+
+export function ProviderEditor({ providerId, onSave }: ProviderEditorProps) {
+ const config = useProviderConfig(providerId);
+ return (
+
+
+
+ );
+}
+
+// Re-export types for consumers
+export type { ProviderEditorProps, ModelMappingValues } from './types';
+```
+
+---
+
+## TypeScript Standards
+
+### Strict Mode Required
+
+All projects use TypeScript strict mode:
+
+```json
+{
+ "compilerOptions": {
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ }
+}
+```
+
+### Type Annotations
+
+```typescript
+// CORRECT: Explicit return types for public functions
+export function detectProfile(args: string[]): DetectedProfile { ... }
+
+// CORRECT: Inferred types for internal functions
+const formatName = (name: string) => name.trim().toLowerCase();
+
+// INCORRECT: any type
+function processData(data: any) { ... } // Use unknown or proper type
+```
+
+### Type Exports
+
+```typescript
+// CORRECT: Use type keyword for type-only exports
+export type { Config, Settings } from './config';
+
+// CORRECT: Group type exports in barrel
+export type {
+ PlatformInfo,
+ BinaryInfo,
+ DownloadProgress,
+} from './types';
+```
+
+---
+
+## ESLint Rules (Enforced)
+
+| Rule | Level | Notes |
+|------|-------|-------|
+| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix |
+| `@typescript-eslint/no-explicit-any` | error | Use proper types |
+| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions |
+| `prefer-const` | error | Immutable by default |
+| `no-var` | error | Use const/let |
+| `eqeqeq` | error | Strict equality |
+| `react-hooks/*` | recommended | (UI only) |
+
+---
+
+## Terminal Output Standards
+
+### ASCII Only
+
+```typescript
+// CORRECT
+console.log('[OK] Operation successful');
+console.log('[!] Warning message');
+console.log('[X] Error occurred');
+console.log('[i] Information');
+
+// INCORRECT - NO EMOJIS
+console.log('Operation successful'); // NO
+console.log('Warning message'); // NO
+```
+
+### Color Handling
+
+```typescript
+import { colors } from '../utils/ui';
+
+// Colors are TTY-aware and respect NO_COLOR
+console.log(colors.green('[OK]') + ' Operation successful');
+```
+
+### Box Borders
+
+Use ASCII box drawing for error displays:
+
+```
++=====================================+
+| [X] ERROR: Configuration failed |
+| |
+| Details: Unable to parse config |
++=====================================+
+```
+
+---
+
+## React Component Standards (UI)
+
+### Component Structure
+
+```typescript
+// component-name.tsx
+
+// 1. Imports (grouped: react, external, internal, relative)
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { useProfiles } from '@/hooks';
+import { formatName } from './utils';
+import type { ComponentProps } from './types';
+
+// 2. Types (if not in separate file)
+interface Props {
+ id: string;
+ onSave: () => void;
+}
+
+// 3. Component
+export function ComponentName({ id, onSave }: Props) {
+ // Hooks first
+ const profiles = useProfiles();
+ const [state, setState] = useState(null);
+
+ // Handlers
+ const handleClick = () => { ... };
+
+ // Render
+ return ( ... );
+}
+```
+
+### Naming Conventions
+
+| Item | Convention | Example |
+|------|------------|---------|
+| Component files | kebab-case.tsx | `provider-editor.tsx` |
+| Component exports | PascalCase | `ProviderEditor` |
+| Hook files | use-*.ts | `use-profiles.ts` |
+| Hook exports | useCamelCase | `useProfiles` |
+| Utility files | kebab-case.ts | `path-utils.ts` |
+| Utility exports | camelCase | `formatPath` |
+
+---
+
+## Quality Gates
+
+### Pre-Commit Sequence
+
+```bash
+# Main project
+bun run format
+bun run lint:fix
+bun run validate
+
+# UI project (if changed)
+cd ui
+bun run format
+bun run lint:fix
+bun run validate
+```
+
+### Validate Runs
+
+| Project | Command | Checks |
+|---------|---------|--------|
+| Main | `bun run validate` | typecheck + lint + format:check + test |
+| UI | `bun run validate` | typecheck + lint + format:check |
+
+---
+
+## Conventional Commits
+
+All commits must follow conventional commit format:
+
+```
+
():
+```
+
+### Types
+
+| Type | When to Use | Version Bump |
+|------|-------------|--------------|
+| `feat` | New feature | MINOR |
+| `fix` | Bug fix | PATCH |
+| `perf` | Performance | PATCH |
+| `docs` | Documentation | None |
+| `style` | Formatting | None |
+| `refactor` | Code restructure | None |
+| `test` | Tests | None |
+| `chore` | Maintenance | None |
+
+### Examples
+
+```bash
+# Correct
+git commit -m "feat(cliproxy): add OAuth token refresh"
+git commit -m "fix(doctor): handle missing config gracefully"
+git commit -m "refactor(ui): split provider-editor into modules"
+
+# Incorrect - REJECTED
+git commit -m "added new feature"
+git commit -m "Fixed bug"
+```
+
+---
+
+## Anti-Patterns to Avoid
+
+### 1. God Files
+
+```typescript
+// BAD: One file doing everything
+// src/utils.ts (2000 lines with mixed concerns)
+
+// GOOD: Split by domain
+// src/utils/ui/colors.ts
+// src/utils/ui/boxes.ts
+// src/utils/shell-executor.ts
+// src/utils/config-manager.ts
+```
+
+### 2. Barrel Import Bypass
+
+```typescript
+// BAD: Direct import bypassing barrel
+import { detectPlatform } from '../cliproxy/platform-detector';
+
+// GOOD: Import from domain barrel
+import { detectPlatform } from '../cliproxy';
+```
+
+### 3. Inline Everything
+
+```typescript
+// BAD: Huge inline functions in components
+function Component() {
+ const handleComplexOperation = () => {
+ // 100 lines of logic...
+ };
+}
+
+// GOOD: Extract to hooks or utilities
+function Component() {
+ const { handleComplexOperation } = useComplexOperation();
+}
+```
+
+### 4. Type Duplication
+
+```typescript
+// BAD: Same types defined in multiple files
+// file1.ts
+interface Config { ... }
+// file2.ts
+interface Config { ... }
+
+// GOOD: Single source of truth
+// types/config.ts
+export interface Config { ... }
+```
+
+---
+
+## Related Documentation
+
+- [Codebase Summary](./codebase-summary.md) - Full directory structure
+- [System Architecture](./system-architecture.md) - Architecture diagrams
+- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md
new file mode 100644
index 00000000..8f418863
--- /dev/null
+++ b/docs/codebase-summary.md
@@ -0,0 +1,410 @@
+# CCS Codebase Summary
+
+Last Updated: 2025-12-19
+
+Comprehensive overview of the modularized CCS codebase structure following the Phase 5 modularization effort.
+
+## Repository Structure
+
+```
+ccs/
+├── src/ # CLI TypeScript source
+├── dist/ # Compiled JavaScript (npm package)
+├── lib/ # Native shell scripts (bash, PowerShell)
+├── ui/ # React dashboard application
+│ ├── src/ # UI source code
+│ └── dist/ # Built UI bundle
+├── tests/ # Test suites
+├── docs/ # Documentation
+└── assets/ # Static assets (logos, screenshots)
+```
+
+---
+
+## CLI Source (`src/`)
+
+The main CLI is organized into domain-specific modules with barrel exports.
+
+### Directory Structure
+
+```
+src/
+├── ccs.ts # Main entry point & CLI router
+├── types/ # TypeScript type definitions
+│ ├── index.ts # Barrel export (aggregates all types)
+│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
+│ ├── config.ts # Config types (Settings, EnvVars)
+│ ├── delegation.ts # Delegation types (sessions, events)
+│ ├── glmt.ts # GLMT types (messages, transforms)
+│ └── utils.ts # Utility types (ErrorCode, LogLevel)
+│
+├── commands/ # CLI command handlers
+│ ├── cliproxy-command.ts # CLIProxy subcommand handling
+│ ├── doctor-command.ts # Health diagnostics
+│ ├── help-command.ts # Help text generation
+│ ├── install-command.ts # Install/uninstall logic
+│ ├── shell-completion-command.ts
+│ ├── sync-command.ts # Symlink synchronization
+│ ├── update-command.ts # Self-update logic
+│ └── version-command.ts # Version display
+│
+├── auth/ # Authentication module
+│ ├── index.ts # Barrel export
+│ ├── commands/ # Auth-specific CLI commands
+│ │ └── index.ts
+│ ├── account-switcher.ts # Account switching logic
+│ └── profile-detector.ts # Profile detection (474 lines)
+│
+├── config/ # Configuration management
+│ ├── index.ts # Barrel export
+│ ├── unified-config-loader.ts # Central config loader (546 lines)
+│ └── migration-manager.ts # Config migration logic
+│
+├── cliproxy/ # CLIProxyAPI integration (heavily modularized)
+│ ├── index.ts # Barrel export (137 lines, extensive)
+│ ├── auth/ # OAuth handlers, token management
+│ │ └── index.ts
+│ ├── binary/ # Binary management
+│ │ └── index.ts
+│ ├── services/ # Service layer
+│ │ └── index.ts
+│ ├── cliproxy-executor.ts # Main executor (666 lines)
+│ ├── config-generator.ts # Config file generation (531 lines)
+│ ├── account-manager.ts # Account management (509 lines)
+│ ├── platform-detector.ts # OS/arch detection
+│ ├── binary-manager.ts # Binary download/update
+│ ├── auth-handler.ts # Authentication handling
+│ ├── model-catalog.ts # Provider model definitions
+│ ├── model-config.ts # Model configuration
+│ ├── service-manager.ts # Background service
+│ ├── proxy-detector.ts # Running proxy detection
+│ ├── startup-lock.ts # Race condition prevention
+│ └── [more files...]
+│
+├── copilot/ # GitHub Copilot integration
+│ ├── index.ts # Barrel export
+│ └── copilot-package-manager.ts # Package management (515 lines)
+│
+├── glmt/ # GLM/GLMT integration
+│ ├── index.ts # Barrel export
+│ ├── pipeline/ # Processing pipeline
+│ │ └── index.ts
+│ ├── glmt-proxy.ts # Main proxy (675 lines)
+│ └── delta-accumulator.ts # Delta processing (484 lines)
+│
+├── delegation/ # Task delegation & headless execution
+│ ├── index.ts # Barrel export
+│ ├── executor/ # Execution engine
+│ └── [delegation files...]
+│
+├── errors/ # Centralized error handling
+│ ├── index.ts # Barrel export
+│ ├── error-handler.ts # Main error handler
+│ ├── exit-codes.ts # Exit code definitions
+│ └── cleanup.ts # Cleanup logic
+│
+├── management/ # Doctor diagnostics
+│ ├── index.ts # Barrel export
+│ ├── checks/ # Diagnostic checks
+│ │ └── index.ts
+│ └── repair/ # Auto-repair logic
+│ └── index.ts
+│
+├── api/ # API utilities & services
+│ ├── index.ts # Barrel export
+│ └── services/ # API services
+│ ├── index.ts
+│ ├── profile-reader.ts
+│ └── profile-writer.ts
+│
+├── utils/ # Utilities (modularized into subdirs)
+│ ├── index.ts # Barrel export
+│ ├── ui/ # Terminal UI utilities
+│ │ ├── index.ts
+│ │ ├── boxes.ts # Box drawing
+│ │ ├── colors.ts # Terminal colors
+│ │ └── spinners.ts # Progress spinners
+│ ├── websearch/ # Search tool integrations
+│ │ └── index.ts
+│ └── [utility files...]
+│
+└── web-server/ # Express web server (heavily modularized)
+ ├── index.ts # Server entry & barrel export
+ ├── routes/ # 15+ route handlers
+ │ ├── index.ts
+ │ ├── accounts-route.ts
+ │ ├── auth-route.ts
+ │ ├── cliproxy-route.ts
+ │ ├── copilot-route.ts
+ │ ├── doctor-route.ts
+ │ ├── glmt-route.ts
+ │ ├── health-route.ts
+ │ ├── profiles-route.ts
+ │ └── [more routes...]
+ ├── health/ # Health check system
+ │ └── index.ts
+ ├── usage/ # Usage analytics module
+ │ ├── index.ts
+ │ ├── handlers.ts # Request handlers (633 lines)
+ │ ├── aggregator.ts # Data aggregation (538 lines)
+ │ └── data-aggregator.ts
+ ├── services/ # Shared services
+ │ └── index.ts
+ └── model-pricing.ts # Model cost definitions (676 lines)
+```
+
+### Module Categories
+
+| Category | Directories | Purpose |
+|----------|-------------|---------|
+| Core | `commands/`, `errors/` | CLI commands, error handling |
+| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
+| Config | `config/`, `types/` | Configuration & type definitions |
+| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations |
+| Services | `web-server/`, `api/` | HTTP server, API services |
+| Utilities | `utils/`, `management/` | Helpers, diagnostics |
+
+---
+
+## UI Source (`ui/src/`)
+
+The React dashboard organized by domain with barrel exports at every level.
+
+### Directory Structure
+
+```
+ui/src/
+├── components/
+│ ├── index.ts # Main barrel (aggregates all domains)
+│ │
+│ ├── account/ # Account management
+│ │ ├── index.ts # Barrel export
+│ │ ├── accounts-table.tsx
+│ │ ├── add-account-dialog.tsx
+│ │ └── flow-viz/ # Flow visualization (split from 1,144-line file)
+│ │ ├── index.tsx # Main component (200 lines)
+│ │ ├── account-card.tsx
+│ │ ├── account-card-stats.tsx
+│ │ ├── connection-timeline.tsx
+│ │ ├── flow-paths.tsx
+│ │ ├── flow-viz-header.tsx
+│ │ ├── provider-card.tsx
+│ │ ├── hooks.ts
+│ │ ├── types.ts
+│ │ ├── utils.ts
+│ │ ├── path-utils.ts
+│ │ └── zone-utils.ts
+│ │
+│ ├── analytics/ # Usage charts, stats cards
+│ │ ├── index.ts
+│ │ ├── cliproxy-stats-card.tsx
+│ │ └── usage-trend-chart.tsx
+│ │
+│ ├── cliproxy/ # CLIProxy configuration
+│ │ ├── index.ts # Barrel export (30 lines)
+│ │ ├── provider-editor/ # Split from 921-line file
+│ │ │ ├── index.tsx # Main editor (250 lines)
+│ │ │ └── [13 focused modules]
+│ │ ├── config/ # YAML editor, file tree
+│ │ │ ├── config-split-view.tsx
+│ │ │ ├── diff-dialog.tsx
+│ │ │ ├── file-tree.tsx
+│ │ │ └── yaml-editor.tsx
+│ │ ├── overview/ # Health lists, preferences
+│ │ │ ├── credential-health-list.tsx
+│ │ │ ├── model-preferences-grid.tsx
+│ │ │ └── quick-stats-row.tsx
+│ │ └── [7 top-level component files]
+│ │
+│ ├── copilot/ # Copilot settings
+│ │ ├── index.ts
+│ │ └── config-form/ # Split from 846-line file
+│ │ └── [13 focused modules]
+│ │
+│ ├── health/ # System health gauges
+│ │ └── index.ts
+│ │
+│ ├── layout/ # App structure
+│ │ ├── index.ts
+│ │ ├── sidebar.tsx
+│ │ └── footer.tsx
+│ │
+│ ├── monitoring/ # Error logs, auth monitor
+│ │ ├── index.ts
+│ │ ├── auth-monitor.tsx # Auth monitoring (465 lines)
+│ │ └── error-logs/ # Split from 617-line file
+│ │ └── [6 focused modules]
+│ │
+│ ├── profiles/ # Profile management
+│ │ ├── index.ts
+│ │ ├── profile-dialog.tsx
+│ │ ├── profile-create-dialog.tsx
+│ │ └── editor/ # Split from 531-line file
+│ │ └── [10 focused modules]
+│ │
+│ ├── setup/ # Quick setup wizard
+│ │ ├── index.ts
+│ │ └── wizard/ # Step-based wizard
+│ │ ├── index.tsx
+│ │ └── steps/
+│ │
+│ ├── shared/ # Reusable components (19 components)
+│ │ ├── index.ts
+│ │ ├── ccs-logo.tsx
+│ │ ├── code-editor.tsx
+│ │ ├── confirm-dialog.tsx
+│ │ ├── provider-icon.tsx
+│ │ ├── settings-dialog.tsx
+│ │ ├── stat-card.tsx
+│ │ └── [13 more shared components]
+│ │
+│ └── ui/ # shadcn/ui primitives
+│ ├── button.tsx
+│ ├── card.tsx
+│ ├── dialog.tsx
+│ ├── sidebar.tsx # Custom sidebar (674 lines)
+│ └── [UI primitives...]
+│
+├── contexts/ # React Contexts
+│ ├── privacy-context.tsx
+│ ├── theme-context.tsx
+│ └── websocket-context.tsx
+│
+├── hooks/ # Custom hooks (domain-prefixed)
+│ ├── use-accounts.ts
+│ ├── use-cliproxy.ts
+│ ├── use-health.ts
+│ ├── use-profiles.ts
+│ ├── use-websocket.ts
+│ └── [more hooks...]
+│
+├── lib/ # Utilities
+│ ├── api.ts # API client
+│ ├── model-catalogs.ts # Model definitions
+│ └── utils.ts # Helper functions
+│
+├── pages/ # Page components (lazy-loaded)
+│ ├── analytics.tsx # Analytics dashboard (420 lines)
+│ ├── api.tsx # API profiles page (350 lines)
+│ ├── cliproxy.tsx # CLIProxy page (405 lines)
+│ ├── copilot.tsx # Copilot page (295 lines)
+│ ├── health.tsx # Health page (256 lines)
+│ └── settings.tsx # Settings page (1,710 lines - TODO: split)
+│
+└── providers/ # Context providers
+ └── websocket-provider.tsx
+```
+
+### Component Statistics
+
+| Domain | Components | Subdirs | Split Files |
+|--------|------------|---------|-------------|
+| account | 3 | flow-viz (12 files) | 1 monster split |
+| analytics | 3 | - | - |
+| cliproxy | 10 | provider-editor, config, overview | 1 monster split |
+| copilot | 2 | config-form (13 files) | 1 monster split |
+| health | 2 | - | - |
+| layout | 3 | - | - |
+| monitoring | 3 | error-logs (6 files) | 1 monster split |
+| profiles | 4 | editor (10 files) | 1 monster split |
+| setup | 2 | wizard/steps | - |
+| shared | 19 | - | - |
+| **Total** | **51+** | **8 subdirs** | **5 splits** |
+
+---
+
+## Key File Metrics
+
+### Largest Files (Targets for Future Splitting)
+
+**CLI (`src/`):**
+
+| File | Lines | Status |
+|------|-------|--------|
+| model-pricing.ts | 676 | Data file - acceptable |
+| glmt-proxy.ts | 675 | Complex - monitor |
+| cliproxy-executor.ts | 666 | Core logic - acceptable |
+| cliproxy-command.ts | 634 | Could split |
+| usage/handlers.ts | 633 | Could split |
+| ccs.ts | 596 | Entry point - acceptable |
+| unified-config-loader.ts | 546 | Complex - acceptable |
+
+**UI (`ui/src/`):**
+
+| File | Lines | Status |
+|------|-------|--------|
+| pages/settings.tsx | 1,710 | **TODO: SPLIT** |
+| components/ui/sidebar.tsx | 674 | shadcn - acceptable |
+| monitoring/auth-monitor.tsx | 465 | Could split |
+| pages/analytics.tsx | 420 | Could split |
+| pages/cliproxy.tsx | 405 | Acceptable |
+
+---
+
+## Import Patterns
+
+### Standard Import Path
+
+```typescript
+// From any file in src/
+import { Config, Settings } from '../types';
+import { execClaudeWithCLIProxy } from '../cliproxy';
+import { handleError } from '../errors';
+
+// From any file in ui/src/
+import { AccountsTable, ProviderIcon, StatCard } from '@/components';
+import { useAccounts, useProfiles } from '@/hooks';
+```
+
+### Barrel Export Pattern
+
+Every domain directory has an `index.ts` that aggregates exports:
+
+```typescript
+// ui/src/components/cliproxy/index.ts
+export { CategorizedModelSelector } from './categorized-model-selector';
+export { CliproxyDialog } from './cliproxy-dialog';
+// ...
+
+// From subdirectories
+export { ProviderEditor } from './provider-editor';
+export type { ProviderEditorProps } from './provider-editor';
+```
+
+---
+
+## Test Structure
+
+```
+tests/
+├── unit/ # Unit tests
+│ ├── auth/
+│ ├── cliproxy/
+│ ├── config/
+│ └── utils/
+├── native/ # Native install tests
+│ ├── linux/
+│ ├── macos/
+│ └── windows/
+└── npm/ # npm package tests
+```
+
+---
+
+## Build Outputs
+
+| Output | Source | Purpose |
+|--------|--------|---------|
+| `dist/` | `src/` | npm package (CLI) |
+| `dist/ui/` | `ui/src/` | Built React app (served by Express) |
+| `lib/` | N/A | Native shell scripts |
+
+---
+
+## Related Documentation
+
+- [Code Standards](./code-standards.md) - Modularization patterns, file size rules
+- [System Architecture](./system-architecture.md) - High-level architecture diagrams
+- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work
+- [WebSearch](./websearch.md) - WebSearch feature documentation
+- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md
new file mode 100644
index 00000000..09549804
--- /dev/null
+++ b/docs/project-overview-pdr.md
@@ -0,0 +1,225 @@
+# CCS Product Development Requirements (PDR)
+
+Last Updated: 2025-12-19
+
+## Product Overview
+
+**Product Name**: CCS (Claude Code Switch)
+
+**Tagline**: The universal AI profile manager for Claude Code
+
+**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex) with a React-based dashboard for configuration management.
+
+---
+
+## Problem Statement
+
+Developers using Claude Code face these challenges:
+
+1. **Single Account Limitation**: Cannot run multiple Claude subscriptions simultaneously
+2. **Provider Lock-in**: Stuck with Anthropic's API, cannot use alternatives
+3. **No Concurrent Sessions**: Cannot work on different projects with different accounts
+4. **Complex Configuration**: Manual env var and config file management
+
+---
+
+## Solution
+
+CCS provides:
+
+1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
+2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity integration
+3. **API Profiles**: GLM, Kimi, any Anthropic-compatible API
+4. **Visual Dashboard**: React SPA for configuration management
+5. **Automatic WebSearch**: MCP fallback for third-party providers
+
+---
+
+## Target Users
+
+| User Type | Use Case | Primary Features |
+|-----------|----------|------------------|
+| Individual Developer | Work/personal separation | Multi-account Claude |
+| Agency/Contractor | Client account isolation | Profile switching |
+| Cost-conscious Dev | GLM for bulk operations | API profiles |
+| Enterprise | Custom LLM integration | OpenAI-compatible endpoints |
+
+---
+
+## Functional Requirements
+
+### FR-001: Profile Switching
+- Switch between profiles with `ccs ` command
+- Support default profile when no argument provided
+- Pass through all Claude CLI arguments
+
+### FR-002: Multi-Account Claude
+- Create isolated Claude instances
+- Maintain separate sessions, todolists, logs per account
+- Share commands, skills, agents across accounts
+
+### FR-003: OAuth Provider Integration
+- Support Gemini, Codex, Antigravity OAuth flows
+- Browser-based authentication
+- Token caching and refresh
+
+### FR-004: API Profile Management
+- Configure custom API endpoints
+- Support Anthropic-compatible APIs
+- Model mapping and configuration
+
+### FR-005: Dashboard UI
+- Visual profile management
+- Real-time health monitoring
+- Usage analytics
+
+### FR-006: Health Diagnostics
+- Verify Claude CLI installation
+- Check config file integrity
+- Validate symlinks and permissions
+
+### FR-007: WebSearch Fallback
+- Auto-configure MCP web search for third-party profiles
+- Support Gemini CLI, Brave, Tavily providers
+- Graceful fallback chain
+
+---
+
+## Non-Functional Requirements
+
+### NFR-001: Performance
+- CLI startup < 100ms
+- Dashboard load < 2s
+- Minimal memory footprint
+
+### NFR-002: Reliability
+- Idempotent operations
+- Graceful error handling
+- Automatic recovery where possible
+
+### NFR-003: Security
+- Local-only proxy binding (127.0.0.1)
+- No credential exposure in logs
+- Secure token storage
+
+### NFR-004: Cross-Platform
+- Support Linux, macOS, Windows
+- Bash 3.2+, PowerShell 5.1+, Node.js 14+
+- Identical behavior across platforms
+
+### NFR-005: Maintainability
+- Files < 200 lines
+- Domain-based organization
+- Barrel exports for clean imports
+
+---
+
+## Technical Requirements
+
+### TR-001: Runtime Dependencies
+- Node.js 14+ or Bun 1.0+
+- Claude Code CLI installed
+- Internet access for OAuth/API calls
+
+### TR-002: Optional Dependencies
+- CLIProxyAPI binary (auto-managed)
+- Gemini CLI for WebSearch
+- Additional MCP servers
+
+### TR-003: Configuration
+- YAML-based config (`~/.ccs/config.yaml`)
+- JSON settings per profile
+- Environment variable overrides
+
+---
+
+## Architecture Constraints
+
+### AC-001: CLI-First Design
+- All features accessible via CLI
+- Dashboard is convenience layer, not required
+- Scriptable and automatable
+
+### AC-002: Non-Invasive
+- Never modify `~/.claude/settings.json`
+- Use environment variables for configuration
+- Reversible changes only
+
+### AC-003: Proxy Pattern
+- Use local proxy for provider routing
+- Claude CLI communicates with localhost
+- Proxy handles upstream API calls
+
+---
+
+## Success Metrics
+
+| Metric | Target | Current |
+|--------|--------|---------|
+| Startup time | < 100ms | TBD |
+| Dashboard load | < 2s | TBD |
+| Error rate | < 1% | TBD |
+| Test coverage | > 80% | TBD |
+| File size compliance | 100% < 200 lines | 85% |
+
+---
+
+## Release Criteria
+
+### v1.0 Release (Current)
+- [x] Multi-account Claude support
+- [x] OAuth provider integration (Gemini, Codex, AGY)
+- [x] API profile management
+- [x] Dashboard UI
+- [x] Health diagnostics
+- [x] WebSearch fallback
+- [x] Cross-platform support
+
+### v1.1 Release (Planned)
+- [ ] Settings page modularization
+- [ ] Enhanced analytics
+- [ ] Profile templates
+- [ ] Improved error messages
+
+### v2.0 Release (Future)
+- [ ] Team collaboration features
+- [ ] Cloud sync for profiles
+- [ ] Plugin system
+- [ ] CLI extension framework
+
+---
+
+## Dependencies
+
+### External Services
+- Anthropic Claude API
+- Google Gemini API
+- GitHub Codex API
+- Z.AI GLM API
+
+### Third-Party Libraries
+- Express.js (web server)
+- React (dashboard)
+- Vite (build tool)
+- shadcn/ui (UI components)
+- CLIProxyAPI (proxy binary)
+
+---
+
+## Risks and Mitigations
+
+| Risk | Probability | Impact | Mitigation |
+|------|-------------|--------|------------|
+| Claude CLI API changes | Medium | High | Version pinning, compatibility layer |
+| Provider API deprecation | Low | High | Fallback chain, multiple providers |
+| OAuth token expiry | Medium | Medium | Auto-refresh, clear error messages |
+| Binary compatibility | Low | Medium | Multi-platform builds, fallback |
+
+---
+
+## Related Documentation
+
+- [Codebase Summary](./codebase-summary.md) - Technical structure
+- [Code Standards](./code-standards.md) - Development conventions
+- [System Architecture](./system-architecture.md) - Architecture diagrams
+- [Project Roadmap](./project-roadmap.md) - Development phases
diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md
new file mode 100644
index 00000000..06801f66
--- /dev/null
+++ b/docs/project-roadmap.md
@@ -0,0 +1,279 @@
+# CCS Project Roadmap
+
+Last Updated: 2025-12-19
+
+Development roadmap documenting completed modularization phases and future work.
+
+---
+
+## Completed Phases
+
+### Phase 1: Type System Modularization
+
+**Status**: COMPLETE
+
+Extracted TypeScript types into dedicated `types/` directory with barrel exports.
+
+**Changes**:
+- Created `src/types/` directory structure
+- Extracted types from inline definitions into dedicated files:
+ - `config.ts` - Config, Settings, EnvVars
+ - `cli.ts` - ParsedArgs, ExitCode, ClaudeCliInfo
+ - `delegation.ts` - Session, execution types
+ - `glmt.ts` - Message transformation types
+ - `utils.ts` - ErrorCode, LogLevel, Result
+- Created `index.ts` barrel export aggregating all types
+
+**Impact**:
+- Centralized type definitions
+- Cleaner imports across codebase
+- Easier type maintenance
+
+---
+
+### Phase 2: CLI Command Extraction
+
+**Status**: COMPLETE
+
+Extracted CLI command handlers from main `ccs.ts` into dedicated `commands/` directory.
+
+**Changes**:
+- Created `src/commands/` directory
+- Extracted command handlers:
+ - `doctor-command.ts` - Health diagnostics
+ - `help-command.ts` - Help text generation
+ - `install-command.ts` - Install/uninstall logic
+ - `shell-completion-command.ts` - Shell completions
+ - `sync-command.ts` - Symlink synchronization
+ - `update-command.ts` - Self-update logic
+ - `version-command.ts` - Version display
+ - `cliproxy-command.ts` - CLIProxy subcommands (634 lines)
+
+**Impact**:
+- Reduced `ccs.ts` from ~1200 lines to 596 lines
+- Isolated command logic for easier testing
+- Clear separation of concerns
+
+---
+
+### Phase 3: CLIProxy Modularization
+
+**Status**: COMPLETE
+
+Heavily modularized the CLIProxy integration module with internal subdirectories.
+
+**Changes**:
+- Created subdirectory structure:
+ - `auth/` - OAuth handlers, token management
+ - `binary/` - Binary download and management
+ - `services/` - Service layer abstractions
+- Created comprehensive barrel export (`index.ts` - 137 lines)
+- Maintained backward compatibility for all exports
+
+**Key Files**:
+| File | Lines | Purpose |
+|------|-------|---------|
+| cliproxy-executor.ts | 666 | Main execution logic |
+| config-generator.ts | 531 | Config file generation |
+| account-manager.ts | 509 | Account management |
+| auth-handler.ts | - | Authentication handling |
+| proxy-detector.ts | - | Running proxy detection |
+
+---
+
+### Phase 4: Utility and Error Modularization
+
+**Status**: COMPLETE
+
+Extracted utilities and error handling into focused modules.
+
+**Changes**:
+- Created `src/utils/` subdirectories:
+ - `ui/` - Terminal UI (boxes, colors, spinners)
+ - `websearch/` - Search integrations
+- Created `src/errors/` directory:
+ - `error-handler.ts` - Main error handling
+ - `exit-codes.ts` - Exit code definitions
+ - `cleanup.ts` - Cleanup logic
+- Created `src/management/` directory:
+ - `checks/` - Diagnostic checks
+ - `repair/` - Auto-repair logic
+
+---
+
+### Phase 5: UI Components Modularization
+
+**Status**: COMPLETE
+
+Major UI refactoring splitting monster files into focused modules.
+
+**Monster Files Split**:
+
+| Original File | Lines | Split Into |
+|---------------|-------|------------|
+| account-flow-viz.tsx | 1,144 | 12 modules in `flow-viz/` |
+| provider-editor.tsx | 921 | 13 modules in `provider-editor/` |
+| copilot-config-form.tsx | 846 | 13 modules in `config-form/` |
+| error-logs.tsx | 617 | 6 modules in `error-logs/` |
+| profile-editor.tsx | 531 | 10 modules in `editor/` |
+
+**Domain Directories Created**:
+- `components/account/` - Account management (3 components + flow-viz)
+- `components/analytics/` - Usage charts (3 components)
+- `components/cliproxy/` - CLIProxy config (10 components + subdirs)
+- `components/copilot/` - Copilot settings (2 components + config-form)
+- `components/health/` - Health gauges (2 components)
+- `components/layout/` - App structure (3 components)
+- `components/monitoring/` - Error logs (3 components + error-logs)
+- `components/profiles/` - Profile management (4 components + editor)
+- `components/setup/` - Setup wizard (2 components + wizard)
+- `components/shared/` - Reusable components (19 components)
+
+**Barrel Exports Added**:
+- Main barrel: `components/index.ts`
+- Domain barrels: One `index.ts` per domain directory
+- Subdirectory barrels: For split component directories
+
+---
+
+## Current Status
+
+### Metrics
+
+| Metric | Before | After | Change |
+|--------|--------|-------|--------|
+| Files > 500 lines | 12 | 7 | -42% |
+| UI files > 200 lines | 28 | 14 | -50% |
+| Barrel exports (CLI) | 5 | 24 | +380% |
+| Barrel exports (UI) | 0 | 11 | New |
+| Domain directories | 4 | 15 | +275% |
+
+### Remaining Large Files
+
+**CLI (Acceptable)**:
+- `model-pricing.ts` (676 lines) - Data file
+- `glmt-proxy.ts` (675 lines) - Complex proxy logic
+- `cliproxy-executor.ts` (666 lines) - Core execution
+- `ccs.ts` (596 lines) - Entry point
+
+**UI (Need Attention)**:
+- `pages/settings.tsx` (1,710 lines) - **HIGH PRIORITY SPLIT**
+- `components/ui/sidebar.tsx` (674 lines) - shadcn component
+- `monitoring/auth-monitor.tsx` (465 lines) - Could split
+- `pages/analytics.tsx` (420 lines) - Could split
+
+---
+
+## Future Work
+
+### Priority 1: Settings Page Split
+
+**Target**: Split `pages/settings.tsx` (1,710 lines)
+
+**Proposed Structure**:
+```
+pages/settings/
+├── index.tsx # Main layout
+├── general-section.tsx # General settings
+├── appearance-section.tsx # Theme, colors
+├── providers-section.tsx # Provider config
+├── websearch-section.tsx # WebSearch config
+├── advanced-section.tsx # Advanced options
+├── hooks.ts # Shared hooks
+└── types.ts # Settings types
+```
+
+### Priority 2: Analytics Page Split
+
+**Target**: Split `pages/analytics.tsx` (420 lines)
+
+**Proposed Structure**:
+```
+pages/analytics/
+├── index.tsx # Main layout
+├── usage-overview.tsx # Usage summary
+├── model-breakdown.tsx # Per-model stats
+├── cost-analysis.tsx # Cost tracking
+└── hooks.ts # Data hooks
+```
+
+### Priority 3: Auth Monitor Split
+
+**Target**: Split `monitoring/auth-monitor.tsx` (465 lines)
+
+**Proposed Structure**:
+```
+monitoring/auth-monitor/
+├── index.tsx # Main component
+├── provider-status.tsx # Provider cards
+├── token-display.tsx # Token info
+├── refresh-controls.tsx # Refresh actions
+└── hooks.ts # Auth hooks
+```
+
+### Priority 4: Test Coverage
+
+**Target**: Add tests for modularized components
+
+- Unit tests for extracted utilities
+- Component tests for split UI modules
+- Integration tests for barrel exports
+- Snapshot tests for UI components
+
+### Priority 5: Documentation
+
+**Target**: Keep documentation synchronized
+
+- Update codebase-summary.md on structural changes
+- Document new patterns in code-standards.md
+- Keep architecture diagrams current
+- Add inline JSDoc comments
+
+---
+
+## Development Milestones
+
+| Milestone | Status | Target Date |
+|-----------|--------|-------------|
+| Phase 1: Types | COMPLETE | - |
+| Phase 2: Commands | COMPLETE | - |
+| Phase 3: CLIProxy | COMPLETE | - |
+| Phase 4: Utils/Errors | COMPLETE | - |
+| Phase 5: UI Components | COMPLETE | - |
+| Phase 6: Settings Split | PENDING | Q1 2026 |
+| Phase 7: Remaining Splits | PENDING | Q1 2026 |
+| Phase 8: Test Coverage | PENDING | Q2 2026 |
+
+---
+
+## Success Criteria
+
+### Code Quality
+
+- [ ] All files under 200 lines (except documented exceptions)
+- [ ] Every directory has barrel export
+- [ ] No circular dependencies
+- [ ] TypeScript strict mode passing
+
+### Maintainability
+
+- [ ] Clear domain boundaries
+- [ ] Consistent naming conventions
+- [ ] Comprehensive documentation
+- [ ] Easy navigation for new developers
+
+### Developer Experience
+
+- [ ] Fast build times
+- [ ] Efficient tree-shaking
+- [ ] Clear import paths
+- [ ] Minimal cognitive load
+
+---
+
+## Related Documentation
+
+- [Codebase Summary](./codebase-summary.md) - Current structure
+- [Code Standards](./code-standards.md) - Patterns and conventions
+- [System Architecture](./system-architecture.md) - Architecture diagrams
+- [CLAUDE.md](../CLAUDE.md) - AI development guidance
diff --git a/docs/system-architecture.md b/docs/system-architecture.md
new file mode 100644
index 00000000..bec06777
--- /dev/null
+++ b/docs/system-architecture.md
@@ -0,0 +1,581 @@
+# CCS System Architecture
+
+Last Updated: 2025-12-19
+
+High-level architecture documentation for the CCS (Claude Code Switch) system.
+
+---
+
+## System Overview
+
+CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex). It consists of two main components:
+
+1. **CLI Application** (`src/`) - Node.js TypeScript CLI
+2. **Dashboard UI** (`ui/`) - React web application served by Express
+
+```
++===========================================================================+
+| CCS System |
++===========================================================================+
+| |
+| +------------------+ +-----------------+ +----------------+ |
+| | User Terminal | ---> | CCS CLI | ---> | Claude Code | |
+| | (ccs command) | | (src/ccs.ts) | | CLI | |
+| +------------------+ +-----------------+ +----------------+ |
+| | | |
+| v v |
+| +------------------+ +-----------------+ +----------------+ |
+| | Dashboard UI | <--> | Express | ---> | Provider APIs | |
+| | (React SPA) | | Web Server | | (Claude/GLM/ | |
+| +------------------+ +-----------------+ | Gemini/etc) | |
+| | +----------------+ |
+| v |
+| +-----------------+ |
+| | CLIProxyAPI | |
+| | (Binary) | |
+| +-----------------+ |
+| |
++===========================================================================+
+```
+
+---
+
+## Component Architecture
+
+### CLI Layer
+
+```
++===========================================================================+
+| CLI Architecture |
++===========================================================================+
+
+ User Input (ccs [args])
+ |
+ v
+ +-------------+
+ | ccs.ts | Entry point, command routing
+ +-------------+
+ |
+ +---> [Version/Help/Doctor/etc.] ---> Exit
+ |
+ v
+ +-------------+
+ | Profile | Determines execution path
+ | Detection |
+ +-------------+
+ |
+ +---> [Native Claude Account] ---> execClaude()
+ | |
+ +---> [CLIProxy Provider] ---> execClaudeWithCLIProxy()
+ | |
+ +---> [GLMT Profile] ---> execClaudeWithProxy()
+ |
+ v
+ +-------------+
+ | Claude CLI | Underlying Anthropic CLI
+ +-------------+
+```
+
+### Profile Mechanisms (Priority Order)
+
+```
+ Profile Resolution
+ |
+ v
+ 1. CLIProxy Hardcoded ----+---> gemini, codex, agy
+ (OAuth-based) | Zero-config OAuth providers
+ |
+ 2. CLIProxy Variants -----+---> config.cliproxy section
+ (User-defined) | Custom provider configurations
+ |
+ 3. Settings-based --------+---> config.profiles section
+ (API key profiles) | GLM, GLMT, Kimi, custom
+ |
+ 4. Account-based ---------+---> profiles.json
+ (Claude instances) | Isolated via CLAUDE_CONFIG_DIR
+ |
+ v
+ Profile Config
+```
+
+---
+
+## Module Architecture
+
+### CLI Modules (`src/`)
+
+```
++===========================================================================+
+| CLI Module Structure |
++===========================================================================+
+
+ +------------------+ +------------------+ +------------------+
+ | commands/ | | auth/ | | config/ |
+ |------------------| |------------------| |------------------|
+ | doctor-command | | account-switcher | | unified-config- |
+ | help-command | | profile-detector | | loader |
+ | install-command | | commands/ | | migration-manager|
+ | sync-command | +------------------+ +------------------+
+ | update-command |
+ +------------------+
+ | | |
+ +-----------------------+------------------------+
+ |
+ v
+ +------------------+ +------------------+ +------------------+
+ | cliproxy/ | | copilot/ | | glmt/ |
+ |------------------| |------------------| |------------------|
+ | cliproxy-executor| | copilot-package- | | glmt-proxy |
+ | config-generator | | manager | | delta-accumulator|
+ | account-manager | | [copilot logic] | | pipeline/ |
+ | auth/ | +------------------+ +------------------+
+ | binary/ |
+ | services/ |
+ +------------------+
+ | | |
+ +-----------------------+------------------------+
+ |
+ v
+ +------------------+ +------------------+ +------------------+
+ | web-server/ | | utils/ | | errors/ |
+ |------------------| |------------------| |------------------|
+ | routes/ (15+) | | ui/ (boxes, | | error-handler |
+ | health/ | | colors, | | exit-codes |
+ | usage/ | | spinners) | | cleanup |
+ | services/ | | websearch/ | +------------------+
+ | model-pricing | | shell-executor |
+ +------------------+ +------------------+
+
+ |
+ v
+ +------------------+ +------------------+
+ | types/ | | management/ |
+ |------------------| |------------------|
+ | config.ts | | checks/ |
+ | cli.ts | | repair/ |
+ | delegation.ts | | [diagnostics] |
+ | glmt.ts | +------------------+
+ +------------------+
+```
+
+### UI Modules (`ui/src/`)
+
+```
++===========================================================================+
+| UI Module Structure |
++===========================================================================+
+
+ +------------------+
+ | pages/ | Route-level components
+ |------------------|
+ | analytics.tsx |
+ | api.tsx |
+ | cliproxy.tsx |
+ | copilot.tsx |
+ | health.tsx |
+ | settings.tsx |
+ +------------------+
+ |
+ v
+ +------------------+ +------------------+ +------------------+
+ | components/ | | contexts/ | | hooks/ |
+ |------------------| |------------------| |------------------|
+ | account/ | | privacy-context | | use-accounts |
+ | analytics/ | | theme-context | | use-cliproxy |
+ | cliproxy/ | | websocket-context| | use-health |
+ | copilot/ | +------------------+ | use-profiles |
+ | health/ | | use-websocket |
+ | layout/ | +------------------+
+ | monitoring/ |
+ | profiles/ |
+ | setup/ |
+ | shared/ |
+ | ui/ (shadcn) |
+ +------------------+
+ |
+ v
+ +------------------+ +------------------+
+ | lib/ | | providers/ |
+ |------------------| |------------------|
+ | api.ts | | websocket- |
+ | model-catalogs | | provider |
+ | utils.ts | +------------------+
+ +------------------+
+```
+
+---
+
+## Data Flow Architecture
+
+### CLI Execution Flow
+
+```
++===========================================================================+
+| CLI Execution Flow |
++===========================================================================+
+
+ 1. Parse Arguments
+ |
+ v
+ 2. Detect Profile Type
+ |
+ +---> Native Claude ---> 3a. Load Account Settings
+ | |
+ | v
+ | 4a. Set CLAUDE_CONFIG_DIR
+ | |
+ | v
+ | 5a. Spawn Claude CLI
+ |
+ +---> CLIProxy -------> 3b. Ensure Binary Installed
+ | |
+ | v
+ | 4b. Generate Config
+ | |
+ | v
+ | 5b. Start CLIProxyAPI
+ | |
+ | v
+ | 6b. Set Proxy Env Vars
+ | |
+ | v
+ | 7b. Spawn Claude CLI
+ |
+ +---> GLMT -----------> 3c. Start Embedded Proxy
+ |
+ v
+ 4c. Spawn Claude CLI
+```
+
+### Dashboard Data Flow
+
+```
++===========================================================================+
+| Dashboard Data Flow |
++===========================================================================+
+
+ Browser (React SPA)
+ |
+ | HTTP Requests + WebSocket
+ v
+ Express Server (src/web-server/)
+ |
+ +---> /api/accounts ---> auth/account-manager
+ |
+ +---> /api/profiles ---> config/unified-config-loader
+ |
+ +---> /api/cliproxy ---> cliproxy/
+ |
+ +---> /api/health ----> management/checks/
+ |
+ +---> /api/usage -----> usage/aggregator
+ |
+ v
+ WebSocket (Real-time)
+ |
+ +---> Health status updates
+ +---> Auth state changes
+ +---> Usage analytics
+```
+
+---
+
+## Provider Integration Architecture
+
+### CLIProxyAPI Flow
+
+```
++===========================================================================+
+| CLIProxyAPI Integration |
++===========================================================================+
+
+ Claude CLI
+ |
+ | ANTHROPIC_BASE_URL = localhost:XXXX
+ v
+ +------------------+
+ | CLIProxyAPI | Local proxy binary
+ | (binary) |
+ +------------------+
+ |
+ +---> OAuth Authentication (Gemini, Codex, AGY)
+ | |
+ | v
+ | +------------------+
+ | | OAuth Server | Browser-based auth
+ | +------------------+
+ |
+ +---> Request Transformation
+ | |
+ | v
+ | Anthropic Format --> Provider Format
+ |
+ +---> Provider APIs
+ |
+ +---> Google (Gemini)
+ +---> GitHub (Codex)
+ +---> Antigravity
+ +---> OpenAI-compatible endpoints
+```
+
+### GLMT Proxy Flow
+
+```
++===========================================================================+
+| GLMT Proxy Integration |
++===========================================================================+
+
+ Claude CLI
+ |
+ | ANTHROPIC_BASE_URL = localhost:XXXX
+ v
+ +------------------+
+ | GLMT Proxy | Embedded Node.js proxy (src/glmt/)
+ | (glmt-proxy.ts)|
+ +------------------+
+ |
+ v
+ +------------------+
+ | Delta Accumulator| Stream transformation
+ +------------------+
+ |
+ v
+ +------------------+
+ | Pipeline | Request/Response transformation
+ +------------------+
+ |
+ v
+ +------------------+
+ | GLM API | Z.AI / Kimi API
+ +------------------+
+```
+
+---
+
+## Configuration Architecture
+
+### Config File Hierarchy
+
+```
++===========================================================================+
+| Configuration Hierarchy |
++===========================================================================+
+
+ ~/.ccs/
+ |
+ +---> config.yaml # Main CCS config (unified)
+ |
+ +---> profiles.json # Claude account registry
+ |
+ +---> .settings.json # Per-profile settings
+ |
+ +---> cliproxy/
+ | |
+ | +---> config.yaml # CLIProxy configuration
+ | +---> auth/ # OAuth tokens
+ | +---> bin/ # CLIProxy binary
+ |
+ +---> shared/ # Symlinked resources
+ |
+ +---> commands/ # Claude Code commands
+ +---> skills/ # Custom skills
+ +---> agents/ # Agent configurations
+```
+
+### Config Loading Order
+
+```
+ 1. Environment Variables (highest priority)
+ |
+ v
+ 2. CLI Arguments
+ |
+ v
+ 3. Profile-specific settings (~/.ccs/.settings.json)
+ |
+ v
+ 4. Main config (~/.ccs/config.yaml)
+ |
+ v
+ 5. Default values (lowest priority)
+```
+
+---
+
+## WebSocket Architecture
+
+### Real-time Communication
+
+```
++===========================================================================+
+| WebSocket Communication |
++===========================================================================+
+
+ Dashboard (React) Server (Express)
+ | |
+ |<------ Connection Established ------>|
+ | |
+ |<------ health:update ----------------| Health status
+ | |
+ |<------ auth:status ------------------| Auth changes
+ | |
+ |<------ usage:update -----------------| Usage stats
+ | |
+ |------- action:refresh -------------->| User requests
+ | |
+```
+
+---
+
+## Security Architecture
+
+### Authentication Flow
+
+```
++===========================================================================+
+| Authentication Flow |
++===========================================================================+
+
+ OAuth Providers (Gemini, Codex, AGY)
+ -----------------------------------
+
+ 1. User runs: ccs gemini
+ |
+ v
+ 2. Check token cache (~/.ccs/cliproxy/auth/)
+ |
+ +---> [Valid token] ---> Use cached token
+ |
+ +---> [No/Expired token]
+ |
+ v
+ 3. Open browser for OAuth
+ |
+ v
+ 4. Callback with auth code
+ |
+ v
+ 5. Exchange for access token
+ |
+ v
+ 6. Cache token locally
+
+
+ API Key Profiles (GLM, Kimi)
+ ----------------------------
+
+ 1. User configures API key in settings
+ |
+ v
+ 2. Key stored in ~/.ccs/.settings.json
+ |
+ v
+ 3. Key passed via ANTHROPIC_AUTH_TOKEN env var
+```
+
+### Security Boundaries
+
+```
+ +------------------+
+ | User Terminal |
+ +------------------+
+ |
+ | Local only (no network exposure)
+ v
+ +------------------+
+ | CCS CLI |
+ +------------------+
+ |
+ | Localhost only (127.0.0.1)
+ v
+ +------------------+
+ | CLIProxy/GLMT | Binds to localhost only
+ +------------------+
+ |
+ | TLS encrypted
+ v
+ +------------------+
+ | Provider APIs | External endpoints
+ +------------------+
+```
+
+---
+
+## Build and Distribution
+
+### Build Pipeline
+
+```
++===========================================================================+
+| Build Pipeline |
++===========================================================================+
+
+ src/ (TypeScript) ui/src/ (React TSX)
+ | |
+ v v
+ TypeScript Compiler Vite Build
+ | |
+ v v
+ dist/ (JavaScript) dist/ui/ (Static assets)
+ | |
+ +---------------+---------------------+
+ |
+ v
+ npm package (@kaitranntt/ccs)
+ |
+ v
+ npm registry / GitHub releases
+```
+
+### Package Contents
+
+```
+ @kaitranntt/ccs
+ |
+ +---> dist/ # Compiled CLI
+ +---> dist/ui/ # Built dashboard
+ +---> lib/ # Native scripts
+ | +---> ccs # Bash bootstrap
+ | +---> ccs.ps1 # PowerShell bootstrap
+ +---> package.json
+```
+
+---
+
+## Deployment Architecture
+
+### Local Installation
+
+```
+ npm install -g @kaitranntt/ccs
+ |
+ v
+ Global node_modules
+ |
+ +---> Creates symlink: ccs --> dist/ccs.js
+ |
+ +---> First run creates: ~/.ccs/
+```
+
+### Runtime Dependencies
+
+```
+ +------------------+ +------------------+
+ | Node.js 14+ | | Claude CLI |
+ | (required) | | (required) |
+ +------------------+ +------------------+
+
+ +------------------+ +------------------+
+ | CLIProxyAPI | | Gemini CLI |
+ | (auto-managed) | | (optional) |
+ +------------------+ +------------------+
+```
+
+---
+
+## Related Documentation
+
+- [Codebase Summary](./codebase-summary.md) - Detailed directory structure
+- [Code Standards](./code-standards.md) - Coding conventions
+- [Project Roadmap](./project-roadmap.md) - Development phases
+- [WebSearch](./websearch.md) - WebSearch feature details
diff --git a/package.json b/package.json
index ad1a5b39..a83e5051 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "6.7.0",
+ "version": "6.7.1-dev.1",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
diff --git a/src/api/index.ts b/src/api/index.ts
new file mode 100644
index 00000000..126d7063
--- /dev/null
+++ b/src/api/index.ts
@@ -0,0 +1,8 @@
+/**
+ * API Module Barrel Export
+ *
+ * Barrel export for API-related functionality including profile services.
+ */
+
+// Services
+export * from './services';
diff --git a/src/api/services/index.ts b/src/api/services/index.ts
new file mode 100644
index 00000000..db692f30
--- /dev/null
+++ b/src/api/services/index.ts
@@ -0,0 +1,30 @@
+/**
+ * API Services
+ *
+ * Barrel export for API-related business logic services.
+ */
+
+// Validation services
+export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service';
+
+// Profile types
+export {
+ type ModelMapping,
+ type ApiProfileInfo,
+ type CliproxyVariantInfo,
+ type ApiListResult,
+ type CreateApiProfileResult,
+ type RemoveApiProfileResult,
+} from './profile-types';
+
+// Profile read operations
+export {
+ apiProfileExists,
+ isApiProfileConfigured,
+ listApiProfiles,
+ getApiProfileNames,
+ isUsingUnifiedConfig,
+} from './profile-reader';
+
+// Profile write operations
+export { createApiProfile, removeApiProfile } from './profile-writer';
diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts
new file mode 100644
index 00000000..d28b3149
--- /dev/null
+++ b/src/api/services/profile-reader.ts
@@ -0,0 +1,122 @@
+/**
+ * API Profile Reader Service
+ *
+ * Read operations for API profiles.
+ * Supports both unified YAML config and legacy JSON config.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { getCcsDir, loadConfig } from '../../utils/config-manager';
+import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
+import { getProfileSecrets } from '../../config/secrets-manager';
+import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
+
+/**
+ * Check if API profile exists in config
+ */
+export function apiProfileExists(name: string): boolean {
+ try {
+ if (isUnifiedMode()) {
+ const config = loadOrCreateUnifiedConfig();
+ return name in config.profiles;
+ }
+ const config = loadConfig();
+ return name in config.profiles;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if API profile has real API key (not placeholder)
+ */
+export function isApiProfileConfigured(apiName: string): boolean {
+ try {
+ if (isUnifiedMode()) {
+ const secrets = getProfileSecrets(apiName);
+ const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
+ return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
+ }
+ // Legacy: check settings.json file
+ const ccsDir = getCcsDir();
+ const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
+ if (!fs.existsSync(settingsPath)) return false;
+
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
+ const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
+ return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * List all API profiles
+ */
+export function listApiProfiles(): ApiListResult {
+ const profiles: ApiProfileInfo[] = [];
+ const variants: CliproxyVariantInfo[] = [];
+
+ if (isUnifiedMode()) {
+ const unifiedConfig = loadOrCreateUnifiedConfig();
+ for (const name of Object.keys(unifiedConfig.profiles)) {
+ profiles.push({
+ name,
+ settingsPath: 'config.yaml',
+ isConfigured: isApiProfileConfigured(name),
+ configSource: 'unified',
+ });
+ }
+ // CLIProxy variants
+ for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) {
+ variants.push({
+ name,
+ provider: variant?.provider || 'unknown',
+ settings: variant?.settings || '-',
+ });
+ }
+ } else {
+ const config = loadConfig();
+ for (const [name, settingsPath] of Object.entries(config.profiles)) {
+ profiles.push({
+ name,
+ settingsPath: settingsPath as string,
+ isConfigured: isApiProfileConfigured(name),
+ configSource: 'legacy',
+ });
+ }
+ // CLIProxy variants
+ if (config.cliproxy) {
+ for (const [name, v] of Object.entries(config.cliproxy)) {
+ const variant = v as { provider: string; settings: string };
+ variants.push({
+ name,
+ provider: variant.provider,
+ settings: variant.settings,
+ });
+ }
+ }
+ }
+
+ return { profiles, variants };
+}
+
+/**
+ * Get list of available API profile names
+ */
+export function getApiProfileNames(): string[] {
+ if (isUnifiedMode()) {
+ const config = loadOrCreateUnifiedConfig();
+ return Object.keys(config.profiles);
+ }
+ const config = loadConfig();
+ return Object.keys(config.profiles);
+}
+
+/**
+ * Check if using unified config mode
+ */
+export function isUsingUnifiedConfig(): boolean {
+ return isUnifiedMode();
+}
diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts
new file mode 100644
index 00000000..75a0b76b
--- /dev/null
+++ b/src/api/services/profile-types.ts
@@ -0,0 +1,47 @@
+/**
+ * API Profile Types
+ *
+ * Shared type definitions for API profile services.
+ */
+
+/** Model mapping for API profiles */
+export interface ModelMapping {
+ default: string;
+ opus: string;
+ sonnet: string;
+ haiku: string;
+}
+
+/** API profile info for listing */
+export interface ApiProfileInfo {
+ name: string;
+ settingsPath: string;
+ isConfigured: boolean;
+ configSource: 'unified' | 'legacy';
+}
+
+/** CLIProxy variant info */
+export interface CliproxyVariantInfo {
+ name: string;
+ provider: string;
+ settings: string;
+}
+
+/** Result from list operation */
+export interface ApiListResult {
+ profiles: ApiProfileInfo[];
+ variants: CliproxyVariantInfo[];
+}
+
+/** Result from create operation */
+export interface CreateApiProfileResult {
+ success: boolean;
+ settingsFile: string;
+ error?: string;
+}
+
+/** Result from remove operation */
+export interface RemoveApiProfileResult {
+ success: boolean;
+ error?: string;
+}
diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts
new file mode 100644
index 00000000..b316d5d6
--- /dev/null
+++ b/src/api/services/profile-writer.ts
@@ -0,0 +1,189 @@
+/**
+ * API Profile Writer Service - Create/remove operations for API profiles.
+ * Supports both unified YAML config and legacy JSON config.
+ */
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager';
+import {
+ loadOrCreateUnifiedConfig,
+ saveUnifiedConfig,
+ isUnifiedMode,
+} from '../../config/unified-config-loader';
+import { deleteAllProfileSecrets } from '../../config/secrets-manager';
+import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
+
+/** Create settings.json file for API profile (legacy format) */
+function createSettingsFile(
+ name: string,
+ baseUrl: string,
+ apiKey: string,
+ models: ModelMapping
+): string {
+ const ccsDir = getCcsDir();
+ const settingsPath = path.join(ccsDir, `${name}.settings.json`);
+
+ const settings = {
+ env: {
+ ANTHROPIC_BASE_URL: baseUrl,
+ ANTHROPIC_AUTH_TOKEN: apiKey,
+ ANTHROPIC_MODEL: models.default,
+ ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
+ ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
+ },
+ };
+
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
+ return settingsPath;
+}
+
+/** Update config.json with new API profile (legacy format) */
+function updateLegacyConfig(name: string): void {
+ const configPath = getConfigPath();
+ const ccsDir = getCcsDir();
+
+ let config: { profiles: Record; cliproxy?: Record };
+ try {
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ } catch {
+ config = { profiles: {} };
+ }
+
+ const relativePath = `~/.ccs/${name}.settings.json`;
+ config.profiles[name] = relativePath;
+
+ if (!fs.existsSync(ccsDir)) {
+ fs.mkdirSync(ccsDir, { recursive: true });
+ }
+
+ // Write config atomically
+ const tempPath = configPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
+ fs.renameSync(tempPath, configPath);
+}
+
+/** Create API profile in unified config */
+function createApiProfileUnified(
+ name: string,
+ baseUrl: string,
+ apiKey: string,
+ models: ModelMapping
+): void {
+ const ccsDir = path.join(os.homedir(), '.ccs');
+ const settingsFile = `${name}.settings.json`;
+ const settingsPath = path.join(ccsDir, settingsFile);
+
+ const settings = {
+ env: {
+ ANTHROPIC_BASE_URL: baseUrl,
+ ANTHROPIC_AUTH_TOKEN: apiKey,
+ ANTHROPIC_MODEL: models.default,
+ ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
+ ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
+ },
+ };
+
+ if (!fs.existsSync(ccsDir)) {
+ fs.mkdirSync(ccsDir, { recursive: true });
+ }
+
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
+
+ const config = loadOrCreateUnifiedConfig();
+ config.profiles[name] = {
+ type: 'api',
+ settings: `~/.ccs/${settingsFile}`,
+ };
+ saveUnifiedConfig(config);
+}
+
+/** Create a new API profile */
+export function createApiProfile(
+ name: string,
+ baseUrl: string,
+ apiKey: string,
+ models: ModelMapping
+): CreateApiProfileResult {
+ try {
+ const settingsFile = `~/.ccs/${name}.settings.json`;
+
+ if (isUnifiedMode()) {
+ createApiProfileUnified(name, baseUrl, apiKey, models);
+ } else {
+ createSettingsFile(name, baseUrl, apiKey, models);
+ updateLegacyConfig(name);
+ }
+
+ return { success: true, settingsFile };
+ } catch (error) {
+ return {
+ success: false,
+ settingsFile: '',
+ error: (error as Error).message,
+ };
+ }
+}
+
+/** Remove API profile from unified config */
+function removeApiProfileUnified(name: string): void {
+ const config = loadOrCreateUnifiedConfig();
+ const profile = config.profiles[name];
+
+ if (!profile) {
+ throw new Error(`API profile not found: ${name}`);
+ }
+
+ // Delete the settings file if it exists
+ if (profile.settings) {
+ const settingsPath = profile.settings.replace(/^~/, os.homedir());
+ if (fs.existsSync(settingsPath)) {
+ fs.unlinkSync(settingsPath);
+ }
+ }
+
+ delete config.profiles[name];
+
+ // Clear default if it was the deleted profile
+ if (config.default === name) {
+ config.default = undefined;
+ }
+
+ saveUnifiedConfig(config);
+
+ // Remove any legacy secrets
+ deleteAllProfileSecrets(name);
+}
+
+/** Remove API profile from legacy config */
+function removeApiProfileLegacy(name: string): void {
+ const config = loadConfig();
+ delete config.profiles[name];
+
+ const configPath = getConfigPath();
+ const tempPath = configPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
+ fs.renameSync(tempPath, configPath);
+
+ // Remove settings file if it exists
+ const expandedPath = path.join(getCcsDir(), `${name}.settings.json`);
+ if (fs.existsSync(expandedPath)) {
+ fs.unlinkSync(expandedPath);
+ }
+}
+
+/** Remove an API profile */
+export function removeApiProfile(name: string): RemoveApiProfileResult {
+ try {
+ if (isUnifiedMode()) {
+ removeApiProfileUnified(name);
+ } else {
+ removeApiProfileLegacy(name);
+ }
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+}
diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts
new file mode 100644
index 00000000..0973ca13
--- /dev/null
+++ b/src/api/services/validation-service.ts
@@ -0,0 +1,76 @@
+/**
+ * API Validation Service
+ *
+ * Provides validation functions for API profile names and URLs.
+ * Extracted from api-command.ts for reuse and testability.
+ */
+
+import { isReservedName } from '../../config/reserved-names';
+
+/**
+ * Validate API profile name
+ * @returns Error message if invalid, null if valid
+ */
+export function validateApiName(name: string): string | null {
+ if (!name) {
+ return 'API name is required';
+ }
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
+ return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore';
+ }
+ if (name.length > 32) {
+ return 'API name must be 32 characters or less';
+ }
+ if (isReservedName(name)) {
+ return `'${name}' is a reserved name`;
+ }
+ return null;
+}
+
+/**
+ * Validate URL format
+ * @returns Error message if invalid, null if valid
+ */
+export function validateUrl(url: string): string | null {
+ if (!url) {
+ return 'Base URL is required';
+ }
+ try {
+ new URL(url);
+ return null;
+ } catch {
+ return 'Invalid URL format (must include protocol, e.g., https://)';
+ }
+}
+
+/**
+ * Check if URL looks like it includes endpoint path (common mistake)
+ * @returns Warning message if problematic, null if OK
+ */
+export function getUrlWarning(url: string): string | null {
+ const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
+ const lowerUrl = url.toLowerCase();
+
+ for (const pathSuffix of problematicPaths) {
+ if (lowerUrl.endsWith(pathSuffix)) {
+ return (
+ `URL ends with "${pathSuffix}" - Claude appends this automatically.\n` +
+ ` You likely want: ${url.replace(new RegExp(pathSuffix + '$', 'i'), '')}`
+ );
+ }
+ }
+ return null;
+}
+
+/**
+ * Sanitize URL by removing common endpoint suffixes
+ */
+export function sanitizeBaseUrl(url: string): string {
+ const suffixes = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
+ let sanitized = url;
+ for (const suffix of suffixes) {
+ const regex = new RegExp(suffix + '$', 'i');
+ sanitized = sanitized.replace(regex, '');
+ }
+ return sanitized;
+}
diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts
index 69882696..23efc106 100644
--- a/src/auth/auth-commands.ts
+++ b/src/auth/auth-commands.ts
@@ -1,69 +1,37 @@
/**
- * Auth Commands (Simplified)
+ * Auth Commands (Facade)
*
* CLI interface for CCS multi-account management.
- * Commands: create, list, show, remove, default
+ * Commands: create, list, show, remove, default, reset-default
*
* Login-per-profile model: Each profile is an isolated Claude instance.
* Users login directly in each instance (no credential copying).
*
- * Supports dual-mode configuration:
- * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists
- * - Legacy JSON format (profiles.json) as fallback
+ * Implementation Note: This is a facade that delegates to modular command handlers.
+ * See ./commands/ for individual command implementations.
*/
-import { spawn, ChildProcess } from 'child_process';
-import * as fs from 'fs';
-import * as path from 'path';
import ProfileRegistry from './profile-registry';
-import { ProfileMetadata } from '../types';
import { InstanceManager } from '../management/instance-manager';
-import {
- initUI,
- header,
- subheader,
- color,
- dim,
- ok,
- fail,
- warn,
- info,
- table,
- infoBox,
- warnBox,
-} from '../utils/ui';
-import { getClaudeCliInfo } from '../utils/claude-detector';
-import { escapeShellArg } from '../utils/shell-executor';
-import { InteractivePrompt } from '../utils/prompt';
+import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui';
import packageJson from '../../package.json';
-import { hasUnifiedConfig } from '../config/unified-config-loader';
-import { isUnifiedConfigEnabled } from '../config/feature-flags';
-interface AuthCommandArgs {
- profileName?: string;
- force?: boolean;
- verbose?: boolean;
- json?: boolean;
- yes?: boolean;
-}
-
-interface ProfileOutput {
- name: string;
- type: string;
- is_default: boolean;
- created: string;
- last_used: string | null;
- instance_path?: string;
- session_count?: number;
-}
-
-interface ListOutput {
- version: string;
- profiles: ProfileOutput[];
-}
+// Import command handlers from modular structure
+import {
+ type CommandContext,
+ handleCreate,
+ handleList,
+ handleShow,
+ handleRemove,
+ handleDefault,
+ handleResetDefault,
+} from './commands';
/**
- * Auth Commands Class
+ * Auth Commands Class (Facade)
+ *
+ * Maintains class API for backward compatibility while delegating
+ * to modular command handlers.
*/
class AuthCommands {
private registry: ProfileRegistry;
@@ -76,10 +44,14 @@ class AuthCommands {
}
/**
- * Check if unified config mode is active
+ * Get command context for handlers
*/
- private isUnifiedMode(): boolean {
- return hasUnifiedConfig() || isUnifiedConfigEnabled();
+ private getContext(): CommandContext {
+ return {
+ registry: this.registry,
+ instanceMgr: this.instanceMgr,
+ version: this.version,
+ };
}
/**
@@ -144,513 +116,45 @@ class AuthCommands {
}
/**
- * Parse command arguments
- */
- private parseArgs(args: string[]): AuthCommandArgs {
- const profileName = args.find((arg) => !arg.startsWith('--'));
- return {
- profileName,
- force: args.includes('--force'),
- verbose: args.includes('--verbose'),
- json: args.includes('--json'),
- yes: args.includes('--yes') || args.includes('-y'),
- };
- }
-
- /**
- * Create new profile and prompt for login
+ * Create new profile - delegates to create-command.ts
*/
async handleCreate(args: string[]): Promise {
- await initUI();
- const { profileName, force } = this.parseArgs(args);
-
- if (!profileName) {
- console.log(fail('Profile name is required'));
- console.log('');
- console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`);
- console.log('');
- console.log('Example:');
- console.log(` ${color('ccs auth create work', 'command')}`);
- process.exit(1);
- }
-
- // Check if profile already exists (check both legacy and unified)
- const existsLegacy = this.registry.hasProfile(profileName);
- const existsUnified = this.registry.hasAccountUnified(profileName);
- if (!force && (existsLegacy || existsUnified)) {
- console.log(fail(`Profile already exists: ${profileName}`));
- console.log(` Use ${color('--force', 'command')} to overwrite`);
- process.exit(1);
- }
-
- try {
- // Create instance directory
- console.log(info(`Creating profile: ${profileName}`));
- const instancePath = this.instanceMgr.ensureInstance(profileName);
-
- // Create/update profile entry based on config mode
- if (this.isUnifiedMode()) {
- // Use unified config (config.yaml)
- if (existsUnified) {
- this.registry.touchAccountUnified(profileName);
- } else {
- this.registry.createAccountUnified(profileName);
- }
- } else {
- // Use legacy profiles.json
- if (existsLegacy) {
- this.registry.updateProfile(profileName, {
- type: 'account',
- });
- } else {
- this.registry.createProfile(profileName, {
- type: 'account',
- });
- }
- }
-
- console.log(info(`Instance directory: ${instancePath}`));
- console.log('');
- console.log(warn('Starting Claude in isolated instance...'));
- console.log(warn('You will be prompted to login with your account.'));
- console.log('');
-
- // Detect Claude CLI
- const claudeInfo = getClaudeCliInfo();
- if (!claudeInfo) {
- console.log(fail('Claude CLI not found'));
- console.log('');
- console.log('Please install Claude CLI first:');
- console.log(` ${color('https://claude.ai/download', 'path')}`);
- process.exit(1);
- }
-
- const { path: claudeCli, needsShell } = claudeInfo;
-
- // Execute Claude in isolated instance (will auto-prompt for login if no credentials)
- // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
- let child: ChildProcess;
- if (needsShell) {
- const cmdString = escapeShellArg(claudeCli);
- child = spawn(cmdString, {
- stdio: 'inherit',
- windowsHide: true,
- shell: true,
- env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
- });
- } else {
- child = spawn(claudeCli, [], {
- stdio: 'inherit',
- windowsHide: true,
- env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
- });
- }
-
- child.on('exit', (code: number | null) => {
- if (code === 0) {
- console.log('');
- console.log(
- infoBox(
- `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`,
- 'Profile Created'
- )
- );
- console.log('');
- console.log(header('Usage'));
- console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`);
- console.log('');
- console.log(
- warnBox(
- `Running the command below will SWITCH your default\n` +
- `CCS account to "${profileName}". After this, running\n` +
- `"ccs" without a profile name will use this account.\n\n` +
- ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` +
- `To restore the original default, run:\n` +
- ` ${color('ccs auth reset-default', 'command')}`,
- 'Set as Default?'
- )
- );
- console.log('');
- process.exit(0);
- } else {
- console.log('');
- console.log(fail('Login failed or cancelled'));
- console.log('');
- console.log('To retry:');
- console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`);
- console.log('');
- process.exit(1);
- }
- });
-
- child.on('error', (err: Error) => {
- console.log(fail(`Failed to execute Claude CLI: ${err.message}`));
- process.exit(1);
- });
- } catch (error) {
- console.log(fail(`Failed to create profile: ${(error as Error).message}`));
- process.exit(1);
- }
+ return handleCreate(this.getContext(), args);
}
/**
- * Format relative time (e.g., "2h ago", "1d ago")
- */
- private formatRelativeTime(date: Date): string {
- const now = Date.now();
- const diff = now - date.getTime();
-
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(diff / 3600000);
- const days = Math.floor(diff / 86400000);
-
- if (days > 0) return `${days}d ago`;
- if (hours > 0) return `${hours}h ago`;
- if (minutes > 0) return `${minutes}m ago`;
- return 'just now';
- }
-
- /**
- * List all saved profiles
+ * List all profiles - delegates to list-command.ts
*/
async handleList(args: string[]): Promise {
- await initUI();
- const { verbose, json } = this.parseArgs(args);
-
- try {
- // Get profiles from both legacy (profiles.json) and unified config (config.yaml)
- const legacyProfiles = this.registry.getAllProfiles();
- const unifiedAccounts = this.registry.getAllAccountsUnified();
-
- // Merge profiles: unified config takes precedence
- const profiles: Record = { ...legacyProfiles };
- for (const [name, account] of Object.entries(unifiedAccounts)) {
- profiles[name] = {
- type: 'account',
- created: account.created,
- last_used: account.last_used,
- };
- }
-
- const defaultProfile = this.registry.getDefaultUnified() ?? this.registry.getDefaultProfile();
- const profileNames = Object.keys(profiles);
-
- // JSON output mode
- if (json) {
- const output: ListOutput = {
- version: this.version,
- profiles: profileNames.map((name) => {
- const profile = profiles[name];
- const isDefault = name === defaultProfile;
- const instancePath = this.instanceMgr.getInstancePath(name);
-
- return {
- name: name,
- type: profile.type || 'account',
- is_default: isDefault,
- created: profile.created,
- last_used: profile.last_used || null,
- instance_path: instancePath,
- };
- }),
- };
- console.log(JSON.stringify(output, null, 2));
- return;
- }
-
- // Human-readable output
- if (profileNames.length === 0) {
- console.log(warn('No account profiles found'));
- console.log('');
- console.log('To create your first profile:');
- console.log(` ${color('ccs auth create ', 'command')}`);
- console.log('');
- console.log('Example:');
- console.log(` ${color('ccs auth create work', 'command')}`);
- console.log('');
- return;
- }
-
- console.log(header('Saved Account Profiles'));
- console.log('');
-
- // Sort by last_used (descending), then alphabetically
- const sorted = profileNames.sort((a, b) => {
- const aProfile = profiles[a];
- const bProfile = profiles[b];
-
- // Default first
- if (a === defaultProfile) return -1;
- if (b === defaultProfile) return 1;
-
- // Then by last_used
- if (aProfile.last_used && bProfile.last_used) {
- return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime();
- }
- if (aProfile.last_used) return -1;
- if (bProfile.last_used) return 1;
-
- // Then alphabetically
- return a.localeCompare(b);
- });
-
- // Build table rows
- const rows: string[][] = sorted.map((name) => {
- const profile = profiles[name];
- const isDefault = name === defaultProfile;
-
- // Status column
- const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
-
- // Last used column
- let lastUsed = '-';
- if (profile.last_used) {
- lastUsed = this.formatRelativeTime(new Date(profile.last_used));
- }
-
- const row = [
- color(name, isDefault ? 'primary' : 'info'),
- profile.type || 'account',
- status,
- ];
-
- if (verbose) {
- row.push(lastUsed);
- }
-
- return row;
- });
-
- // Headers
- const headers = verbose
- ? ['Profile', 'Type', 'Status', 'Last Used']
- : ['Profile', 'Type', 'Status'];
-
- // Print table
- console.log(
- table(rows, {
- head: headers,
- colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15],
- })
- );
- console.log('');
- console.log(dim(`Total: ${profileNames.length} profile(s)`));
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to list profiles: ${(error as Error).message}`));
- process.exit(1);
- }
+ return handleList(this.getContext(), args);
}
/**
- * Show details for a specific profile
+ * Show profile details - delegates to show-command.ts
*/
async handleShow(args: string[]): Promise {
- await initUI();
- const { profileName, json } = this.parseArgs(args);
-
- if (!profileName) {
- console.log(fail('Profile name is required'));
- console.log('');
- console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`);
- process.exit(1);
- }
-
- try {
- const profile = this.registry.getProfile(profileName);
- const defaultProfile = this.registry.getDefaultProfile();
- const isDefault = profileName === defaultProfile;
- const instancePath = this.instanceMgr.getInstancePath(profileName);
-
- // Count sessions
- let sessionCount = 0;
- try {
- const sessionsDir = path.join(instancePath, 'session-env');
- if (fs.existsSync(sessionsDir)) {
- const files = fs.readdirSync(sessionsDir);
- sessionCount = files.filter((f) => f.endsWith('.json')).length;
- }
- } catch (_e) {
- // Ignore errors counting sessions
- }
-
- // JSON output mode
- if (json) {
- const output: ProfileOutput = {
- name: profileName,
- type: profile.type || 'account',
- is_default: isDefault,
- created: profile.created,
- last_used: profile.last_used || null,
- instance_path: instancePath,
- session_count: sessionCount,
- };
- console.log(JSON.stringify(output, null, 2));
- return;
- }
-
- // Human-readable output
- const defaultBadge = isDefault ? color(' (default)', 'success') : '';
- console.log(header(`Profile: ${profileName}${defaultBadge}`));
- console.log('');
-
- // Details table
- const details = [
- ['Type', profile.type || 'account'],
- ['Instance', instancePath],
- ['Created', new Date(profile.created).toLocaleString()],
- ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
- ['Sessions', `${sessionCount}`],
- ];
-
- console.log(
- table(details, {
- colWidths: [15, 45],
- })
- );
- console.log('');
- } catch (error) {
- console.log(fail((error as Error).message));
- process.exit(1);
- }
+ return handleShow(this.getContext(), args);
}
/**
- * Remove a saved profile
+ * Remove profile - delegates to remove-command.ts
*/
async handleRemove(args: string[]): Promise {
- await initUI();
- const { profileName, yes } = this.parseArgs(args);
-
- if (!profileName) {
- console.log(fail('Profile name is required'));
- console.log('');
- console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`);
- process.exit(1);
- }
-
- // Check existence in both legacy and unified
- const existsLegacy = this.registry.hasProfile(profileName);
- const existsUnified = this.registry.hasAccountUnified(profileName);
-
- if (!existsLegacy && !existsUnified) {
- console.log(fail(`Profile not found: ${profileName}`));
- process.exit(1);
- }
-
- try {
- // Get instance path and session count for impact display
- const instancePath = this.instanceMgr.getInstancePath(profileName);
- let sessionCount = 0;
-
- try {
- const sessionsDir = path.join(instancePath, 'session-env');
- if (fs.existsSync(sessionsDir)) {
- const files = fs.readdirSync(sessionsDir);
- sessionCount = files.filter((f) => f.endsWith('.json')).length;
- }
- } catch (_e) {
- // Ignore errors counting sessions
- }
-
- // Display impact
- console.log('');
- console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`);
- console.log(` Instance path: ${instancePath}`);
- console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`);
- console.log('');
-
- // Interactive confirmation (or --yes flag)
- const confirmed =
- yes ||
- (await InteractivePrompt.confirm(
- 'Delete this profile?',
- { default: false } // Default to NO (safe)
- ));
-
- if (!confirmed) {
- console.log(info('Cancelled'));
- process.exit(0);
- }
-
- // Delete instance
- this.instanceMgr.deleteInstance(profileName);
-
- // Delete profile from appropriate config
- if (this.isUnifiedMode() && existsUnified) {
- this.registry.removeAccountUnified(profileName);
- }
- if (existsLegacy) {
- this.registry.deleteProfile(profileName);
- }
-
- console.log(ok(`Profile removed: ${profileName}`));
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to remove profile: ${(error as Error).message}`));
- process.exit(1);
- }
+ return handleRemove(this.getContext(), args);
}
/**
- * Set default profile
+ * Set default profile - delegates to default-command.ts
*/
async handleDefault(args: string[]): Promise {
- await initUI();
- const { profileName } = this.parseArgs(args);
-
- if (!profileName) {
- console.log(fail('Profile name is required'));
- console.log('');
- console.log(`Usage: ${color('ccs auth default ', 'command')}`);
- process.exit(1);
- }
-
- try {
- // Use unified or legacy based on config mode
- if (this.isUnifiedMode()) {
- this.registry.setDefaultUnified(profileName);
- } else {
- this.registry.setDefaultProfile(profileName);
- }
-
- console.log(ok(`Default profile set: ${profileName}`));
- console.log('');
- console.log('Now you can use:');
- console.log(
- ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}`
- );
- console.log('');
- } catch (error) {
- console.log(fail((error as Error).message));
- process.exit(1);
- }
+ return handleDefault(this.getContext(), args);
}
/**
- * Reset default profile (clear the custom default, restore original CCS behavior)
+ * Reset default profile - delegates to default-command.ts
*/
async handleResetDefault(): Promise {
- await initUI();
-
- try {
- // Use unified or legacy based on config mode
- if (this.isUnifiedMode()) {
- this.registry.clearDefaultUnified();
- } else {
- this.registry.clearDefaultProfile();
- }
-
- console.log(ok('Default profile cleared'));
- console.log('');
- console.log('CCS will now use the original behavior:');
- console.log(` ${dim('# Uses your primary Claude account')}`);
- console.log(` ${color('ccs "your prompt"', 'command')}`);
- console.log('');
- } catch (error) {
- console.log(fail((error as Error).message));
- process.exit(1);
- }
+ return handleResetDefault(this.getContext());
}
/**
diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts
new file mode 100644
index 00000000..caeb41ed
--- /dev/null
+++ b/src/auth/commands/create-command.ts
@@ -0,0 +1,148 @@
+/**
+ * Create Command Handler
+ *
+ * Creates a new profile and prompts for login in an isolated Claude instance.
+ */
+
+import { spawn, ChildProcess } from 'child_process';
+import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui';
+import { getClaudeCliInfo } from '../../utils/claude-detector';
+import { escapeShellArg } from '../../utils/shell-executor';
+import { isUnifiedMode } from '../../config/unified-config-loader';
+import { exitWithError } from '../../errors';
+import { ExitCode } from '../../errors/exit-codes';
+import { CommandContext, parseArgs } from './types';
+
+/**
+ * Handle the create command
+ */
+export async function handleCreate(ctx: CommandContext, args: string[]): Promise {
+ await initUI();
+ const { profileName, force } = parseArgs(args);
+
+ if (!profileName) {
+ console.log(fail('Profile name is required'));
+ console.log('');
+ console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`);
+ console.log('');
+ console.log('Example:');
+ console.log(` ${color('ccs auth create work', 'command')}`);
+ exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
+ }
+
+ // Check if profile already exists (check both legacy and unified)
+ const existsLegacy = ctx.registry.hasProfile(profileName);
+ const existsUnified = ctx.registry.hasAccountUnified(profileName);
+ if (!force && (existsLegacy || existsUnified)) {
+ console.log(fail(`Profile already exists: ${profileName}`));
+ console.log(` Use ${color('--force', 'command')} to overwrite`);
+ exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
+ }
+
+ try {
+ // Create instance directory
+ console.log(info(`Creating profile: ${profileName}`));
+ const instancePath = ctx.instanceMgr.ensureInstance(profileName);
+
+ // Create/update profile entry based on config mode
+ if (isUnifiedMode()) {
+ // Use unified config (config.yaml)
+ if (existsUnified) {
+ ctx.registry.touchAccountUnified(profileName);
+ } else {
+ ctx.registry.createAccountUnified(profileName);
+ }
+ } else {
+ // Use legacy profiles.json
+ if (existsLegacy) {
+ ctx.registry.updateProfile(profileName, {
+ type: 'account',
+ });
+ } else {
+ ctx.registry.createProfile(profileName, {
+ type: 'account',
+ });
+ }
+ }
+
+ console.log(info(`Instance directory: ${instancePath}`));
+ console.log('');
+ console.log(warn('Starting Claude in isolated instance...'));
+ console.log(warn('You will be prompted to login with your account.'));
+ console.log('');
+
+ // Detect Claude CLI
+ const claudeInfo = getClaudeCliInfo();
+ if (!claudeInfo) {
+ console.log(fail('Claude CLI not found'));
+ console.log('');
+ console.log('Please install Claude CLI first:');
+ console.log(` ${color('https://claude.ai/download', 'path')}`);
+ exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
+ }
+
+ const { path: claudeCli, needsShell } = claudeInfo;
+
+ // Execute Claude in isolated instance (will auto-prompt for login if no credentials)
+ // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
+ let child: ChildProcess;
+ if (needsShell) {
+ const cmdString = escapeShellArg(claudeCli);
+ child = spawn(cmdString, {
+ stdio: 'inherit',
+ windowsHide: true,
+ shell: true,
+ env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
+ });
+ } else {
+ child = spawn(claudeCli, [], {
+ stdio: 'inherit',
+ windowsHide: true,
+ env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
+ });
+ }
+
+ child.on('exit', (code: number | null) => {
+ if (code === 0) {
+ console.log('');
+ console.log(
+ infoBox(
+ `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`,
+ 'Profile Created'
+ )
+ );
+ console.log('');
+ console.log(header('Usage'));
+ console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`);
+ console.log('');
+ console.log(
+ warnBox(
+ `Running the command below will SWITCH your default\n` +
+ `CCS account to "${profileName}". After this, running\n` +
+ `"ccs" without a profile name will use this account.\n\n` +
+ ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` +
+ `To restore the original default, run:\n` +
+ ` ${color('ccs auth reset-default', 'command')}`,
+ 'Set as Default?'
+ )
+ );
+ console.log('');
+ process.exit(0);
+ } else {
+ console.log('');
+ console.log(fail('Login failed or cancelled'));
+ console.log('');
+ console.log('To retry:');
+ console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`);
+ console.log('');
+ exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR);
+ }
+ });
+
+ child.on('error', (err: Error) => {
+ exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR);
+ });
+ } catch (error) {
+ exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
+ }
+}
diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts
new file mode 100644
index 00000000..7964a60f
--- /dev/null
+++ b/src/auth/commands/default-command.ts
@@ -0,0 +1,71 @@
+/**
+ * Default Command Handler
+ *
+ * Sets or clears the default profile.
+ */
+
+import { initUI, color, dim, ok, fail } from '../../utils/ui';
+import { isUnifiedMode } from '../../config/unified-config-loader';
+import { exitWithError } from '../../errors';
+import { ExitCode } from '../../errors/exit-codes';
+import { CommandContext, parseArgs } from './types';
+
+/**
+ * Handle the default command (set default profile)
+ */
+export async function handleDefault(ctx: CommandContext, args: string[]): Promise {
+ await initUI();
+ const { profileName } = parseArgs(args);
+
+ if (!profileName) {
+ console.log(fail('Profile name is required'));
+ console.log('');
+ console.log(`Usage: ${color('ccs auth default ', 'command')}`);
+ exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
+ }
+
+ try {
+ // Use unified or legacy based on config mode
+ if (isUnifiedMode()) {
+ ctx.registry.setDefaultUnified(profileName);
+ } else {
+ ctx.registry.setDefaultProfile(profileName);
+ }
+
+ console.log(ok(`Default profile set: ${profileName}`));
+ console.log('');
+ console.log('Now you can use:');
+ console.log(
+ ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}`
+ );
+ console.log('');
+ } catch (error) {
+ exitWithError((error as Error).message, ExitCode.PROFILE_ERROR);
+ }
+}
+
+/**
+ * Handle the reset-default command (clear the custom default)
+ */
+export async function handleResetDefault(ctx: CommandContext): Promise {
+ await initUI();
+
+ try {
+ // Use unified or legacy based on config mode
+ if (isUnifiedMode()) {
+ ctx.registry.clearDefaultUnified();
+ } else {
+ ctx.registry.clearDefaultProfile();
+ }
+
+ console.log(ok('Default profile cleared'));
+ console.log('');
+ console.log('CCS will now use the original behavior:');
+ console.log(` ${dim('# Uses your primary Claude account')}`);
+ console.log(` ${color('ccs "your prompt"', 'command')}`);
+ console.log('');
+ } catch (error) {
+ console.log(fail((error as Error).message));
+ process.exit(1);
+ }
+}
diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts
new file mode 100644
index 00000000..67a11be5
--- /dev/null
+++ b/src/auth/commands/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Auth Commands Barrel Export
+ *
+ * Re-exports all command handlers and types from the commands module.
+ */
+
+// Types and utilities
+export {
+ AuthCommandArgs,
+ ProfileOutput,
+ ListOutput,
+ CommandContext,
+ parseArgs,
+ formatRelativeTime,
+} from './types';
+
+// Command handlers
+export { handleCreate } from './create-command';
+export { handleList } from './list-command';
+export { handleShow } from './show-command';
+export { handleRemove } from './remove-command';
+export { handleDefault, handleResetDefault } from './default-command';
diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts
new file mode 100644
index 00000000..90444c5e
--- /dev/null
+++ b/src/auth/commands/list-command.ts
@@ -0,0 +1,138 @@
+/**
+ * List Command Handler
+ *
+ * Lists all saved profiles from both legacy and unified config.
+ */
+
+import { ProfileMetadata } from '../../types';
+import { initUI, header, color, dim, warn, table } from '../../utils/ui';
+import { exitWithError } from '../../errors';
+import { ExitCode } from '../../errors/exit-codes';
+import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
+
+/**
+ * Handle the list command
+ */
+export async function handleList(ctx: CommandContext, args: string[]): Promise {
+ await initUI();
+ const { verbose, json } = parseArgs(args);
+
+ try {
+ // Get profiles from both legacy (profiles.json) and unified config (config.yaml)
+ const legacyProfiles = ctx.registry.getAllProfiles();
+ const unifiedAccounts = ctx.registry.getAllAccountsUnified();
+
+ // Merge profiles: unified config takes precedence
+ const profiles: Record = { ...legacyProfiles };
+ for (const [name, account] of Object.entries(unifiedAccounts)) {
+ profiles[name] = {
+ type: 'account',
+ created: account.created,
+ last_used: account.last_used,
+ };
+ }
+
+ const defaultProfile = ctx.registry.getDefaultUnified() ?? ctx.registry.getDefaultProfile();
+ const profileNames = Object.keys(profiles);
+
+ // JSON output mode
+ if (json) {
+ const output: ListOutput = {
+ version: ctx.version,
+ profiles: profileNames.map((name) => {
+ const profile = profiles[name];
+ const isDefault = name === defaultProfile;
+ const instancePath = ctx.instanceMgr.getInstancePath(name);
+
+ return {
+ name: name,
+ type: profile.type || 'account',
+ is_default: isDefault,
+ created: profile.created,
+ last_used: profile.last_used || null,
+ instance_path: instancePath,
+ };
+ }),
+ };
+ console.log(JSON.stringify(output, null, 2));
+ return;
+ }
+
+ // Human-readable output
+ if (profileNames.length === 0) {
+ console.log(warn('No account profiles found'));
+ console.log('');
+ console.log('To create your first profile:');
+ console.log(` ${color('ccs auth create ', 'command')}`);
+ console.log('');
+ console.log('Example:');
+ console.log(` ${color('ccs auth create work', 'command')}`);
+ console.log('');
+ return;
+ }
+
+ console.log(header('Saved Account Profiles'));
+ console.log('');
+
+ // Sort by last_used (descending), then alphabetically
+ const sorted = profileNames.sort((a, b) => {
+ const aProfile = profiles[a];
+ const bProfile = profiles[b];
+
+ // Default first
+ if (a === defaultProfile) return -1;
+ if (b === defaultProfile) return 1;
+
+ // Then by last_used
+ if (aProfile.last_used && bProfile.last_used) {
+ return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime();
+ }
+ if (aProfile.last_used) return -1;
+ if (bProfile.last_used) return 1;
+
+ // Then alphabetically
+ return a.localeCompare(b);
+ });
+
+ // Build table rows
+ const rows: string[][] = sorted.map((name) => {
+ const profile = profiles[name];
+ const isDefault = name === defaultProfile;
+
+ // Status column
+ const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
+
+ // Last used column
+ let lastUsed = '-';
+ if (profile.last_used) {
+ lastUsed = formatRelativeTime(new Date(profile.last_used));
+ }
+
+ const row = [color(name, isDefault ? 'primary' : 'info'), profile.type || 'account', status];
+
+ if (verbose) {
+ row.push(lastUsed);
+ }
+
+ return row;
+ });
+
+ // Headers
+ const headers = verbose
+ ? ['Profile', 'Type', 'Status', 'Last Used']
+ : ['Profile', 'Type', 'Status'];
+
+ // Print table
+ console.log(
+ table(rows, {
+ head: headers,
+ colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15],
+ })
+ );
+ console.log('');
+ console.log(dim(`Total: ${profileNames.length} profile(s)`));
+ console.log('');
+ } catch (error) {
+ exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
+ }
+}
diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts
new file mode 100644
index 00000000..cfef25cd
--- /dev/null
+++ b/src/auth/commands/remove-command.ts
@@ -0,0 +1,86 @@
+/**
+ * Remove Command Handler
+ *
+ * Removes a saved profile and its instance directory.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { initUI, color, ok, fail, info } from '../../utils/ui';
+import { InteractivePrompt } from '../../utils/prompt';
+import { isUnifiedMode } from '../../config/unified-config-loader';
+import { exitWithError } from '../../errors';
+import { ExitCode } from '../../errors/exit-codes';
+import { CommandContext, parseArgs } from './types';
+
+/**
+ * Handle the remove command
+ */
+export async function handleRemove(ctx: CommandContext, args: string[]): Promise {
+ await initUI();
+ const { profileName, yes } = parseArgs(args);
+
+ if (!profileName) {
+ console.log(fail('Profile name is required'));
+ console.log('');
+ console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`);
+ exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
+ }
+
+ // Check existence in both legacy and unified
+ const existsLegacy = ctx.registry.hasProfile(profileName);
+ const existsUnified = ctx.registry.hasAccountUnified(profileName);
+
+ if (!existsLegacy && !existsUnified) {
+ console.log(fail(`Profile not found: ${profileName}`));
+ exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
+ }
+
+ try {
+ // Get instance path and session count for impact display
+ const instancePath = ctx.instanceMgr.getInstancePath(profileName);
+ let sessionCount = 0;
+
+ try {
+ const sessionsDir = path.join(instancePath, 'session-env');
+ if (fs.existsSync(sessionsDir)) {
+ const files = fs.readdirSync(sessionsDir);
+ sessionCount = files.filter((f) => f.endsWith('.json')).length;
+ }
+ } catch (_e) {
+ // Ignore errors counting sessions
+ }
+
+ // Display impact
+ console.log('');
+ console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`);
+ console.log(` Instance path: ${instancePath}`);
+ console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`);
+ console.log('');
+
+ // Interactive confirmation (or --yes flag)
+ const confirmed =
+ yes || (await InteractivePrompt.confirm('Delete this profile?', { default: false })); // Default to NO (safe)
+
+ if (!confirmed) {
+ console.log(info('Cancelled'));
+ process.exit(0);
+ }
+
+ // Delete instance
+ ctx.instanceMgr.deleteInstance(profileName);
+
+ // Delete profile from appropriate config
+ if (isUnifiedMode() && existsUnified) {
+ ctx.registry.removeAccountUnified(profileName);
+ }
+ if (existsLegacy) {
+ ctx.registry.deleteProfile(profileName);
+ }
+
+ console.log(ok(`Profile removed: ${profileName}`));
+ console.log('');
+ } catch (error) {
+ exitWithError(`Failed to remove profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
+ }
+}
diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts
new file mode 100644
index 00000000..7f0c5f1a
--- /dev/null
+++ b/src/auth/commands/show-command.ts
@@ -0,0 +1,84 @@
+/**
+ * Show Command Handler
+ *
+ * Shows details for a specific profile.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { initUI, header, color, fail, table } from '../../utils/ui';
+import { exitWithError } from '../../errors';
+import { ExitCode } from '../../errors/exit-codes';
+import { CommandContext, ProfileOutput, parseArgs } from './types';
+
+/**
+ * Handle the show command
+ */
+export async function handleShow(ctx: CommandContext, args: string[]): Promise {
+ await initUI();
+ const { profileName, json } = parseArgs(args);
+
+ if (!profileName) {
+ console.log(fail('Profile name is required'));
+ console.log('');
+ console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`);
+ exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
+ }
+
+ try {
+ const profile = ctx.registry.getProfile(profileName);
+ const defaultProfile = ctx.registry.getDefaultProfile();
+ const isDefault = profileName === defaultProfile;
+ const instancePath = ctx.instanceMgr.getInstancePath(profileName);
+
+ // Count sessions
+ let sessionCount = 0;
+ try {
+ const sessionsDir = path.join(instancePath, 'session-env');
+ if (fs.existsSync(sessionsDir)) {
+ const files = fs.readdirSync(sessionsDir);
+ sessionCount = files.filter((f) => f.endsWith('.json')).length;
+ }
+ } catch (_e) {
+ // Ignore errors counting sessions
+ }
+
+ // JSON output mode
+ if (json) {
+ const output: ProfileOutput = {
+ name: profileName,
+ type: profile.type || 'account',
+ is_default: isDefault,
+ created: profile.created,
+ last_used: profile.last_used || null,
+ instance_path: instancePath,
+ session_count: sessionCount,
+ };
+ console.log(JSON.stringify(output, null, 2));
+ return;
+ }
+
+ // Human-readable output
+ const defaultBadge = isDefault ? color(' (default)', 'success') : '';
+ console.log(header(`Profile: ${profileName}${defaultBadge}`));
+ console.log('');
+
+ // Details table
+ const details = [
+ ['Type', profile.type || 'account'],
+ ['Instance', instancePath],
+ ['Created', new Date(profile.created).toLocaleString()],
+ ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
+ ['Sessions', `${sessionCount}`],
+ ];
+
+ console.log(
+ table(details, {
+ colWidths: [15, 45],
+ })
+ );
+ console.log('');
+ } catch (error) {
+ exitWithError((error as Error).message, ExitCode.PROFILE_ERROR);
+ }
+}
diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts
new file mode 100644
index 00000000..c1346753
--- /dev/null
+++ b/src/auth/commands/types.ts
@@ -0,0 +1,66 @@
+/**
+ * Auth Commands Type Definitions
+ *
+ * Shared interfaces for auth command modules.
+ */
+
+import ProfileRegistry from '../profile-registry';
+import { InstanceManager } from '../../management/instance-manager';
+
+// Re-export for backward compatibility
+export { formatRelativeTime } from '../../utils/time';
+
+/**
+ * Command arguments parsed from CLI
+ */
+export interface AuthCommandArgs {
+ profileName?: string;
+ force?: boolean;
+ verbose?: boolean;
+ json?: boolean;
+ yes?: boolean;
+}
+
+/**
+ * Profile output for JSON mode
+ */
+export interface ProfileOutput {
+ name: string;
+ type: string;
+ is_default: boolean;
+ created: string;
+ last_used: string | null;
+ instance_path?: string;
+ session_count?: number;
+}
+
+/**
+ * List output for JSON mode
+ */
+export interface ListOutput {
+ version: string;
+ profiles: ProfileOutput[];
+}
+
+/**
+ * Shared context passed to command handlers
+ */
+export interface CommandContext {
+ registry: ProfileRegistry;
+ instanceMgr: InstanceManager;
+ version: string;
+}
+
+/**
+ * Parse command arguments from raw args array
+ */
+export function parseArgs(args: string[]): AuthCommandArgs {
+ const profileName = args.find((arg) => !arg.startsWith('--'));
+ return {
+ profileName,
+ force: args.includes('--force'),
+ verbose: args.includes('--verbose'),
+ json: args.includes('--json'),
+ yes: args.includes('--yes') || args.includes('-y'),
+ };
+}
diff --git a/src/auth/index.ts b/src/auth/index.ts
new file mode 100644
index 00000000..c7298e77
--- /dev/null
+++ b/src/auth/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Auth module barrel export
+ */
+
+export { default as AuthCommands } from './auth-commands';
+export { default as ProfileRegistry } from './profile-registry';
+export { default as ProfileDetector } from './profile-detector';
+export type { ProfileDetectionResult, ProfileType, AllProfiles } from './profile-detector';
diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts
index cefa4fbb..ee2ffafd 100644
--- a/src/auth/profile-detector.ts
+++ b/src/auth/profile-detector.ts
@@ -15,9 +15,8 @@ import * as os from 'os';
import { findSimilarStrings } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
-import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader';
+import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getProfileSecrets } from '../config/secrets-manager';
-import { isUnifiedConfigEnabled } from '../config/feature-flags';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
@@ -79,20 +78,12 @@ class ProfileDetector {
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
}
- /**
- * Check if unified config mode is active.
- * Returns true if config.yaml exists or CCS_UNIFIED_CONFIG=1.
- */
- private isUnifiedMode(): boolean {
- return hasUnifiedConfig() || isUnifiedConfigEnabled();
- }
-
/**
* Load unified config if available.
* Returns null if not in unified mode or load fails.
*/
private readUnifiedConfig(): UnifiedConfig | null {
- if (!this.isUnifiedMode()) return null;
+ if (!isUnifiedMode()) return null;
return loadUnifiedConfig();
}
diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts
index e3571d8e..e0013e9c 100644
--- a/src/auth/profile-registry.ts
+++ b/src/auth/profile-registry.ts
@@ -3,11 +3,10 @@ import * as path from 'path';
import * as os from 'os';
import { ProfileMetadata } from '../types';
import {
- hasUnifiedConfig,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
+ isUnifiedMode,
} from '../config/unified-config-loader';
-import { isUnifiedConfigEnabled } from '../config/feature-flags';
/**
* Profile Registry (Simplified)
@@ -47,13 +46,6 @@ export class ProfileRegistry {
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
}
- /**
- * Check if unified config mode is active
- */
- private isUnifiedMode(): boolean {
- return hasUnifiedConfig() || isUnifiedConfigEnabled();
- }
-
/**
* Read profiles from disk
*/
@@ -306,7 +298,7 @@ export class ProfileRegistry {
* Check if account exists in unified config
*/
hasAccountUnified(name: string): boolean {
- if (!this.isUnifiedMode()) return false;
+ if (!isUnifiedMode()) return false;
const config = loadOrCreateUnifiedConfig();
return !!config.accounts[name];
}
@@ -315,7 +307,7 @@ export class ProfileRegistry {
* Get all accounts from unified config
*/
getAllAccountsUnified(): Record {
- if (!this.isUnifiedMode()) return {};
+ if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig();
return config.accounts;
}
@@ -324,7 +316,7 @@ export class ProfileRegistry {
* Get default from unified config
*/
getDefaultUnified(): string | undefined {
- if (!this.isUnifiedMode()) return undefined;
+ if (!isUnifiedMode()) return undefined;
const config = loadOrCreateUnifiedConfig();
return config.default;
}
diff --git a/src/ccs.ts b/src/ccs.ts
index 5fbd8faf..8b37d138 100644
--- a/src/ccs.ts
+++ b/src/ccs.ts
@@ -13,6 +13,9 @@ import {
} from './utils/websearch-manager';
import { getGlobalEnvConfig } from './config/unified-config-loader';
+// Import centralized error handling
+import { handleError, runCleanup } from './errors';
+
// Import extracted command handlers
import { handleVersionCommand } from './commands/version-command';
import { handleHelpCommand } from './commands/help-command';
@@ -566,8 +569,28 @@ async function main(): Promise {
}
}
-// Run main
-main().catch((error) => {
- console.error('Fatal error:', error.message);
- process.exit(1);
+// ========== Global Error Handlers ==========
+
+// Handle uncaught exceptions
+process.on('uncaughtException', (error: Error) => {
+ handleError(error);
});
+
+// Handle unhandled promise rejections
+process.on('unhandledRejection', (reason: unknown) => {
+ handleError(reason);
+});
+
+// Handle process termination signals for cleanup
+process.on('SIGTERM', () => {
+ runCleanup();
+ process.exit(0);
+});
+
+process.on('SIGINT', () => {
+ runCleanup();
+ process.exit(130); // 128 + SIGINT(2)
+});
+
+// Run main
+main().catch(handleError);
diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts
index 8ec68e3c..a7b3528f 100644
--- a/src/cliproxy/auth-handler.ts
+++ b/src/cliproxy/auth-handler.ts
@@ -10,895 +10,51 @@
*
* Token storage: ~/.ccs/cliproxy/auth//
* Each provider has its own directory to avoid conflicts.
- */
-
-import { execSync, spawn } from 'child_process';
-import * as fs from 'fs';
-import * as path from 'path';
-import { ok, fail, info, warn, color } from '../utils/ui';
-import { ensureCLIProxyBinary } from './binary-manager';
-import { generateConfig, getProviderAuthDir } from './config-generator';
-import { CLIProxyProvider } from './types';
-import {
- AccountInfo,
- discoverExistingAccounts,
- generateNickname,
- getDefaultAccount,
- getProviderAccounts,
- registerAccount,
- touchAccount,
-} from './account-manager';
-import {
- enhancedPreflightOAuthCheck,
- OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
-} from '../management/oauth-port-diagnostics';
-import {
- parseProjectList,
- parseDefaultProject,
- isProjectSelectionPrompt,
- isProjectList,
- generateSessionId,
- requestProjectSelection,
- type GCloudProject,
- type ProjectSelectionPrompt,
-} from './project-selection-handler';
-
-/**
- * OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
- * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
*
- * OAuth flow types per provider:
- * - Gemini: Authorization Code Flow with local callback server on port 8085
- * - Codex: Authorization Code Flow with local callback server on port 1455
- * - Agy: Authorization Code Flow with local callback server on port 51121
- * - Qwen: Device Code Flow (polling-based, NO callback port needed)
- *
- * We auto-kill processes on callback ports before OAuth to avoid conflicts.
+ * This file is a facade that re-exports from the auth/ submodule.
+ * The actual implementation is split across:
+ * - auth/auth-types.ts - Types and OAuth configurations
+ * - auth/token-manager.ts - Token storage/retrieval/validation
+ * - auth/oauth-handler.ts - OAuth flow handling
+ * - auth/environment-detector.ts - Headless detection and port management
*/
-const OAUTH_CALLBACK_PORTS: Partial> = {
- gemini: 8085,
- // codex uses 1455
- // agy uses 51121
- // qwen uses Device Code Flow - no callback port needed
-};
-/**
- * Kill any process using a specific port
- * Used to free OAuth callback port before authentication
- */
-function killProcessOnPort(port: number, verbose: boolean): boolean {
- try {
- if (process.platform === 'win32') {
- // Windows: use netstat + taskkill
- const result = execSync(`netstat -ano | findstr :${port}`, {
- encoding: 'utf-8',
- stdio: 'pipe',
- });
- const lines = result.trim().split('\n');
- for (const line of lines) {
- const parts = line.trim().split(/\s+/);
- const pid = parts[parts.length - 1];
- if (pid && /^\d+$/.test(pid)) {
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
- if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
- }
- }
- return true;
- } else {
- // Unix: use lsof + kill
- const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
- const pids = result
- .trim()
- .split('\n')
- .filter((p) => p);
- for (const pid of pids) {
- execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
- if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
- }
- return pids.length > 0;
- }
- } catch {
- // No process on port or command failed - that's fine
- return false;
- }
-}
-
-/**
- * Detect if running in a headless environment (no browser available)
- *
- * IMPROVED: Avoids false positives on Windows desktop environments
- * where isTTY may be undefined due to terminal wrapper behavior.
- *
- * Case study: Vietnamese Windows users reported "command hangs" because
- * their terminal (PowerShell via npm) didn't set isTTY correctly.
- */
-function isHeadlessEnvironment(): boolean {
- // SSH session - always headless
- if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
- return true;
- }
-
- // No display on Linux (X11/Wayland) - headless
- if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
- return true;
- }
-
- // Windows desktop - NEVER headless unless SSH (already checked above)
- // This fixes false positive where Windows npm wrappers don't set isTTY correctly
- // Windows desktop environments always have browser capability
- if (process.platform === 'win32') {
- return false;
- }
-
- // macOS - check for proper terminal
- if (process.platform === 'darwin') {
- // Non-interactive stdin on macOS means likely piped/scripted
- if (!process.stdin.isTTY) {
- return true;
- }
- return false;
- }
-
- // Linux with display - check TTY
- if (process.platform === 'linux') {
- if (!process.stdin.isTTY) {
- return true;
- }
- return false;
- }
-
- // Default fallback for unknown platforms
- return !process.stdin.isTTY;
-}
-
-/**
- * Auth status for a provider
- */
-export interface AuthStatus {
- /** Provider name */
- provider: CLIProxyProvider;
- /** Whether authentication exists */
- authenticated: boolean;
- /** Path to token directory */
- tokenDir: string;
- /** Token file paths found */
- tokenFiles: string[];
- /** When last authenticated (if known) */
- lastAuth?: Date;
- /** Accounts registered for this provider (multi-account support) */
- accounts: AccountInfo[];
- /** Default account ID */
- defaultAccount?: string;
-}
-
-/**
- * OAuth config for each provider
- */
-interface ProviderOAuthConfig {
- /** Provider identifier */
- provider: CLIProxyProvider;
- /** Display name */
- displayName: string;
- /** OAuth authorization URL (for manual flow) */
- authUrl: string;
- /** Scopes required */
- scopes: string[];
- /** CLI flag for auth */
- authFlag: string;
-}
-
-/**
- * OAuth configurations per provider
- * Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
- */
-const OAUTH_CONFIGS: Record = {
- gemini: {
- provider: 'gemini',
- displayName: 'Google Gemini',
- authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
- scopes: ['https://www.googleapis.com/auth/generative-language'],
- authFlag: '--login',
- },
- codex: {
- provider: 'codex',
- displayName: 'Codex',
- authUrl: 'https://auth.openai.com/authorize',
- scopes: ['openid', 'profile'],
- authFlag: '--codex-login',
- },
- agy: {
- provider: 'agy',
- displayName: 'Antigravity',
- authUrl: 'https://antigravity.ai/oauth/authorize',
- scopes: ['api'],
- authFlag: '--antigravity-login',
- },
- qwen: {
- provider: 'qwen',
- displayName: 'Qwen Code',
- authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
- scopes: ['openid', 'profile', 'email', 'model.completion'],
- authFlag: '--qwen-login',
- },
- iflow: {
- provider: 'iflow',
- displayName: 'iFlow',
- authUrl: 'https://iflow.cn/oauth',
- scopes: ['phone', 'profile', 'email'],
- authFlag: '--iflow-login',
- },
-};
-
-/**
- * Get OAuth config for provider
- */
-export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
- const config = OAUTH_CONFIGS[provider];
- if (!config) {
- throw new Error(`Unknown provider: ${provider}`);
- }
- return config;
-}
-
-/**
- * Get token directory for provider
- */
-export function getProviderTokenDir(provider: CLIProxyProvider): string {
- return getProviderAuthDir(provider);
-}
-
-/**
- * Provider-specific auth file prefixes (fallback detection)
- * CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
- * Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
- */
-const PROVIDER_AUTH_PREFIXES: Record = {
- gemini: ['gemini-', 'google-'],
- codex: ['codex-', 'openai-'],
- agy: ['antigravity-', 'agy-'],
- qwen: ['qwen-'],
- iflow: ['iflow-'],
-};
-
-/**
- * Provider type values inside token JSON files
- * CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
- */
-const PROVIDER_TYPE_VALUES: Record = {
- gemini: ['gemini'],
- codex: ['codex'],
- agy: ['antigravity'],
- qwen: ['qwen'],
- iflow: ['iflow'],
-};
-
-/**
- * Check if a JSON file contains a token for the given provider
- * Reads the file and checks the "type" field
- */
-function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
- try {
- const content = fs.readFileSync(filePath, 'utf-8');
- const data = JSON.parse(content);
- const typeValue = (data.type || '').toLowerCase();
- const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
- return validTypes.includes(typeValue);
- } catch {
- return false;
- }
-}
-
-/**
- * Check if provider has valid authentication
- * CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
- * Detection strategy:
- * 1. First check by filename prefix (fast path)
- * 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
- */
-export function isAuthenticated(provider: CLIProxyProvider): boolean {
- const tokenDir = getProviderTokenDir(provider);
-
- if (!fs.existsSync(tokenDir)) {
- return false;
- }
-
- const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
-
- try {
- const files = fs.readdirSync(tokenDir);
- const jsonFiles = files.filter(
- (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
- );
-
- // Strategy 1: Check by filename prefix (fast path for antigravity, codex)
- const prefixMatch = jsonFiles.some((f) => {
- const lowerFile = f.toLowerCase();
- return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
- });
- if (prefixMatch) return true;
-
- // Strategy 2: Check JSON content for "type" field (needed for Gemini)
- for (const f of jsonFiles) {
- const filePath = path.join(tokenDir, f);
- if (isTokenFileForProvider(filePath, provider)) {
- return true;
- }
- }
-
- return false;
- } catch {
- return false;
- }
-}
-
-/**
- * Get detailed auth status for provider
- * Uses same detection strategy as isAuthenticated: prefix first, then content
- */
-export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
- const tokenDir = getProviderTokenDir(provider);
- let tokenFiles: string[] = [];
- let lastAuth: Date | undefined;
-
- const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
-
- if (fs.existsSync(tokenDir)) {
- const files = fs.readdirSync(tokenDir);
- const jsonFiles = files.filter(
- (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
- );
-
- // Check each file: by prefix OR by content
- tokenFiles = jsonFiles.filter((f) => {
- const lowerFile = f.toLowerCase();
- // Strategy 1: prefix match
- if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
- return true;
- }
- // Strategy 2: content match (for Gemini tokens without prefix)
- const filePath = path.join(tokenDir, f);
- return isTokenFileForProvider(filePath, provider);
- });
-
- // Get most recent modification time
- for (const file of tokenFiles) {
- const filePath = path.join(tokenDir, file);
- try {
- const stats = fs.statSync(filePath);
- if (!lastAuth || stats.mtime > lastAuth) {
- lastAuth = stats.mtime;
- }
- } catch {
- // Skip if can't stat file
- }
- }
- }
-
- // Get registered accounts for multi-account support
- const accounts = getProviderAccounts(provider);
- const defaultAccount = getDefaultAccount(provider);
-
- return {
- provider,
- authenticated: tokenFiles.length > 0,
- tokenDir,
- tokenFiles,
- lastAuth,
- accounts,
- defaultAccount: defaultAccount?.id,
- };
-}
-
-/**
- * Get auth status for all providers
- */
-export function getAllAuthStatus(): AuthStatus[] {
- const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
- return providers.map(getAuthStatus);
-}
-
-/**
- * Clear authentication for provider
- * Only removes files belonging to the specified provider (by prefix or content)
- * Does NOT remove the shared auth directory or other providers' files
- */
-export function clearAuth(provider: CLIProxyProvider): boolean {
- const tokenDir = getProviderTokenDir(provider);
-
- if (!fs.existsSync(tokenDir)) {
- return false;
- }
-
- const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
- const files = fs.readdirSync(tokenDir);
- let removedCount = 0;
-
- // Only remove files that belong to this provider
- for (const file of files) {
- const filePath = path.join(tokenDir, file);
- const lowerFile = file.toLowerCase();
-
- // Check by prefix first (fast path)
- const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
-
- // If no prefix match, check by content (for Gemini tokens without prefix)
- const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
-
- if (matchesByPrefix || matchesByContent) {
- try {
- fs.unlinkSync(filePath);
- removedCount++;
- } catch {
- // Failed to remove - skip
- }
- }
- }
-
- // DO NOT remove the shared auth directory - other providers may still have tokens
- return removedCount > 0;
-}
-
-/**
- * Display a single step status line
- */
-function showStep(
- step: number,
- total: number,
- status: 'ok' | 'fail' | 'progress',
- message: string
-): void {
- const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]';
- console.log(`${statusIcon} [${step}/${total}] ${message}`);
-}
-
-/**
- * Get platform-specific troubleshooting for OAuth timeout
- */
-function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | null): string[] {
- const lines: string[] = [];
- lines.push('');
- lines.push('TROUBLESHOOTING:');
- lines.push(' 1. Check browser completed auth (should show success page)');
-
- if (port) {
- lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
- lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
- } else {
- lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
- }
-
- return lines;
-}
-
-/**
- * Trigger OAuth flow for provider
- * Auto-detects headless environment and uses --no-browser flag accordingly
- * Shows real-time step-by-step progress for better user feedback
- * @param provider - The CLIProxy provider to authenticate
- * @param options - OAuth options
- * @param options.add - If true, skip confirm prompt when adding another account
- * @returns Account info if successful, null otherwise
- */
-export async function triggerOAuth(
- provider: CLIProxyProvider,
- options: {
- verbose?: boolean;
- headless?: boolean;
- account?: string;
- add?: boolean;
- nickname?: string;
- /** If true, triggered from Web UI (enables project selection prompt) */
- fromUI?: boolean;
- } = {}
-): Promise {
- const oauthConfig = getOAuthConfig(provider);
- const { verbose = false, add = false, nickname, fromUI = false } = options;
- const callbackPort = OAUTH_PORTS[provider];
- const isCLI = !fromUI; // CLI mode = auto-select default project
-
- // Auto-detect headless if not explicitly set
- const headless = options.headless ?? isHeadlessEnvironment();
-
- const log = (msg: string) => {
- if (verbose) {
- console.error(`[auth] ${msg}`);
- }
- };
-
- // Check for existing accounts and prompt if --add not specified
- const existingAccounts = getProviderAccounts(provider);
- if (existingAccounts.length > 0 && !add) {
- console.log('');
- console.log(
- info(
- `${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
- )
- );
-
- // Import readline for confirm prompt
- const readline = await import('readline');
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- });
-
- const confirmed = await new Promise((resolve) => {
- rl.question('[?] Add another account? (y/N): ', (answer) => {
- rl.close();
- resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
- });
- });
-
- if (!confirmed) {
- console.log(info('Cancelled'));
- return null;
- }
- }
-
- // Enhanced pre-flight check with real-time display
- console.log('');
- console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
-
- const preflight = await enhancedPreflightOAuthCheck(provider);
-
- // Display each check result
- for (const check of preflight.checks) {
- const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]';
- console.log(` ${icon} ${check.name}: ${check.message}`);
- if (check.fixCommand && check.status !== 'ok') {
- console.log(` Fix: ${check.fixCommand}`);
- }
- }
-
- // Show firewall warning prominently on Windows
- if (preflight.firewallWarning) {
- console.log('');
- console.log(warn('Windows Firewall may block OAuth callback'));
- console.log(' If auth hangs, run as Administrator:');
- console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`);
- }
-
- if (!preflight.ready) {
- console.log('');
- console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
- return null;
- }
-
- console.log('');
-
- // Step 1: Ensure binary exists
- showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
- let binaryPath: string;
- try {
- binaryPath = await ensureCLIProxyBinary(verbose);
- // Clear and rewrite with OK
- process.stdout.write('\x1b[1A\x1b[2K'); // Move up and clear line
- showStep(1, 4, 'ok', 'CLIProxy binary ready');
- } catch (error) {
- process.stdout.write('\x1b[1A\x1b[2K');
- showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
- console.error(fail((error as Error).message));
- throw error;
- }
-
- // Ensure auth directory exists
- const tokenDir = getProviderTokenDir(provider);
- fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
-
- // Generate config file (CLIProxyAPI requires it even for auth)
- const configPath = generateConfig(provider);
- log(`Config generated: ${configPath}`);
-
- // Free OAuth callback port if needed
- const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
- if (localCallbackPort) {
- const killed = killProcessOnPort(localCallbackPort, verbose);
- if (killed) {
- log(`Freed port ${localCallbackPort} for OAuth callback`);
- }
- }
-
- // Build args: config + auth flag + optional --no-browser for headless
- const args = ['--config', configPath, oauthConfig.authFlag];
- if (headless) {
- args.push('--no-browser');
- }
-
- // Step 2: Starting callback server
- showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
-
- // Show headless instructions if needed
- if (headless) {
- console.log('');
- console.log(warn('PORT FORWARDING REQUIRED'));
- console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
- console.log(' Run this on your LOCAL machine:');
- console.log(
- ` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} @`, 'command')}`
- );
- console.log('');
- }
-
- return new Promise((resolve) => {
- // Spawn CLIProxyAPI with auth flag
- // Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection)
- const authProcess = spawn(binaryPath, args, {
- stdio: ['pipe', 'pipe', 'pipe'],
- env: {
- ...process.env,
- CLI_PROXY_AUTH_DIR: tokenDir,
- },
- });
-
- let stderrData = '';
- let urlDisplayed = false;
- let browserOpened = false;
- let projectPromptHandled = false;
- let accumulatedOutput = ''; // Accumulate output to parse project list
- let parsedProjects: GCloudProject[] = [];
- const sessionId = generateSessionId(); // Unique session ID for this auth flow
- const startTime = Date.now();
-
- authProcess.stdout?.on('data', async (data: Buffer) => {
- const output = data.toString();
- log(`stdout: ${output.trim()}`);
-
- // Accumulate output for project list parsing
- accumulatedOutput += output;
-
- // Parse project list when available
- if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) {
- parsedProjects = parseProjectList(accumulatedOutput);
- log(`Parsed ${parsedProjects.length} projects`);
- }
-
- // Handle project selection prompt
- if (!projectPromptHandled && isProjectSelectionPrompt(output)) {
- projectPromptHandled = true;
-
- const defaultProjectId = parseDefaultProject(output) || '';
-
- // If we have projects and this is a UI-triggered flow, request selection
- if (parsedProjects.length > 0 && !isCLI) {
- log(`Requesting project selection from UI (session: ${sessionId})`);
-
- const prompt: ProjectSelectionPrompt = {
- sessionId,
- provider,
- projects: parsedProjects,
- defaultProjectId,
- supportsAll: output.includes('ALL'),
- };
-
- try {
- // Request selection from UI (with timeout fallback to default)
- const selectedId = await requestProjectSelection(prompt);
-
- // Write selection to stdin (empty = default, else project ID or ALL)
- const response = selectedId || '';
- log(`User selected: ${response || '(default)'}`);
- authProcess.stdin?.write(response + '\n');
- } catch {
- // Fallback to default on error
- log('Project selection failed, using default');
- authProcess.stdin?.write('\n');
- }
- } else {
- // CLI mode or no projects: auto-select default
- log('CLI mode or no projects, auto-selecting default');
- authProcess.stdin?.write('\n');
- }
- }
-
- // Detect when callback server starts or browser opens
- if (!browserOpened && (output.includes('listening') || output.includes('http'))) {
- process.stdout.write('\x1b[1A\x1b[2K');
- showStep(2, 4, 'ok', `Callback server listening on port ${callbackPort}`);
- showStep(3, 4, 'progress', 'Opening browser...');
- browserOpened = true;
- }
-
- // In headless mode, display OAuth URLs prominently
- if (headless) {
- const urlMatch = output.match(/https?:\/\/[^\s]+/);
- if (urlMatch && !urlDisplayed) {
- console.log('');
- console.log(info(`${oauthConfig.displayName} OAuth URL:`));
- console.log(` ${urlMatch[0]}`);
- console.log('');
- urlDisplayed = true;
- }
- }
- });
-
- authProcess.stderr?.on('data', (data: Buffer) => {
- const output = data.toString();
- stderrData += output;
- log(`stderr: ${output.trim()}`);
-
- // Also check stderr for URLs
- if (headless && !urlDisplayed) {
- const urlMatch = output.match(/https?:\/\/[^\s]+/);
- if (urlMatch) {
- console.log('');
- console.log(info(`${oauthConfig.displayName} OAuth URL:`));
- console.log(` ${urlMatch[0]}`);
- console.log('');
- urlDisplayed = true;
- }
- }
- });
-
- // After a short delay, assume browser opened and show waiting
- setTimeout(() => {
- if (!browserOpened) {
- process.stdout.write('\x1b[1A\x1b[2K');
- showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
- showStep(3, 4, 'ok', 'Browser opened');
- browserOpened = true;
- }
- showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
- console.log('');
- console.log(info('Complete the login in your browser. This page will update automatically.'));
- if (!verbose) {
- console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
- }
- }, 2000);
-
- // Timeout after 5 minutes for headless, 2 minutes for normal
- const timeoutMs = headless ? 300000 : 120000;
- const timeout = setTimeout(() => {
- authProcess.kill();
- console.log('');
- console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
-
- // Show platform-specific troubleshooting
- const troubleshooting = getTimeoutTroubleshooting(provider, callbackPort);
- for (const line of troubleshooting) {
- console.log(line);
- }
-
- resolve(null);
- }, timeoutMs);
-
- authProcess.on('exit', (code) => {
- clearTimeout(timeout);
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
-
- if (code === 0) {
- // Verify token was created BEFORE showing success
- if (isAuthenticated(provider)) {
- console.log('');
- console.log(ok(`Authentication successful (${elapsed}s)`));
-
- // Register the account in accounts registry
- const account = registerAccountFromToken(provider, tokenDir, nickname);
- resolve(account);
- } else {
- console.log('');
- console.log(fail('Token not found after authentication'));
- console.log('');
- console.log('The browser showed success but callback was not received.');
-
- // Show platform-specific guidance
- if (process.platform === 'win32') {
- console.log('');
- console.log('On Windows, this usually means:');
- console.log(' 1. Windows Firewall blocked the callback');
- console.log(' 2. Antivirus software blocked the connection');
- console.log('');
- console.log('Try running as Administrator:');
- console.log(
- ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}`
- );
- }
-
- console.log('');
- console.log(`Try: ccs ${provider} --auth --verbose`);
- resolve(null);
- }
- } else {
- console.log('');
- console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
- if (stderrData && !urlDisplayed) {
- console.log(` ${stderrData.trim().split('\n')[0]}`);
- }
- if (headless && !urlDisplayed) {
- console.log('');
- console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
- }
- resolve(null);
- }
- });
-
- authProcess.on('error', (error) => {
- clearTimeout(timeout);
- console.log('');
- console.log(fail(`Failed to start auth process: ${error.message}`));
- resolve(null);
- });
- });
-}
-
-/**
- * Register account from newly created token file
- * Scans auth directory for new token and extracts email
- * @param provider - The CLIProxy provider
- * @param tokenDir - Directory containing token files
- * @param nickname - Optional nickname (uses auto-generated from email if not provided)
- */
-function registerAccountFromToken(
- provider: CLIProxyProvider,
- tokenDir: string,
- nickname?: string
-): AccountInfo | null {
- try {
- const files = fs.readdirSync(tokenDir);
- const jsonFiles = files.filter((f) => f.endsWith('.json'));
-
- // Find newest token file for this provider
- let newestFile: string | null = null;
- let newestMtime = 0;
-
- for (const file of jsonFiles) {
- const filePath = path.join(tokenDir, file);
- if (!isTokenFileForProvider(filePath, provider)) continue;
-
- const stats = fs.statSync(filePath);
- if (stats.mtimeMs > newestMtime) {
- newestMtime = stats.mtimeMs;
- newestFile = file;
- }
- }
-
- if (!newestFile) {
- return null;
- }
-
- // Read token to extract email
- const tokenPath = path.join(tokenDir, newestFile);
- const content = fs.readFileSync(tokenPath, 'utf-8');
- const data = JSON.parse(content);
- const email = data.email || undefined;
-
- // Register the account (use provided nickname or auto-generate from email)
- return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
- } catch {
- return null;
- }
-}
-
-/**
- * Ensure provider is authenticated
- * Triggers OAuth flow if not authenticated
- * @param provider - The CLIProxy provider
- * @param options - Auth options including optional account
- * @returns true if authenticated, false otherwise
- */
-export async function ensureAuth(
- provider: CLIProxyProvider,
- options: { verbose?: boolean; headless?: boolean; account?: string } = {}
-): Promise {
- // Check if already authenticated
- if (isAuthenticated(provider)) {
- if (options.verbose) {
- console.error(`[auth] ${provider} already authenticated`);
- }
- // Touch the account to update last used time
- const defaultAccount = getDefaultAccount(provider);
- if (defaultAccount) {
- touchAccount(provider, options.account || defaultAccount.id);
- }
- return true;
- }
-
- // Not authenticated - trigger OAuth
- const oauthConfig = getOAuthConfig(provider);
- console.log(info(`${oauthConfig.displayName} authentication required`));
-
- const account = await triggerOAuth(provider, options);
- return account !== null;
-}
+// Re-export types
+export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth';
+
+// Re-export configurations
+export {
+ OAUTH_CALLBACK_PORTS,
+ OAUTH_CONFIGS,
+ PROVIDER_AUTH_PREFIXES,
+ PROVIDER_TYPE_VALUES,
+ getOAuthConfig,
+} from './auth';
+
+// Re-export token management functions
+export {
+ getProviderTokenDir,
+ isTokenFileForProvider,
+ isAuthenticated,
+ getAuthStatus,
+ getAllAuthStatus,
+ clearAuth,
+ displayAuthStatus,
+} from './auth';
+
+// Re-export environment detection functions
+export {
+ isHeadlessEnvironment,
+ killProcessOnPort,
+ getTimeoutTroubleshooting,
+ showStep,
+} from './auth';
+
+// Re-export OAuth handling functions
+export { triggerOAuth, ensureAuth } from './auth';
+
+// Re-export account management initialization
+import { discoverExistingAccounts } from './account-manager';
/**
* Initialize accounts registry from existing tokens
@@ -907,26 +63,3 @@ export async function ensureAuth(
export function initializeAccounts(): void {
discoverExistingAccounts();
}
-
-/**
- * Display auth status for all providers
- */
-export function displayAuthStatus(): void {
- console.log('CLIProxy Authentication Status:');
- console.log('');
-
- const statuses = getAllAuthStatus();
-
- for (const status of statuses) {
- const oauthConfig = getOAuthConfig(status.provider);
- const icon = status.authenticated ? '[OK]' : '[!]';
- const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
- const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
-
- console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
- }
-
- console.log('');
- console.log('To authenticate: ccs --auth');
- console.log('To logout: ccs --logout');
-}
diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts
new file mode 100644
index 00000000..ddabae1c
--- /dev/null
+++ b/src/cliproxy/auth/auth-types.ts
@@ -0,0 +1,152 @@
+/**
+ * Auth Types and Configurations
+ *
+ * Type definitions and OAuth configurations for CLIProxy authentication.
+ */
+
+import { CLIProxyProvider } from '../types';
+import { AccountInfo } from '../account-manager';
+
+/**
+ * OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
+ * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth
+ *
+ * OAuth flow types per provider:
+ * - Gemini: Authorization Code Flow with local callback server on port 8085
+ * - Codex: Authorization Code Flow with local callback server on port 1455
+ * - Agy: Authorization Code Flow with local callback server on port 51121
+ * - Qwen: Device Code Flow (polling-based, NO callback port needed)
+ */
+export const OAUTH_CALLBACK_PORTS: Partial> = {
+ gemini: 8085,
+ // codex uses 1455
+ // agy uses 51121
+ // qwen uses Device Code Flow - no callback port needed
+};
+
+/**
+ * Auth status for a provider
+ */
+export interface AuthStatus {
+ /** Provider name */
+ provider: CLIProxyProvider;
+ /** Whether authentication exists */
+ authenticated: boolean;
+ /** Path to token directory */
+ tokenDir: string;
+ /** Token file paths found */
+ tokenFiles: string[];
+ /** When last authenticated (if known) */
+ lastAuth?: Date;
+ /** Accounts registered for this provider (multi-account support) */
+ accounts: AccountInfo[];
+ /** Default account ID */
+ defaultAccount?: string;
+}
+
+/**
+ * OAuth config for each provider
+ */
+export interface ProviderOAuthConfig {
+ /** Provider identifier */
+ provider: CLIProxyProvider;
+ /** Display name */
+ displayName: string;
+ /** OAuth authorization URL (for manual flow) */
+ authUrl: string;
+ /** Scopes required */
+ scopes: string[];
+ /** CLI flag for auth */
+ authFlag: string;
+}
+
+/**
+ * OAuth configurations per provider
+ * Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
+ */
+export const OAUTH_CONFIGS: Record = {
+ gemini: {
+ provider: 'gemini',
+ displayName: 'Google Gemini',
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ scopes: ['https://www.googleapis.com/auth/generative-language'],
+ authFlag: '--login',
+ },
+ codex: {
+ provider: 'codex',
+ displayName: 'Codex',
+ authUrl: 'https://auth.openai.com/authorize',
+ scopes: ['openid', 'profile'],
+ authFlag: '--codex-login',
+ },
+ agy: {
+ provider: 'agy',
+ displayName: 'Antigravity',
+ authUrl: 'https://antigravity.ai/oauth/authorize',
+ scopes: ['api'],
+ authFlag: '--antigravity-login',
+ },
+ qwen: {
+ provider: 'qwen',
+ displayName: 'Qwen Code',
+ authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
+ scopes: ['openid', 'profile', 'email', 'model.completion'],
+ authFlag: '--qwen-login',
+ },
+ iflow: {
+ provider: 'iflow',
+ displayName: 'iFlow',
+ authUrl: 'https://iflow.cn/oauth',
+ scopes: ['phone', 'profile', 'email'],
+ authFlag: '--iflow-login',
+ },
+};
+
+/**
+ * Provider-specific auth file prefixes (fallback detection)
+ * CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
+ * Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
+ */
+export const PROVIDER_AUTH_PREFIXES: Record = {
+ gemini: ['gemini-', 'google-'],
+ codex: ['codex-', 'openai-'],
+ agy: ['antigravity-', 'agy-'],
+ qwen: ['qwen-'],
+ iflow: ['iflow-'],
+};
+
+/**
+ * Provider type values inside token JSON files
+ * CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
+ */
+export const PROVIDER_TYPE_VALUES: Record = {
+ gemini: ['gemini'],
+ codex: ['codex'],
+ agy: ['antigravity'],
+ qwen: ['qwen'],
+ iflow: ['iflow'],
+};
+
+/**
+ * Get OAuth config for provider
+ */
+export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig {
+ const config = OAUTH_CONFIGS[provider];
+ if (!config) {
+ throw new Error(`Unknown provider: ${provider}`);
+ }
+ return config;
+}
+
+/**
+ * OAuth options for triggerOAuth
+ */
+export interface OAuthOptions {
+ verbose?: boolean;
+ headless?: boolean;
+ account?: string;
+ add?: boolean;
+ nickname?: string;
+ /** If true, triggered from Web UI (enables project selection prompt) */
+ fromUI?: boolean;
+}
diff --git a/src/cliproxy/auth/environment-detector.ts b/src/cliproxy/auth/environment-detector.ts
new file mode 100644
index 00000000..287f0076
--- /dev/null
+++ b/src/cliproxy/auth/environment-detector.ts
@@ -0,0 +1,132 @@
+/**
+ * Environment Detector for CLIProxyAPI
+ *
+ * Detects headless environments and manages OAuth callback ports.
+ */
+
+import { execSync } from 'child_process';
+import { CLIProxyProvider } from '../types';
+
+/**
+ * Detect if running in a headless environment (no browser available)
+ *
+ * IMPROVED: Avoids false positives on Windows desktop environments
+ * where isTTY may be undefined due to terminal wrapper behavior.
+ *
+ * Case study: Vietnamese Windows users reported "command hangs" because
+ * their terminal (PowerShell via npm) didn't set isTTY correctly.
+ */
+export function isHeadlessEnvironment(): boolean {
+ // SSH session - always headless
+ if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
+ return true;
+ }
+
+ // No display on Linux (X11/Wayland) - headless
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
+ return true;
+ }
+
+ // Windows desktop - NEVER headless unless SSH (already checked above)
+ // This fixes false positive where Windows npm wrappers don't set isTTY correctly
+ // Windows desktop environments always have browser capability
+ if (process.platform === 'win32') {
+ return false;
+ }
+
+ // macOS - check for proper terminal
+ if (process.platform === 'darwin') {
+ // Non-interactive stdin on macOS means likely piped/scripted
+ if (!process.stdin.isTTY) {
+ return true;
+ }
+ return false;
+ }
+
+ // Linux with display - check TTY
+ if (process.platform === 'linux') {
+ if (!process.stdin.isTTY) {
+ return true;
+ }
+ return false;
+ }
+
+ // Default fallback for unknown platforms
+ return !process.stdin.isTTY;
+}
+
+/**
+ * Kill any process using a specific port
+ * Used to free OAuth callback port before authentication
+ */
+export function killProcessOnPort(port: number, verbose: boolean): boolean {
+ try {
+ if (process.platform === 'win32') {
+ // Windows: use netstat + taskkill
+ const result = execSync(`netstat -ano | findstr :${port}`, {
+ encoding: 'utf-8',
+ stdio: 'pipe',
+ });
+ const lines = result.trim().split('\n');
+ for (const line of lines) {
+ const parts = line.trim().split(/\s+/);
+ const pid = parts[parts.length - 1];
+ if (pid && /^\d+$/.test(pid)) {
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' });
+ if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
+ }
+ }
+ return true;
+ } else {
+ // Unix: use lsof + kill
+ const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' });
+ const pids = result
+ .trim()
+ .split('\n')
+ .filter((p) => p);
+ for (const pid of pids) {
+ execSync(`kill -9 ${pid}`, { stdio: 'pipe' });
+ if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`);
+ }
+ return pids.length > 0;
+ }
+ } catch {
+ // No process on port or command failed - that's fine
+ return false;
+ }
+}
+
+/**
+ * Get platform-specific troubleshooting for OAuth timeout
+ */
+export function getTimeoutTroubleshooting(
+ provider: CLIProxyProvider,
+ port: number | null
+): string[] {
+ const lines: string[] = [];
+ lines.push('');
+ lines.push('TROUBLESHOOTING:');
+ lines.push(' 1. Check browser completed auth (should show success page)');
+
+ if (port) {
+ lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
+ lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
+ } else {
+ lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
+ }
+
+ return lines;
+}
+
+/**
+ * Display a single step status line
+ */
+export function showStep(
+ step: number,
+ total: number,
+ status: 'ok' | 'fail' | 'progress',
+ message: string
+): void {
+ const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]';
+ console.log(`${statusIcon} [${step}/${total}] ${message}`);
+}
diff --git a/src/cliproxy/auth/index.ts b/src/cliproxy/auth/index.ts
new file mode 100644
index 00000000..fd6d0214
--- /dev/null
+++ b/src/cliproxy/auth/index.ts
@@ -0,0 +1,38 @@
+/**
+ * Auth Module - Barrel Export
+ *
+ * Re-exports all auth-related functionality from submodules.
+ */
+
+// Types and configurations
+export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth-types';
+export {
+ OAUTH_CALLBACK_PORTS,
+ OAUTH_CONFIGS,
+ PROVIDER_AUTH_PREFIXES,
+ PROVIDER_TYPE_VALUES,
+ getOAuthConfig,
+} from './auth-types';
+
+// Token management
+export {
+ getProviderTokenDir,
+ isTokenFileForProvider,
+ isAuthenticated,
+ getAuthStatus,
+ getAllAuthStatus,
+ clearAuth,
+ registerAccountFromToken,
+ displayAuthStatus,
+} from './token-manager';
+
+// Environment detection
+export {
+ isHeadlessEnvironment,
+ killProcessOnPort,
+ getTimeoutTroubleshooting,
+ showStep,
+} from './environment-detector';
+
+// OAuth handling
+export { triggerOAuth, ensureAuth } from './oauth-handler';
diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts
new file mode 100644
index 00000000..b27201d6
--- /dev/null
+++ b/src/cliproxy/auth/oauth-handler.ts
@@ -0,0 +1,230 @@
+/**
+ * OAuth Handler for CLIProxyAPI
+ *
+ * Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity).
+ * CLIProxyAPI handles OAuth internally - we just need to:
+ * 1. Check if auth exists (token files in CCS auth directory)
+ * 2. Trigger OAuth flow by spawning binary with auth flag
+ * 3. Auto-detect headless environments (SSH, no DISPLAY)
+ * 4. Use --no-browser flag for headless, display OAuth URL for manual auth
+ */
+
+import * as fs from 'fs';
+import { fail, info, warn, color } from '../../utils/ui';
+import { ensureCLIProxyBinary } from '../binary-manager';
+import { generateConfig } from '../config-generator';
+import { CLIProxyProvider } from '../types';
+import {
+ AccountInfo,
+ getProviderAccounts,
+ getDefaultAccount,
+ touchAccount,
+} from '../account-manager';
+import {
+ enhancedPreflightOAuthCheck,
+ OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
+} from '../../management/oauth-port-diagnostics';
+import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
+import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
+import { getProviderTokenDir, isAuthenticated } from './token-manager';
+import { executeOAuthProcess } from './oauth-process';
+
+/**
+ * Prompt user to add another account
+ */
+async function promptAddAccount(): Promise {
+ const readline = await import('readline');
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ return new Promise((resolve) => {
+ rl.question('[?] Add another account? (y/N): ', (answer) => {
+ rl.close();
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
+ });
+ });
+}
+
+/**
+ * Run pre-flight OAuth checks
+ */
+async function runPreflightChecks(
+ provider: CLIProxyProvider,
+ oauthConfig: { displayName: string }
+): Promise {
+ console.log('');
+ console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`));
+
+ const preflight = await enhancedPreflightOAuthCheck(provider);
+
+ for (const check of preflight.checks) {
+ const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]';
+ console.log(` ${icon} ${check.name}: ${check.message}`);
+ if (check.fixCommand && check.status !== 'ok') {
+ console.log(` Fix: ${check.fixCommand}`);
+ }
+ }
+
+ if (preflight.firewallWarning) {
+ console.log('');
+ console.log(warn('Windows Firewall may block OAuth callback'));
+ console.log(' If auth hangs, run as Administrator:');
+ console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`);
+ }
+
+ if (!preflight.ready) {
+ console.log('');
+ console.log(fail('Pre-flight check failed. Resolve issues above and retry.'));
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Prepare OAuth binary and config
+ */
+async function prepareBinary(
+ provider: CLIProxyProvider,
+ verbose: boolean
+): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> {
+ showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
+
+ try {
+ const binaryPath = await ensureCLIProxyBinary(verbose);
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(1, 4, 'ok', 'CLIProxy binary ready');
+
+ const tokenDir = getProviderTokenDir(provider);
+ fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
+
+ const configPath = generateConfig(provider);
+ if (verbose) {
+ console.error(`[auth] Config generated: ${configPath}`);
+ }
+
+ return { binaryPath, tokenDir, configPath };
+ } catch (error) {
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
+ console.error(fail((error as Error).message));
+ throw error;
+ }
+}
+
+/**
+ * Trigger OAuth flow for provider
+ * Auto-detects headless environment and uses --no-browser flag accordingly
+ * Shows real-time step-by-step progress for better user feedback
+ */
+export async function triggerOAuth(
+ provider: CLIProxyProvider,
+ options: OAuthOptions = {}
+): Promise {
+ const oauthConfig = getOAuthConfig(provider);
+ const { verbose = false, add = false, nickname, fromUI = false } = options;
+ const callbackPort = OAUTH_PORTS[provider];
+ const isCLI = !fromUI;
+ const headless = options.headless ?? isHeadlessEnvironment();
+
+ // Check for existing accounts
+ const existingAccounts = getProviderAccounts(provider);
+ if (existingAccounts.length > 0 && !add) {
+ console.log('');
+ console.log(
+ info(
+ `${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
+ )
+ );
+
+ if (!(await promptAddAccount())) {
+ console.log(info('Cancelled'));
+ return null;
+ }
+ }
+
+ // Pre-flight checks
+ if (!(await runPreflightChecks(provider, oauthConfig))) {
+ return null;
+ }
+
+ console.log('');
+
+ // Prepare binary
+ const prepared = await prepareBinary(provider, verbose);
+ if (!prepared) return null;
+
+ const { binaryPath, tokenDir, configPath } = prepared;
+
+ // Free callback port if needed
+ const localCallbackPort = OAUTH_CALLBACK_PORTS[provider];
+ if (localCallbackPort) {
+ const killed = killProcessOnPort(localCallbackPort, verbose);
+ if (killed && verbose) {
+ console.error(`[auth] Freed port ${localCallbackPort} for OAuth callback`);
+ }
+ }
+
+ // Build args
+ const args = ['--config', configPath, oauthConfig.authFlag];
+ if (headless) {
+ args.push('--no-browser');
+ }
+
+ // Show callback server step
+ showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`);
+
+ // Show headless instructions
+ if (headless) {
+ console.log('');
+ console.log(warn('PORT FORWARDING REQUIRED'));
+ console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
+ console.log(' Run this on your LOCAL machine:');
+ console.log(
+ ` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} @`, 'command')}`
+ );
+ console.log('');
+ }
+
+ // Execute OAuth process
+ return executeOAuthProcess({
+ provider,
+ binaryPath,
+ args,
+ tokenDir,
+ oauthConfig,
+ callbackPort,
+ headless,
+ verbose,
+ isCLI,
+ nickname,
+ });
+}
+
+/**
+ * Ensure provider is authenticated
+ * Triggers OAuth flow if not authenticated
+ */
+export async function ensureAuth(
+ provider: CLIProxyProvider,
+ options: { verbose?: boolean; headless?: boolean; account?: string } = {}
+): Promise {
+ if (isAuthenticated(provider)) {
+ if (options.verbose) {
+ console.error(`[auth] ${provider} already authenticated`);
+ }
+ const defaultAccount = getDefaultAccount(provider);
+ if (defaultAccount) {
+ touchAccount(provider, options.account || defaultAccount.id);
+ }
+ return true;
+ }
+
+ const oauthConfig = getOAuthConfig(provider);
+ console.log(info(`${oauthConfig.displayName} authentication required`));
+
+ const account = await triggerOAuth(provider, options);
+ return account !== null;
+}
diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts
new file mode 100644
index 00000000..94b70d1e
--- /dev/null
+++ b/src/cliproxy/auth/oauth-process.ts
@@ -0,0 +1,290 @@
+/**
+ * OAuth Process Execution for CLIProxyAPI
+ *
+ * Handles the spawning and monitoring of CLIProxy OAuth process.
+ * Separated from oauth-handler.ts for modularity.
+ */
+
+import { spawn, ChildProcess } from 'child_process';
+import { ok, fail, info } from '../../utils/ui';
+import { CLIProxyProvider } from '../types';
+import { AccountInfo } from '../account-manager';
+import {
+ parseProjectList,
+ parseDefaultProject,
+ isProjectSelectionPrompt,
+ isProjectList,
+ generateSessionId,
+ requestProjectSelection,
+ type GCloudProject,
+ type ProjectSelectionPrompt,
+} from '../project-selection-handler';
+import { ProviderOAuthConfig } from './auth-types';
+import { getTimeoutTroubleshooting, showStep } from './environment-detector';
+import { isAuthenticated, registerAccountFromToken } from './token-manager';
+
+/** Options for OAuth process execution */
+export interface OAuthProcessOptions {
+ provider: CLIProxyProvider;
+ binaryPath: string;
+ args: string[];
+ tokenDir: string;
+ oauthConfig: ProviderOAuthConfig;
+ callbackPort: number | null;
+ headless: boolean;
+ verbose: boolean;
+ isCLI: boolean;
+ nickname?: string;
+}
+
+/** Internal state for OAuth process */
+interface ProcessState {
+ stderrData: string;
+ urlDisplayed: boolean;
+ browserOpened: boolean;
+ projectPromptHandled: boolean;
+ accumulatedOutput: string;
+ parsedProjects: GCloudProject[];
+ sessionId: string;
+}
+
+/**
+ * Handle project selection prompt
+ */
+async function handleProjectSelection(
+ output: string,
+ state: ProcessState,
+ options: OAuthProcessOptions,
+ authProcess: ChildProcess,
+ log: (msg: string) => void
+): Promise {
+ const defaultProjectId = parseDefaultProject(output) || '';
+
+ if (state.parsedProjects.length > 0 && !options.isCLI) {
+ log(`Requesting project selection from UI (session: ${state.sessionId})`);
+
+ const prompt: ProjectSelectionPrompt = {
+ sessionId: state.sessionId,
+ provider: options.provider,
+ projects: state.parsedProjects,
+ defaultProjectId,
+ supportsAll: output.includes('ALL'),
+ };
+
+ try {
+ const selectedId = await requestProjectSelection(prompt);
+ const response = selectedId || '';
+ log(`User selected: ${response || '(default)'}`);
+ authProcess.stdin?.write(response + '\n');
+ } catch {
+ log('Project selection failed, using default');
+ authProcess.stdin?.write('\n');
+ }
+ } else {
+ log('CLI mode or no projects, auto-selecting default');
+ authProcess.stdin?.write('\n');
+ }
+}
+
+/**
+ * Handle stdout data from OAuth process
+ */
+async function handleStdout(
+ output: string,
+ state: ProcessState,
+ options: OAuthProcessOptions,
+ authProcess: ChildProcess,
+ log: (msg: string) => void
+): Promise {
+ log(`stdout: ${output.trim()}`);
+ state.accumulatedOutput += output;
+
+ // Parse project list when available
+ if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
+ state.parsedProjects = parseProjectList(state.accumulatedOutput);
+ log(`Parsed ${state.parsedProjects.length} projects`);
+ }
+
+ // Handle project selection prompt
+ if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) {
+ state.projectPromptHandled = true;
+ await handleProjectSelection(output, state, options, authProcess, log);
+ }
+
+ // Detect callback server / browser
+ if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) {
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`);
+ showStep(3, 4, 'progress', 'Opening browser...');
+ state.browserOpened = true;
+ }
+
+ // Display OAuth URLs in headless mode
+ if (options.headless && !state.urlDisplayed) {
+ const urlMatch = output.match(/https?:\/\/[^\s]+/);
+ if (urlMatch) {
+ console.log('');
+ console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
+ console.log(` ${urlMatch[0]}`);
+ console.log('');
+ state.urlDisplayed = true;
+ }
+ }
+}
+
+/** Display OAuth URL from stderr if in headless mode */
+function displayUrlFromStderr(
+ output: string,
+ state: ProcessState,
+ oauthConfig: ProviderOAuthConfig
+): void {
+ const urlMatch = output.match(/https?:\/\/[^\s]+/);
+ if (urlMatch) {
+ console.log('');
+ console.log(info(`${oauthConfig.displayName} OAuth URL:`));
+ console.log(` ${urlMatch[0]}`);
+ console.log('');
+ state.urlDisplayed = true;
+ }
+}
+
+/** Handle token not found after successful process exit */
+function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void {
+ console.log('');
+ console.log(fail('Token not found after authentication'));
+ console.log('');
+ console.log('The browser showed success but callback was not received.');
+
+ if (process.platform === 'win32') {
+ console.log('');
+ console.log('On Windows, this usually means:');
+ console.log(' 1. Windows Firewall blocked the callback');
+ console.log(' 2. Antivirus software blocked the connection');
+ console.log('');
+ console.log('Try running as Administrator:');
+ console.log(
+ ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}`
+ );
+ }
+
+ console.log('');
+ console.log(`Try: ccs ${provider} --auth --verbose`);
+}
+
+/** Handle process exit with error */
+function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void {
+ console.log('');
+ console.log(fail(`CLIProxyAPI auth exited with code ${code}`));
+ if (state.stderrData && !state.urlDisplayed) {
+ console.log(` ${state.stderrData.trim().split('\n')[0]}`);
+ }
+ if (headless && !state.urlDisplayed) {
+ console.log('');
+ console.log(info('No OAuth URL was displayed. Try with --verbose for details.'));
+ }
+}
+
+/**
+ * Execute OAuth process and wait for completion
+ */
+export function executeOAuthProcess(options: OAuthProcessOptions): Promise {
+ const {
+ provider,
+ binaryPath,
+ args,
+ tokenDir,
+ oauthConfig,
+ callbackPort,
+ headless,
+ verbose,
+ nickname,
+ } = options;
+
+ const log = (msg: string) => {
+ if (verbose) console.error(`[auth] ${msg}`);
+ };
+
+ return new Promise((resolve) => {
+ const authProcess = spawn(binaryPath, args, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
+ });
+
+ const state: ProcessState = {
+ stderrData: '',
+ urlDisplayed: false,
+ browserOpened: false,
+ projectPromptHandled: false,
+ accumulatedOutput: '',
+ parsedProjects: [],
+ sessionId: generateSessionId(),
+ };
+
+ const startTime = Date.now();
+
+ authProcess.stdout?.on('data', async (data: Buffer) => {
+ await handleStdout(data.toString(), state, options, authProcess, log);
+ });
+
+ authProcess.stderr?.on('data', (data: Buffer) => {
+ const output = data.toString();
+ state.stderrData += output;
+ log(`stderr: ${output.trim()}`);
+ if (headless && !state.urlDisplayed) {
+ displayUrlFromStderr(output, state, oauthConfig);
+ }
+ });
+
+ // Show waiting message after delay
+ setTimeout(() => {
+ if (!state.browserOpened) {
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
+ showStep(3, 4, 'ok', 'Browser opened');
+ state.browserOpened = true;
+ }
+ showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
+ console.log('');
+ console.log(info('Complete the login in your browser. This page will update automatically.'));
+ if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
+ }, 2000);
+
+ // Timeout handling
+ const timeoutMs = headless ? 300000 : 120000;
+ const timeout = setTimeout(() => {
+ authProcess.kill();
+ console.log('');
+ console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
+ for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) {
+ console.log(line);
+ }
+ resolve(null);
+ }, timeoutMs);
+
+ authProcess.on('exit', (code) => {
+ clearTimeout(timeout);
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+
+ if (code === 0) {
+ if (isAuthenticated(provider)) {
+ console.log('');
+ console.log(ok(`Authentication successful (${elapsed}s)`));
+ resolve(registerAccountFromToken(provider, tokenDir, nickname));
+ } else {
+ handleTokenNotFound(provider, callbackPort);
+ resolve(null);
+ }
+ } else {
+ handleProcessError(code, state, headless);
+ resolve(null);
+ }
+ });
+
+ authProcess.on('error', (error) => {
+ clearTimeout(timeout);
+ console.log('');
+ console.log(fail(`Failed to start auth process: ${error.message}`));
+ resolve(null);
+ });
+ });
+}
diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts
new file mode 100644
index 00000000..c1e4fafe
--- /dev/null
+++ b/src/cliproxy/auth/token-manager.ts
@@ -0,0 +1,260 @@
+/**
+ * Token Manager for CLIProxyAPI
+ *
+ * Handles OAuth token storage, retrieval, and validation.
+ * Tokens are stored in ~/.ccs/cliproxy/auth/ directory.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { CLIProxyProvider } from '../types';
+import { getProviderAuthDir } from '../config-generator';
+import { getProviderAccounts, getDefaultAccount } from '../account-manager';
+import {
+ AuthStatus,
+ PROVIDER_AUTH_PREFIXES,
+ PROVIDER_TYPE_VALUES,
+ getOAuthConfig,
+} from './auth-types';
+
+/**
+ * Get token directory for provider
+ */
+export function getProviderTokenDir(provider: CLIProxyProvider): string {
+ return getProviderAuthDir(provider);
+}
+
+/**
+ * Check if a JSON file contains a token for the given provider
+ * Reads the file and checks the "type" field
+ */
+export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const data = JSON.parse(content);
+ const typeValue = (data.type || '').toLowerCase();
+ const validTypes = PROVIDER_TYPE_VALUES[provider] || [];
+ return validTypes.includes(typeValue);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if provider has valid authentication
+ * CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
+ * Detection strategy:
+ * 1. First check by filename prefix (fast path)
+ * 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix)
+ */
+export function isAuthenticated(provider: CLIProxyProvider): boolean {
+ const tokenDir = getProviderTokenDir(provider);
+
+ if (!fs.existsSync(tokenDir)) {
+ return false;
+ }
+
+ const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
+
+ try {
+ const files = fs.readdirSync(tokenDir);
+ const jsonFiles = files.filter(
+ (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
+ );
+
+ // Strategy 1: Check by filename prefix (fast path for antigravity, codex)
+ const prefixMatch = jsonFiles.some((f) => {
+ const lowerFile = f.toLowerCase();
+ return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
+ });
+ if (prefixMatch) return true;
+
+ // Strategy 2: Check JSON content for "type" field (needed for Gemini)
+ for (const f of jsonFiles) {
+ const filePath = path.join(tokenDir, f);
+ if (isTokenFileForProvider(filePath, provider)) {
+ return true;
+ }
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get detailed auth status for provider
+ * Uses same detection strategy as isAuthenticated: prefix first, then content
+ */
+export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
+ const tokenDir = getProviderTokenDir(provider);
+ let tokenFiles: string[] = [];
+ let lastAuth: Date | undefined;
+
+ const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
+
+ if (fs.existsSync(tokenDir)) {
+ const files = fs.readdirSync(tokenDir);
+ const jsonFiles = files.filter(
+ (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials'
+ );
+
+ // Check each file: by prefix OR by content
+ tokenFiles = jsonFiles.filter((f) => {
+ const lowerFile = f.toLowerCase();
+ // Strategy 1: prefix match
+ if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) {
+ return true;
+ }
+ // Strategy 2: content match (for Gemini tokens without prefix)
+ const filePath = path.join(tokenDir, f);
+ return isTokenFileForProvider(filePath, provider);
+ });
+
+ // Get most recent modification time
+ for (const file of tokenFiles) {
+ const filePath = path.join(tokenDir, file);
+ try {
+ const stats = fs.statSync(filePath);
+ if (!lastAuth || stats.mtime > lastAuth) {
+ lastAuth = stats.mtime;
+ }
+ } catch {
+ // Skip if can't stat file
+ }
+ }
+ }
+
+ // Get registered accounts for multi-account support
+ const accounts = getProviderAccounts(provider);
+ const defaultAccount = getDefaultAccount(provider);
+
+ return {
+ provider,
+ authenticated: tokenFiles.length > 0,
+ tokenDir,
+ tokenFiles,
+ lastAuth,
+ accounts,
+ defaultAccount: defaultAccount?.id,
+ };
+}
+
+/**
+ * Get auth status for all providers
+ */
+export function getAllAuthStatus(): AuthStatus[] {
+ const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
+ return providers.map(getAuthStatus);
+}
+
+/**
+ * Clear authentication for provider
+ * Only removes files belonging to the specified provider (by prefix or content)
+ * Does NOT remove the shared auth directory or other providers' files
+ */
+export function clearAuth(provider: CLIProxyProvider): boolean {
+ const tokenDir = getProviderTokenDir(provider);
+
+ if (!fs.existsSync(tokenDir)) {
+ return false;
+ }
+
+ const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
+ const files = fs.readdirSync(tokenDir);
+ let removedCount = 0;
+
+ // Only remove files that belong to this provider
+ for (const file of files) {
+ const filePath = path.join(tokenDir, file);
+ const lowerFile = file.toLowerCase();
+
+ // Check by prefix first (fast path)
+ const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
+
+ // If no prefix match, check by content (for Gemini tokens without prefix)
+ const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider);
+
+ if (matchesByPrefix || matchesByContent) {
+ try {
+ fs.unlinkSync(filePath);
+ removedCount++;
+ } catch {
+ // Failed to remove - skip
+ }
+ }
+ }
+
+ // DO NOT remove the shared auth directory - other providers may still have tokens
+ return removedCount > 0;
+}
+
+/**
+ * Register account from newly created token file
+ * Scans auth directory for new token and extracts email
+ * @param provider - The CLIProxy provider
+ * @param tokenDir - Directory containing token files
+ * @param nickname - Optional nickname (uses auto-generated from email if not provided)
+ */
+export function registerAccountFromToken(
+ provider: CLIProxyProvider,
+ tokenDir: string,
+ nickname?: string
+): import('../account-manager').AccountInfo | null {
+ const { registerAccount, generateNickname } = require('../account-manager');
+ try {
+ const files = fs.readdirSync(tokenDir);
+ const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
+
+ let newestFile: string | null = null;
+ let newestMtime = 0;
+
+ for (const file of jsonFiles) {
+ const filePath = path.join(tokenDir, file);
+ if (!isTokenFileForProvider(filePath, provider)) continue;
+
+ const stats = fs.statSync(filePath);
+ if (stats.mtimeMs > newestMtime) {
+ newestMtime = stats.mtimeMs;
+ newestFile = file;
+ }
+ }
+
+ if (!newestFile) {
+ return null;
+ }
+
+ const tokenPath = path.join(tokenDir, newestFile);
+ const content = fs.readFileSync(tokenPath, 'utf-8');
+ const data = JSON.parse(content);
+ const email = data.email || undefined;
+
+ return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Display auth status for all providers
+ */
+export function displayAuthStatus(): void {
+ console.log('CLIProxy Authentication Status:');
+ console.log('');
+
+ const statuses = getAllAuthStatus();
+
+ for (const status of statuses) {
+ const oauthConfig = getOAuthConfig(status.provider);
+ const icon = status.authenticated ? '[OK]' : '[!]';
+ const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
+ const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
+
+ console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
+ }
+
+ console.log('');
+ console.log('To authenticate: ccs --auth');
+ console.log('To logout: ccs --logout');
+}
diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts
index fa037060..70adfec1 100644
--- a/src/cliproxy/binary-manager.ts
+++ b/src/cliproxy/binary-manager.ts
@@ -1,66 +1,29 @@
/**
* Binary Manager for CLIProxyAPI
*
- * Download-on-demand binary manager:
- * - Downloads platform-specific binary from GitHub releases
- * - Verifies SHA256 checksum
- * - Extracts and caches binary locally
- * - Supports retry logic with exponential backoff
- * - Auto-checks for updates on startup (fetches latest from GitHub API)
- *
+ * Facade pattern wrapper for modular binary management.
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as https from 'https';
-import * as http from 'http';
-import * as crypto from 'crypto';
-import * as zlib from 'zlib';
-import { ProgressIndicator } from '../utils/progress-indicator';
-import { ok, info } from '../utils/ui';
-import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
-import { isCliproxyRunning } from './stats-fetcher';
+import { info } from '../utils/ui';
+import { getBinDir } from './config-generator';
+import { BinaryInfo, BinaryManagerConfig } from './types';
+import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
import {
- BinaryInfo,
- BinaryManagerConfig,
- ChecksumResult,
- DownloadResult,
- ProgressCallback,
-} from './types';
-import {
- detectPlatform,
- getDownloadUrl,
- getChecksumsUrl,
- getExecutableName,
- getArchiveBinaryName,
- CLIPROXY_FALLBACK_VERSION,
-} from './platform-detector';
-
-/** Cache duration for version check (1 hour in milliseconds) */
-const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
-
-/** Version pin file name - stores user's explicit version choice */
-const VERSION_PIN_FILE = '.version-pin';
-
-/** GitHub API URL for latest release */
-const GITHUB_API_LATEST_RELEASE =
- 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest';
-
-/** Version cache file structure */
-interface VersionCache {
- latestVersion: string;
- checkedAt: number;
-}
-
-/** Update check result */
-interface UpdateCheckResult {
- hasUpdate: boolean;
- currentVersion: string;
- latestVersion: string;
- fromCache: boolean;
- checkedAt: number; // Unix timestamp of last check
-}
+ UpdateCheckResult,
+ checkForUpdates,
+ deleteBinary,
+ getBinaryPath,
+ isBinaryInstalled,
+ getBinaryInfo,
+ getPinnedVersion,
+ savePinnedVersion,
+ clearPinnedVersion,
+ isVersionPinned,
+ getVersionPinPath,
+ readInstalledVersion,
+ ensureBinary,
+} from './binary';
/** Default configuration */
const DEFAULT_CONFIG: BinaryManagerConfig = {
@@ -77,925 +40,85 @@ const DEFAULT_CONFIG: BinaryManagerConfig = {
*/
export class BinaryManager {
private config: BinaryManagerConfig;
- private verbose: boolean;
constructor(config: Partial = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
- this.verbose = this.config.verbose;
}
- /**
- * Ensure binary is available (download if missing, update if outdated)
- * @returns Path to executable binary
- */
+ /** Ensure binary is available (download if missing, update if outdated) */
async ensureBinary(): Promise {
- const binaryPath = this.getBinaryPath();
-
- // Check if binary already exists
- if (fs.existsSync(binaryPath)) {
- this.log(`Binary exists: ${binaryPath}`);
-
- // Skip auto-update if forceVersion is set (user requested specific version)
- if (this.config.forceVersion) {
- this.log(`Force version mode: skipping auto-update`);
- return this.getBinaryPath();
- }
-
- // Check for updates in background (non-blocking for UX)
- try {
- const updateResult = await this.checkForUpdates();
- if (updateResult.hasUpdate) {
- // Check if CLIProxyAPI is currently running - can't update while running
- const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
- if (proxyRunning) {
- // Proxy is running - can't update, just notify user
- console.log(
- info(
- `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
- )
- );
- console.log(info('Run "ccs cliproxy stop" then restart to apply update'));
- this.log('Skipping update: CLIProxyAPI is currently running');
- } else {
- // Proxy not running - safe to update
- console.log(
- info(
- `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
- )
- );
- console.log(info('Updating CLIProxyAPI...'));
-
- // Delete old binary and download new version
- this.deleteBinary();
- this.config.version = updateResult.latestVersion;
- await this.downloadAndInstall();
- }
- }
- } catch (error) {
- // Silent fail - don't block startup if update check fails
- const err = error as Error;
- this.log(`Update check failed (non-blocking): ${err.message}`);
- }
-
- return this.getBinaryPath();
- }
-
- // Download, verify, extract
- this.log('Binary not found, downloading...');
-
- // Skip auto-upgrade to latest if forceVersion is set
- if (!this.config.forceVersion) {
- // Check latest version before first download
- try {
- const latestVersion = await this.fetchLatestVersion();
- if (latestVersion && this.isNewerVersion(latestVersion, this.config.version)) {
- this.log(`Using latest version: ${latestVersion} (instead of ${this.config.version})`);
- this.config.version = latestVersion;
- }
- } catch {
- // Use pinned version if API fails
- this.log(`Using pinned version: ${this.config.version}`);
- }
- } else {
- this.log(`Force version mode: using specified version ${this.config.version}`);
- }
-
- await this.downloadAndInstall();
-
- return binaryPath;
+ return ensureBinary(this.config);
}
- /**
- * Check for updates by comparing installed version with latest release
- * Uses cache to avoid hitting GitHub API on every run
- */
+ /** Check for updates by comparing installed version with latest release */
async checkForUpdates(): Promise {
- const currentVersion = this.getInstalledVersion();
-
- // Try cache first
- const cache = this.getVersionCache();
- if (cache) {
- this.log(`Using cached version: ${cache.latestVersion}`);
- return {
- hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion),
- currentVersion,
- latestVersion: cache.latestVersion,
- fromCache: true,
- checkedAt: cache.checkedAt,
- };
- }
-
- // Fetch from GitHub API
- const latestVersion = await this.fetchLatestVersion();
- const now = Date.now();
- this.cacheLatestVersion(latestVersion);
-
- return {
- hasUpdate: this.isNewerVersion(latestVersion, currentVersion),
- currentVersion,
- latestVersion,
- fromCache: false,
- checkedAt: now,
- };
+ return checkForUpdates(this.config.binPath, this.config.version, this.config.verbose);
}
- /**
- * Fetch latest version from GitHub API
- */
- private async fetchLatestVersion(): Promise {
- const response = await this.fetchJson(GITHUB_API_LATEST_RELEASE);
-
- // Extract version from tag_name (format: "v6.5.27" or "6.5.27")
- const tagName = response.tag_name as string;
- if (!tagName) {
- throw new Error('No tag_name in GitHub API response');
- }
-
- return tagName.replace(/^v/, '');
- }
-
- /**
- * Fetch JSON from URL (for GitHub API)
- */
- private fetchJson(url: string): Promise> {
- return new Promise((resolve, reject) => {
- const options = {
- headers: {
- 'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0',
- Accept: 'application/vnd.github.v3+json',
- },
- };
-
- const handleResponse = (res: http.IncomingMessage) => {
- if (res.statusCode === 301 || res.statusCode === 302) {
- const redirectUrl = res.headers.location;
- if (!redirectUrl) {
- reject(new Error('Redirect without location header'));
- return;
- }
- this.fetchJson(redirectUrl).then(resolve).catch(reject);
- return;
- }
-
- if (res.statusCode !== 200) {
- reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
- return;
- }
-
- let data = '';
- res.on('data', (chunk) => (data += chunk));
- res.on('end', () => {
- try {
- resolve(JSON.parse(data));
- } catch {
- reject(new Error('Invalid JSON from GitHub API'));
- }
- });
- res.on('error', reject);
- };
-
- const req = https.get(url, options, handleResponse);
- req.on('error', reject);
- req.setTimeout(10000, () => {
- req.destroy();
- reject(new Error('GitHub API timeout (10s)'));
- });
- });
- }
-
- /**
- * Get installed version from version file or fallback to pinned
- */
- private getInstalledVersion(): string {
- const versionFile = path.join(this.config.binPath, '.version');
- if (fs.existsSync(versionFile)) {
- try {
- return fs.readFileSync(versionFile, 'utf8').trim();
- } catch {
- return this.config.version;
- }
- }
- return this.config.version;
- }
-
- /**
- * Save installed version to file
- */
- private saveInstalledVersion(version: string): void {
- const versionFile = path.join(this.config.binPath, '.version');
- try {
- fs.writeFileSync(versionFile, version, 'utf8');
- } catch {
- // Silent fail - not critical
- }
- }
-
- /**
- * Get version cache data if still valid
- */
- private getVersionCache(): VersionCache | null {
- const cachePath = this.getVersionCachePath();
- if (!fs.existsSync(cachePath)) {
- return null;
- }
-
- try {
- const content = fs.readFileSync(cachePath, 'utf8');
- const cache: VersionCache = JSON.parse(content);
-
- // Check if cache is still valid
- if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
- return cache;
- }
-
- // Cache expired
- return null;
- } catch {
- return null;
- }
- }
-
- /**
- * Cache latest version for future checks
- */
- private cacheLatestVersion(version: string): void {
- const cachePath = this.getVersionCachePath();
- const cache: VersionCache = {
- latestVersion: version,
- checkedAt: Date.now(),
- };
-
- try {
- fs.mkdirSync(path.dirname(cachePath), { recursive: true });
- fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
- } catch {
- // Silent fail - caching is optional
- }
- }
-
- /**
- * Get path to version cache file
- */
- private getVersionCachePath(): string {
- return path.join(getCliproxyDir(), '.version-cache.json');
- }
-
- /**
- * Compare semver versions (true if latest > current)
- */
- private isNewerVersion(latest: string, current: string): boolean {
- const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0);
- const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0);
-
- // Pad arrays to same length
- while (latestParts.length < 3) latestParts.push(0);
- while (currentParts.length < 3) currentParts.push(0);
-
- for (let i = 0; i < 3; i++) {
- if (latestParts[i] > currentParts[i]) return true;
- if (latestParts[i] < currentParts[i]) return false;
- }
-
- return false; // Equal versions
- }
-
- /**
- * Get full path to binary executable
- */
+ /** Get full path to binary executable */
getBinaryPath(): string {
- const execName = getExecutableName();
- return path.join(this.config.binPath, execName);
+ return getBinaryPath(this.config.binPath);
}
- /**
- * Check if binary exists
- */
+ /** Check if binary exists */
isBinaryInstalled(): boolean {
- return fs.existsSync(this.getBinaryPath());
+ return isBinaryInstalled(this.config.binPath);
}
- /**
- * Get binary info if installed
- */
+ /** Get binary info if installed */
async getBinaryInfo(): Promise {
- const binaryPath = this.getBinaryPath();
- if (!fs.existsSync(binaryPath)) {
- return null;
- }
-
- const platform = detectPlatform();
- const checksum = await this.computeChecksum(binaryPath);
-
- return {
- path: binaryPath,
- version: this.config.version,
- platform,
- checksum,
- };
+ return getBinaryInfo(this.config.binPath, this.config.version);
}
- /**
- * Download and install binary
- */
- private async downloadAndInstall(): Promise {
- const platform = detectPlatform(this.config.version);
- const downloadUrl = getDownloadUrl(this.config.version);
- const checksumsUrl = getChecksumsUrl(this.config.version);
-
- // Ensure bin directory exists
- fs.mkdirSync(this.config.binPath, { recursive: true });
-
- // Download archive
- const archivePath = path.join(this.config.binPath, `cliproxy-archive.${platform.extension}`);
-
- // Use single spinner and update text as we progress (avoids UI jumping)
- const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${this.config.version}`);
- spinner.start();
-
- try {
- // Download with retry
- const result = await this.downloadWithRetry(downloadUrl, archivePath);
- if (!result.success) {
- spinner.fail('Download failed');
- throw new Error(result.error || 'Download failed after retries');
- }
-
- // Verify checksum (update spinner text instead of creating new one)
- spinner.update('Verifying checksum');
-
- const checksumResult = await this.verifyChecksum(
- archivePath,
- platform.binaryName,
- checksumsUrl
- );
-
- if (!checksumResult.valid) {
- spinner.fail('Checksum mismatch');
- fs.unlinkSync(archivePath);
- throw new Error(
- `Checksum mismatch for ${platform.binaryName}\n` +
- `Expected: ${checksumResult.expected}\n` +
- `Actual: ${checksumResult.actual}\n\n` +
- `Manual download: ${downloadUrl}`
- );
- }
-
- // Extract archive (update spinner text)
- spinner.update('Extracting binary');
-
- await this.extractArchive(archivePath, platform.extension);
-
- spinner.succeed('CLIProxyAPI ready');
-
- // Cleanup archive
- fs.unlinkSync(archivePath);
-
- // Make executable (Unix only)
- const binaryPath = this.getBinaryPath();
- if (platform.os !== 'windows' && fs.existsSync(binaryPath)) {
- fs.chmodSync(binaryPath, 0o755);
- this.log(`Set executable permissions: ${binaryPath}`);
- }
-
- // Save installed version for future update checks
- this.saveInstalledVersion(this.config.version);
-
- console.log(ok(`CLIProxyAPI v${this.config.version} installed successfully`));
- } catch (error) {
- spinner.fail('Installation failed');
- throw error;
- }
- }
-
- /**
- * Download file with retry logic and exponential backoff
- */
- private async downloadWithRetry(url: string, destPath: string): Promise {
- let lastError = '';
- let retries = 0;
-
- while (retries < this.config.maxRetries) {
- try {
- await this.downloadFile(url, destPath);
- return { success: true, filePath: destPath, retries };
- } catch (error) {
- const err = error as Error;
- lastError = err.message;
- retries++;
-
- if (retries < this.config.maxRetries) {
- // Exponential backoff: 1s, 2s, 4s
- const delay = Math.pow(2, retries - 1) * 1000;
- this.log(`Retry ${retries}/${this.config.maxRetries} after ${delay}ms: ${lastError}`);
- await this.sleep(delay);
- }
- }
- }
-
- return {
- success: false,
- error: `Download failed after ${retries} attempts: ${lastError}`,
- retries,
- };
- }
-
- /**
- * Download file from URL with progress tracking
- */
- private downloadFile(
- url: string,
- destPath: string,
- onProgress?: ProgressCallback
- ): Promise {
- return new Promise((resolve, reject) => {
- const handleResponse = (res: http.IncomingMessage) => {
- // Handle redirects (GitHub releases use 302)
- if (res.statusCode === 301 || res.statusCode === 302) {
- const redirectUrl = res.headers.location;
- if (!redirectUrl) {
- reject(new Error('Redirect without location header'));
- return;
- }
- this.log(`Following redirect: ${redirectUrl}`);
- this.downloadFile(redirectUrl, destPath, onProgress).then(resolve).catch(reject);
- return;
- }
-
- if (res.statusCode !== 200) {
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
- return;
- }
-
- const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
- let downloadedBytes = 0;
-
- const fileStream = fs.createWriteStream(destPath);
-
- res.on('data', (chunk: Buffer) => {
- downloadedBytes += chunk.length;
- if (onProgress && totalBytes > 0) {
- onProgress({
- total: totalBytes,
- downloaded: downloadedBytes,
- percentage: Math.round((downloadedBytes / totalBytes) * 100),
- });
- }
- });
-
- res.pipe(fileStream);
-
- fileStream.on('finish', () => {
- fileStream.close();
- resolve();
- });
-
- fileStream.on('error', (err) => {
- fs.unlink(destPath, () => {}); // Cleanup partial file
- reject(err);
- });
-
- res.on('error', (err) => {
- fs.unlink(destPath, () => {});
- reject(err);
- });
- };
-
- const protocol = url.startsWith('https') ? https : http;
- const req = protocol.get(url, handleResponse);
-
- req.on('error', reject);
- req.setTimeout(60000, () => {
- req.destroy();
- reject(new Error('Download timeout (60s)'));
- });
- });
- }
-
- /**
- * Verify file checksum against checksums.txt
- */
- private async verifyChecksum(
- filePath: string,
- binaryName: string,
- checksumsUrl: string
- ): Promise {
- // Download checksums.txt
- const checksumsContent = await this.fetchText(checksumsUrl);
-
- // Parse expected checksum
- const expectedHash = this.parseChecksum(checksumsContent, binaryName);
- if (!expectedHash) {
- throw new Error(`Checksum not found for ${binaryName} in checksums.txt`);
- }
-
- // Compute actual checksum
- const actualHash = await this.computeChecksum(filePath);
-
- return {
- valid: actualHash === expectedHash,
- expected: expectedHash,
- actual: actualHash,
- };
- }
-
- /**
- * Parse checksum from checksums.txt content
- */
- private parseChecksum(content: string, binaryName: string): string | null {
- const lines = content.split('\n');
- for (const line of lines) {
- // Format: "hash filename" or "hash filename"
- const parts = line.trim().split(/\s+/);
- if (parts.length >= 2 && parts[1] === binaryName) {
- return parts[0].toLowerCase();
- }
- }
- return null;
- }
-
- /**
- * Compute SHA256 checksum of file
- */
- private computeChecksum(filePath: string): Promise {
- return new Promise((resolve, reject) => {
- const hash = crypto.createHash('sha256');
- const stream = fs.createReadStream(filePath);
-
- stream.on('data', (data) => hash.update(data));
- stream.on('end', () => resolve(hash.digest('hex')));
- stream.on('error', reject);
- });
- }
-
- /**
- * Fetch text content from URL
- */
- private fetchText(url: string): Promise {
- return new Promise((resolve, reject) => {
- const handleResponse = (res: http.IncomingMessage) => {
- // Handle redirects
- if (res.statusCode === 301 || res.statusCode === 302) {
- const redirectUrl = res.headers.location;
- if (!redirectUrl) {
- reject(new Error('Redirect without location header'));
- return;
- }
- this.fetchText(redirectUrl).then(resolve).catch(reject);
- return;
- }
-
- if (res.statusCode !== 200) {
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
- return;
- }
-
- let data = '';
- res.on('data', (chunk) => (data += chunk));
- res.on('end', () => resolve(data));
- res.on('error', reject);
- };
-
- const protocol = url.startsWith('https') ? https : http;
- const req = protocol.get(url, handleResponse);
- req.on('error', reject);
- req.setTimeout(30000, () => {
- req.destroy();
- reject(new Error('Request timeout (30s)'));
- });
- });
- }
-
- /**
- * Extract archive (tar.gz or zip)
- */
- private async extractArchive(archivePath: string, extension: 'tar.gz' | 'zip'): Promise {
- if (extension === 'tar.gz') {
- await this.extractTarGz(archivePath);
- } else {
- await this.extractZip(archivePath);
- }
- }
-
- /**
- * Extract tar.gz archive using Node.js built-in modules
- */
- private extractTarGz(archivePath: string): Promise {
- return new Promise((resolve, reject) => {
- const destDir = this.config.binPath;
- const execName = getExecutableName();
- const archiveBinaryName = getArchiveBinaryName();
-
- // Read and decompress
- const gunzip = zlib.createGunzip();
- const input = fs.createReadStream(archivePath);
-
- let headerBuffer = Buffer.alloc(0);
- let currentFile: { name: string; size: number } | null = null;
- let bytesRead = 0;
- let fileBuffer = Buffer.alloc(0);
-
- const processData = (data: Buffer) => {
- headerBuffer = Buffer.concat([headerBuffer, data]);
-
- while (headerBuffer.length >= 512) {
- if (!currentFile) {
- // Parse tar header
- const header = headerBuffer.subarray(0, 512);
- headerBuffer = headerBuffer.subarray(512);
-
- // Check for empty header (end of archive)
- if (header.every((b) => b === 0)) {
- return;
- }
-
- // Extract filename (bytes 0-99)
- let name = '';
- for (let i = 0; i < 100 && header[i] !== 0; i++) {
- name += String.fromCharCode(header[i]);
- }
-
- // Extract size (bytes 124-135, octal)
- let sizeStr = '';
- for (let i = 124; i < 136 && header[i] !== 0; i++) {
- sizeStr += String.fromCharCode(header[i]);
- }
- const size = parseInt(sizeStr.trim(), 8) || 0;
-
- if (name && size > 0) {
- // Extract just the filename (handle directories)
- const baseName = path.basename(name);
- if (
- baseName === execName ||
- baseName === archiveBinaryName ||
- baseName === 'cli-proxy-api'
- ) {
- currentFile = { name: baseName, size };
- fileBuffer = Buffer.alloc(0);
- bytesRead = 0;
- } else {
- // Skip this file's data
- const paddedSize = Math.ceil(size / 512) * 512;
- if (headerBuffer.length >= paddedSize) {
- headerBuffer = headerBuffer.subarray(paddedSize);
- } else {
- // Need to skip data in chunks
- currentFile = { name: '', size: paddedSize };
- bytesRead = 0;
- }
- }
- }
- } else {
- // Read file data
- const remaining = currentFile.size - bytesRead;
- const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length));
- headerBuffer = headerBuffer.subarray(chunk.length);
-
- if (currentFile.name) {
- fileBuffer = Buffer.concat([fileBuffer, chunk]);
- }
- bytesRead += chunk.length;
-
- if (bytesRead >= currentFile.size) {
- // File complete
- if (currentFile.name) {
- const destPath = path.join(destDir, execName);
- fs.writeFileSync(destPath, fileBuffer);
- this.log(`Extracted: ${currentFile.name} -> ${destPath}`);
- }
-
- // Skip padding to 512-byte boundary
- const paddedSize = Math.ceil(currentFile.size / 512) * 512;
- const padding = paddedSize - currentFile.size;
- if (headerBuffer.length >= padding) {
- headerBuffer = headerBuffer.subarray(padding);
- }
-
- currentFile = null;
- fileBuffer = Buffer.alloc(0);
- }
- }
- }
- };
-
- input.pipe(gunzip);
- gunzip.on('data', processData);
- gunzip.on('end', resolve);
- gunzip.on('error', reject);
- input.on('error', reject);
- });
- }
-
- /**
- * Extract zip archive using Node.js (simple implementation)
- */
- private extractZip(archivePath: string): Promise {
- return new Promise((resolve, reject) => {
- const destDir = this.config.binPath;
- const execName = getExecutableName();
- const archiveBinaryName = getArchiveBinaryName();
- const buffer = fs.readFileSync(archivePath);
-
- // Find End of Central Directory record (EOCD)
- let eocdOffset = buffer.length - 22;
- while (eocdOffset >= 0) {
- if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) {
- break;
- }
- eocdOffset--;
- }
-
- if (eocdOffset < 0) {
- reject(new Error('Invalid ZIP file: EOCD not found'));
- return;
- }
-
- const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16);
- let offset = centralDirOffset;
-
- // Parse central directory
- while (offset < eocdOffset) {
- const sig = buffer.readUInt32LE(offset);
- if (sig !== 0x02014b50) break;
-
- const compressionMethod = buffer.readUInt16LE(offset + 10);
- const compressedSize = buffer.readUInt32LE(offset + 20);
- const uncompressedSize = buffer.readUInt32LE(offset + 24);
- const fileNameLength = buffer.readUInt16LE(offset + 28);
- const extraFieldLength = buffer.readUInt16LE(offset + 30);
- const commentLength = buffer.readUInt16LE(offset + 32);
- const localHeaderOffset = buffer.readUInt32LE(offset + 42);
-
- const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength);
- const baseName = path.basename(fileName);
-
- // Check if this is the executable we want
- if (
- baseName === execName ||
- baseName === archiveBinaryName ||
- baseName === 'cli-proxy-api.exe'
- ) {
- // Read from local file header
- const localOffset = localHeaderOffset;
- const localSig = buffer.readUInt32LE(localOffset);
-
- if (localSig !== 0x04034b50) {
- reject(new Error('Invalid local file header'));
- return;
- }
-
- const localFileNameLength = buffer.readUInt16LE(localOffset + 26);
- const localExtraLength = buffer.readUInt16LE(localOffset + 28);
- const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength;
-
- let fileData: Buffer;
- if (compressionMethod === 0) {
- // Stored (no compression)
- fileData = buffer.subarray(dataOffset, dataOffset + compressedSize);
- } else if (compressionMethod === 8) {
- // Deflate
- const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
- fileData = zlib.inflateRawSync(compressed);
- } else {
- reject(new Error(`Unsupported compression method: ${compressionMethod}`));
- return;
- }
-
- if (fileData.length !== uncompressedSize) {
- reject(new Error('Decompression size mismatch'));
- return;
- }
-
- const destPath = path.join(destDir, execName);
- fs.writeFileSync(destPath, fileData);
- this.log(`Extracted: ${fileName} -> ${destPath}`);
- resolve();
- return;
- }
-
- offset += 46 + fileNameLength + extraFieldLength + commentLength;
- }
-
- reject(new Error(`Executable not found in archive: ${execName}`));
- });
- }
-
- /**
- * Delete binary (for cleanup or reinstall)
- */
+ /** Delete binary (for cleanup or reinstall) */
deleteBinary(): void {
- const binaryPath = this.getBinaryPath();
- if (fs.existsSync(binaryPath)) {
- fs.unlinkSync(binaryPath);
- this.log(`Deleted: ${binaryPath}`);
- }
- }
-
- /**
- * Sleep helper
- */
- private sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
-
- /**
- * Log message if verbose
- */
- private log(message: string): void {
- if (this.verbose) {
- console.error(`[cliproxy] ${message}`);
- }
+ deleteBinary(this.config.binPath, this.config.verbose);
}
}
-/**
- * Convenience function to ensure binary is available
- * Respects version pin if set by user via 'ccs cliproxy --install '
- * @returns Path to CLIProxyAPI executable
- */
+/** Convenience function respecting version pin */
export async function ensureCLIProxyBinary(verbose = false): Promise {
const pinnedVersion = getPinnedVersion();
-
if (pinnedVersion) {
- // Version is pinned - use forceVersion to prevent auto-update
- if (verbose) {
- console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`);
- }
- const manager = new BinaryManager({
+ if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`);
+ return new BinaryManager({
version: pinnedVersion,
verbose,
forceVersion: true,
- });
- return manager.ensureBinary();
+ }).ensureBinary();
}
-
- // No pin - allow auto-update to latest
- const manager = new BinaryManager({ verbose });
- return manager.ensureBinary();
+ return new BinaryManager({ verbose }).ensureBinary();
}
-/**
- * Check if CLIProxyAPI binary is installed
- */
+/** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(): boolean {
- const manager = new BinaryManager();
- return manager.isBinaryInstalled();
+ return new BinaryManager().isBinaryInstalled();
}
-/**
- * Get CLIProxyAPI binary path (may not exist)
- */
+/** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(): string {
- const manager = new BinaryManager();
- return manager.getBinaryPath();
+ return new BinaryManager().getBinaryPath();
}
-/**
- * Get installed CLIProxyAPI version from .version file
- * Returns the fallback version if not installed or version file missing
- */
+/** Get installed CLIProxyAPI version from .version file */
export function getInstalledCliproxyVersion(): string {
- const versionFile = path.join(getBinDir(), '.version');
- if (fs.existsSync(versionFile)) {
- try {
- return fs.readFileSync(versionFile, 'utf8').trim();
- } catch {
- return CLIPROXY_FALLBACK_VERSION;
- }
- }
- return CLIPROXY_FALLBACK_VERSION;
+ return readInstalledVersion(getBinDir(), CLIPROXY_FALLBACK_VERSION);
}
-/**
- * Install a specific version of CLIProxyAPI
- * Deletes existing binary and downloads the specified version
- *
- * @param version Version to install (e.g., "6.5.53")
- * @param verbose Enable verbose logging
- */
+/** Install a specific version of CLIProxyAPI */
export async function installCliproxyVersion(version: string, verbose = false): Promise {
- // Use forceVersion to prevent auto-upgrade to latest
const manager = new BinaryManager({ version, verbose, forceVersion: true });
-
- // Delete existing binary if present
if (manager.isBinaryInstalled()) {
- const currentVersion = getInstalledCliproxyVersion();
- if (verbose) {
- console.log(info(`Removing existing CLIProxyAPI v${currentVersion}`));
- }
+ if (verbose)
+ console.log(info(`Removing existing CLIProxyAPI v${getInstalledCliproxyVersion()}`));
manager.deleteBinary();
}
-
- // Install specified version (forceVersion prevents auto-upgrade)
await manager.ensureBinary();
}
-/**
- * Fetch the latest CLIProxyAPI version from GitHub API
- * @returns Latest version string (e.g., "6.5.53")
- */
+/** Fetch the latest CLIProxyAPI version from GitHub API */
export async function fetchLatestCliproxyVersion(): Promise {
- const manager = new BinaryManager();
- const result = await manager.checkForUpdates();
+ const result = await new BinaryManager().checkForUpdates();
return result.latestVersion;
}
@@ -1005,76 +128,21 @@ export interface CliproxyUpdateCheckResult {
currentVersion: string;
latestVersion: string;
fromCache: boolean;
- checkedAt: number; // Unix timestamp of last check
+ checkedAt: number;
}
-/**
- * Check for CLIProxyAPI binary updates
- * @returns Update check result with version info
- */
+/** Check for CLIProxyAPI binary updates */
export async function checkCliproxyUpdate(): Promise {
- const manager = new BinaryManager();
- return manager.checkForUpdates();
+ return new BinaryManager().checkForUpdates();
}
-/**
- * Get path to version pin file
- * @returns Absolute path to .version-pin file
- */
-export function getVersionPinPath(): string {
- return path.join(getBinDir(), VERSION_PIN_FILE);
-}
-
-/**
- * Get pinned version if one exists
- * @returns Pinned version string, or null if not pinned
- */
-export function getPinnedVersion(): string | null {
- const pinPath = getVersionPinPath();
- if (!fs.existsSync(pinPath)) {
- return null;
- }
- try {
- return fs.readFileSync(pinPath, 'utf8').trim();
- } catch {
- return null;
- }
-}
-
-/**
- * Save pinned version to persist user's explicit choice
- * @param version Version to pin (e.g., "6.5.50")
- */
-export function savePinnedVersion(version: string): void {
- const pinPath = getVersionPinPath();
- try {
- fs.mkdirSync(path.dirname(pinPath), { recursive: true });
- fs.writeFileSync(pinPath, version, 'utf8');
- } catch {
- // Silent fail - not critical but log if verbose
- }
-}
-
-/**
- * Clear pinned version (unpin)
- */
-export function clearPinnedVersion(): void {
- const pinPath = getVersionPinPath();
- if (fs.existsSync(pinPath)) {
- try {
- fs.unlinkSync(pinPath);
- } catch {
- // Silent fail
- }
- }
-}
-
-/**
- * Check if a version is currently pinned
- * @returns true if a version is pinned
- */
-export function isVersionPinned(): boolean {
- return getPinnedVersion() !== null;
-}
+// Re-export version pin functions
+export {
+ getVersionPinPath,
+ getPinnedVersion,
+ savePinnedVersion,
+ clearPinnedVersion,
+ isVersionPinned,
+};
export default BinaryManager;
diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts
new file mode 100644
index 00000000..8ef38ac0
--- /dev/null
+++ b/src/cliproxy/binary/downloader.ts
@@ -0,0 +1,230 @@
+/**
+ * Binary Downloader
+ * Handles downloading files with retry logic, progress tracking, and redirect following.
+ */
+
+import * as fs from 'fs';
+import * as https from 'https';
+import * as http from 'http';
+import { DownloadResult, ProgressCallback } from '../types';
+
+/** Default configuration for downloader */
+export interface DownloaderConfig {
+ /** Maximum retry attempts */
+ maxRetries: number;
+ /** Enable verbose logging */
+ verbose: boolean;
+}
+
+const DEFAULT_CONFIG: DownloaderConfig = {
+ maxRetries: 3,
+ verbose: false,
+};
+
+/**
+ * Download file from URL with progress tracking
+ */
+export function downloadFile(
+ url: string,
+ destPath: string,
+ onProgress?: ProgressCallback,
+ verbose = false
+): Promise {
+ return new Promise((resolve, reject) => {
+ const handleResponse = (res: http.IncomingMessage) => {
+ // Handle redirects (GitHub releases use 302)
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ const redirectUrl = res.headers.location;
+ if (!redirectUrl) {
+ reject(new Error('Redirect without location header'));
+ return;
+ }
+ if (verbose) {
+ console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
+ }
+ downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject);
+ return;
+ }
+
+ if (res.statusCode !== 200) {
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
+ return;
+ }
+
+ const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
+ let downloadedBytes = 0;
+
+ const fileStream = fs.createWriteStream(destPath);
+
+ res.on('data', (chunk: Buffer) => {
+ downloadedBytes += chunk.length;
+ if (onProgress && totalBytes > 0) {
+ onProgress({
+ total: totalBytes,
+ downloaded: downloadedBytes,
+ percentage: Math.round((downloadedBytes / totalBytes) * 100),
+ });
+ }
+ });
+
+ res.pipe(fileStream);
+
+ fileStream.on('finish', () => {
+ fileStream.close();
+ resolve();
+ });
+
+ fileStream.on('error', (err) => {
+ fs.unlink(destPath, () => {}); // Cleanup partial file
+ reject(err);
+ });
+
+ res.on('error', (err) => {
+ fs.unlink(destPath, () => {});
+ reject(err);
+ });
+ };
+
+ const protocol = url.startsWith('https') ? https : http;
+ const req = protocol.get(url, handleResponse);
+
+ req.on('error', reject);
+ req.setTimeout(60000, () => {
+ req.destroy();
+ reject(new Error('Download timeout (60s)'));
+ });
+ });
+}
+
+/**
+ * Download file with retry logic and exponential backoff
+ */
+export async function downloadWithRetry(
+ url: string,
+ destPath: string,
+ config: Partial = {}
+): Promise {
+ const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config };
+ let lastError = '';
+ let retries = 0;
+
+ while (retries < maxRetries) {
+ try {
+ await downloadFile(url, destPath, undefined, verbose);
+ return { success: true, filePath: destPath, retries };
+ } catch (error) {
+ const err = error as Error;
+ lastError = err.message;
+ retries++;
+
+ if (retries < maxRetries) {
+ // Exponential backoff: 1s, 2s, 4s
+ const delay = Math.pow(2, retries - 1) * 1000;
+ if (verbose) {
+ console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`);
+ }
+ await sleep(delay);
+ }
+ }
+ }
+
+ return {
+ success: false,
+ error: `Download failed after ${retries} attempts: ${lastError}`,
+ retries,
+ };
+}
+
+/**
+ * Fetch text content from URL
+ */
+export function fetchText(url: string, verbose = false): Promise {
+ return new Promise((resolve, reject) => {
+ const handleResponse = (res: http.IncomingMessage) => {
+ // Handle redirects
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ const redirectUrl = res.headers.location;
+ if (!redirectUrl) {
+ reject(new Error('Redirect without location header'));
+ return;
+ }
+ fetchText(redirectUrl, verbose).then(resolve).catch(reject);
+ return;
+ }
+
+ if (res.statusCode !== 200) {
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
+ return;
+ }
+
+ let data = '';
+ res.on('data', (chunk) => (data += chunk));
+ res.on('end', () => resolve(data));
+ res.on('error', reject);
+ };
+
+ const protocol = url.startsWith('https') ? https : http;
+ const req = protocol.get(url, handleResponse);
+ req.on('error', reject);
+ req.setTimeout(30000, () => {
+ req.destroy();
+ reject(new Error('Request timeout (30s)'));
+ });
+ });
+}
+
+/**
+ * Fetch JSON from URL (for GitHub API)
+ */
+export function fetchJson(url: string, verbose = false): Promise> {
+ return new Promise((resolve, reject) => {
+ const options = {
+ headers: {
+ 'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0',
+ Accept: 'application/vnd.github.v3+json',
+ },
+ };
+
+ const handleResponse = (res: http.IncomingMessage) => {
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ const redirectUrl = res.headers.location;
+ if (!redirectUrl) {
+ reject(new Error('Redirect without location header'));
+ return;
+ }
+ fetchJson(redirectUrl, verbose).then(resolve).catch(reject);
+ return;
+ }
+
+ if (res.statusCode !== 200) {
+ reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
+ return;
+ }
+
+ let data = '';
+ res.on('data', (chunk) => (data += chunk));
+ res.on('end', () => {
+ try {
+ resolve(JSON.parse(data));
+ } catch {
+ reject(new Error('Invalid JSON from GitHub API'));
+ }
+ });
+ res.on('error', reject);
+ };
+
+ const req = https.get(url, options, handleResponse);
+ req.on('error', reject);
+ req.setTimeout(10000, () => {
+ req.destroy();
+ reject(new Error('GitHub API timeout (10s)'));
+ });
+ });
+}
+
+/**
+ * Sleep helper
+ */
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/cliproxy/binary/extractor.ts b/src/cliproxy/binary/extractor.ts
new file mode 100644
index 00000000..09d496b1
--- /dev/null
+++ b/src/cliproxy/binary/extractor.ts
@@ -0,0 +1,28 @@
+/**
+ * Archive Extractor
+ * Facade for tar.gz and zip archive extraction.
+ */
+
+import { ArchiveExtension } from '../types';
+import { extractTarGz } from './tar-extractor';
+import { extractZip } from './zip-extractor';
+
+// Re-export for convenience
+export { extractTarGz } from './tar-extractor';
+export { extractZip } from './zip-extractor';
+
+/**
+ * Extract archive based on extension
+ */
+export async function extractArchive(
+ archivePath: string,
+ destDir: string,
+ extension: ArchiveExtension,
+ verbose = false
+): Promise {
+ if (extension === 'tar.gz') {
+ await extractTarGz(archivePath, destDir, verbose);
+ } else {
+ await extractZip(archivePath, destDir, verbose);
+ }
+}
diff --git a/src/cliproxy/binary/index.ts b/src/cliproxy/binary/index.ts
new file mode 100644
index 00000000..e0336258
--- /dev/null
+++ b/src/cliproxy/binary/index.ts
@@ -0,0 +1,44 @@
+/**
+ * Binary Module - Barrel Export
+ * Re-exports all binary management functionality.
+ */
+
+// Types
+export type { VersionCache, UpdateCheckResult } from './types';
+export { VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE, GITHUB_API_LATEST_RELEASE } from './types';
+
+// Downloader
+export { downloadFile, downloadWithRetry, fetchText, fetchJson } from './downloader';
+
+// Verifier
+export { computeChecksum, parseChecksum, verifyChecksum } from './verifier';
+
+// Version Cache
+export {
+ getVersionCachePath,
+ getVersionPinPath,
+ readVersionCache,
+ writeVersionCache,
+ readInstalledVersion,
+ writeInstalledVersion,
+ getPinnedVersion,
+ savePinnedVersion,
+ clearPinnedVersion,
+ isVersionPinned,
+} from './version-cache';
+
+// Version Checker
+export { isNewerVersion, fetchLatestVersion, checkForUpdates } from './version-checker';
+
+// Extractor
+export { extractTarGz, extractZip, extractArchive } from './extractor';
+
+// Updater
+export {
+ downloadAndInstall,
+ deleteBinary,
+ getBinaryPath,
+ isBinaryInstalled,
+ getBinaryInfo,
+ ensureBinary,
+} from './updater';
diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts
new file mode 100644
index 00000000..e640a043
--- /dev/null
+++ b/src/cliproxy/binary/installer.ts
@@ -0,0 +1,119 @@
+/**
+ * Binary Installer
+ * Handles downloading, verifying, and extracting binary.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { BinaryManagerConfig } from '../types';
+import {
+ detectPlatform,
+ getDownloadUrl,
+ getChecksumsUrl,
+ getExecutableName,
+} from '../platform-detector';
+import { downloadWithRetry } from './downloader';
+import { verifyChecksum, computeChecksum } from './verifier';
+import { extractArchive } from './extractor';
+import { writeInstalledVersion } from './version-cache';
+import { ProgressIndicator } from '../../utils/progress-indicator';
+import { ok } from '../../utils/ui';
+
+/**
+ * Download and install the binary
+ */
+export async function downloadAndInstall(
+ config: BinaryManagerConfig,
+ verbose = false
+): Promise {
+ const platform = detectPlatform(config.version);
+ const downloadUrl = getDownloadUrl(config.version);
+ const checksumsUrl = getChecksumsUrl(config.version);
+
+ fs.mkdirSync(config.binPath, { recursive: true });
+ const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`);
+ const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${config.version}`);
+ spinner.start();
+
+ try {
+ const result = await downloadWithRetry(downloadUrl, archivePath, {
+ maxRetries: config.maxRetries,
+ verbose,
+ });
+ if (!result.success) {
+ spinner.fail('Download failed');
+ throw new Error(result.error || 'Download failed after retries');
+ }
+
+ spinner.update('Verifying checksum');
+ const checksumResult = await verifyChecksum(
+ archivePath,
+ platform.binaryName,
+ checksumsUrl,
+ verbose
+ );
+
+ if (!checksumResult.valid) {
+ spinner.fail('Checksum mismatch');
+ fs.unlinkSync(archivePath);
+ throw new Error(
+ `Checksum mismatch for ${platform.binaryName}\nExpected: ${checksumResult.expected}\n` +
+ `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}`
+ );
+ }
+
+ spinner.update('Extracting binary');
+ await extractArchive(archivePath, config.binPath, platform.extension, verbose);
+ spinner.succeed('CLIProxyAPI ready');
+ fs.unlinkSync(archivePath);
+
+ const binaryPath = path.join(config.binPath, getExecutableName());
+ if (platform.os !== 'windows' && fs.existsSync(binaryPath)) {
+ fs.chmodSync(binaryPath, 0o755);
+ if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`);
+ }
+
+ writeInstalledVersion(config.binPath, config.version);
+ console.log(ok(`CLIProxyAPI v${config.version} installed successfully`));
+ } catch (error) {
+ spinner.fail('Installation failed');
+ throw error;
+ }
+}
+
+/** Delete binary (for cleanup or reinstall) */
+export function deleteBinary(binPath: string, verbose = false): void {
+ const binaryPath = path.join(binPath, getExecutableName());
+ if (fs.existsSync(binaryPath)) {
+ fs.unlinkSync(binaryPath);
+ if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`);
+ }
+}
+
+/** Get binary path */
+export function getBinaryPath(binPath: string): string {
+ return path.join(binPath, getExecutableName());
+}
+
+/** Check if binary exists */
+export function isBinaryInstalled(binPath: string): boolean {
+ return fs.existsSync(getBinaryPath(binPath));
+}
+
+/** Get binary info if installed */
+export async function getBinaryInfo(
+ binPath: string,
+ version: string
+): Promise<{
+ path: string;
+ version: string;
+ platform: ReturnType;
+ checksum: string;
+} | null> {
+ const binaryPath = getBinaryPath(binPath);
+ if (!fs.existsSync(binaryPath)) return null;
+
+ const platform = detectPlatform();
+ const checksum = await computeChecksum(binaryPath);
+ return { path: binaryPath, version, platform, checksum };
+}
diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts
new file mode 100644
index 00000000..aed9ef3c
--- /dev/null
+++ b/src/cliproxy/binary/lifecycle.ts
@@ -0,0 +1,86 @@
+/**
+ * Binary Lifecycle Manager
+ * Handles ensuring binary availability and auto-updates.
+ */
+
+import * as fs from 'fs';
+import { BinaryManagerConfig } from '../types';
+import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker';
+import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
+import { info } from '../../utils/ui';
+import { isCliproxyRunning } from '../stats-fetcher';
+import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
+
+/** Log helper */
+function log(message: string, verbose: boolean): void {
+ if (verbose) console.error(`[cliproxy] ${message}`);
+}
+
+/** Handle auto-update when binary exists */
+async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise {
+ const updateResult = await checkForUpdates(config.binPath, config.version, verbose);
+ if (!updateResult.hasUpdate) return;
+
+ const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
+ const updateMsg = `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`;
+
+ if (proxyRunning) {
+ console.log(info(updateMsg));
+ console.log(info('Run "ccs cliproxy stop" then restart to apply update'));
+ log('Skipping update: CLIProxyAPI is currently running', verbose);
+ } else {
+ console.log(info(updateMsg));
+ console.log(info('Updating CLIProxyAPI...'));
+ deleteBinary(config.binPath, verbose);
+ config.version = updateResult.latestVersion;
+ await downloadAndInstall(config, verbose);
+ }
+}
+
+/**
+ * Ensure binary is available (download if missing, update if outdated)
+ * @returns Path to executable binary
+ */
+export async function ensureBinary(config: BinaryManagerConfig): Promise {
+ const verbose = config.verbose;
+ const binaryPath = getBinaryPath(config.binPath);
+
+ // Binary exists - check for updates unless forceVersion
+ if (fs.existsSync(binaryPath)) {
+ log(`Binary exists: ${binaryPath}`, verbose);
+
+ if (config.forceVersion) {
+ log('Force version mode: skipping auto-update', verbose);
+ return binaryPath;
+ }
+
+ try {
+ await handleAutoUpdate(config, verbose);
+ } catch (error) {
+ const err = error as Error;
+ log(`Update check failed (non-blocking): ${err.message}`, verbose);
+ }
+
+ return binaryPath;
+ }
+
+ // Binary missing - download
+ log('Binary not found, downloading...', verbose);
+
+ if (!config.forceVersion) {
+ try {
+ const latestVersion = await fetchLatestVersion(verbose);
+ if (latestVersion && isNewerVersion(latestVersion, config.version)) {
+ log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose);
+ config.version = latestVersion;
+ }
+ } catch {
+ log(`Using pinned version: ${config.version}`, verbose);
+ }
+ } else {
+ log(`Force version mode: using specified version ${config.version}`, verbose);
+ }
+
+ await downloadAndInstall(config, verbose);
+ return binaryPath;
+}
diff --git a/src/cliproxy/binary/tar-extractor.ts b/src/cliproxy/binary/tar-extractor.ts
new file mode 100644
index 00000000..305df2e4
--- /dev/null
+++ b/src/cliproxy/binary/tar-extractor.ts
@@ -0,0 +1,98 @@
+/**
+ * Tar.gz Archive Extractor
+ * Handles extraction of tar.gz archives using Node.js built-in modules.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as zlib from 'zlib';
+import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
+
+/**
+ * Extract tar.gz archive using Node.js built-in modules
+ */
+export function extractTarGz(archivePath: string, destDir: string, verbose = false): Promise {
+ return new Promise((resolve, reject) => {
+ const execName = getExecutableName();
+ const archiveBinaryName = getArchiveBinaryName();
+ const gunzip = zlib.createGunzip();
+ const input = fs.createReadStream(archivePath);
+
+ let headerBuffer = Buffer.alloc(0);
+ let currentFile: { name: string; size: number } | null = null;
+ let bytesRead = 0;
+ let fileBuffer = Buffer.alloc(0);
+
+ const processData = (data: Buffer) => {
+ headerBuffer = Buffer.concat([headerBuffer, data]);
+
+ while (headerBuffer.length >= 512) {
+ if (!currentFile) {
+ const header = headerBuffer.subarray(0, 512);
+ headerBuffer = headerBuffer.subarray(512);
+
+ if (header.every((b) => b === 0)) return;
+
+ let name = '';
+ for (let i = 0; i < 100 && header[i] !== 0; i++) {
+ name += String.fromCharCode(header[i]);
+ }
+
+ let sizeStr = '';
+ for (let i = 124; i < 136 && header[i] !== 0; i++) {
+ sizeStr += String.fromCharCode(header[i]);
+ }
+ const size = parseInt(sizeStr.trim(), 8) || 0;
+
+ if (name && size > 0) {
+ const baseName = path.basename(name);
+ if (
+ baseName === execName ||
+ baseName === archiveBinaryName ||
+ baseName === 'cli-proxy-api'
+ ) {
+ currentFile = { name: baseName, size };
+ fileBuffer = Buffer.alloc(0);
+ bytesRead = 0;
+ } else {
+ const paddedSize = Math.ceil(size / 512) * 512;
+ if (headerBuffer.length >= paddedSize) {
+ headerBuffer = headerBuffer.subarray(paddedSize);
+ } else {
+ currentFile = { name: '', size: paddedSize };
+ bytesRead = 0;
+ }
+ }
+ }
+ } else {
+ const remaining = currentFile.size - bytesRead;
+ const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length));
+ headerBuffer = headerBuffer.subarray(chunk.length);
+
+ if (currentFile.name) fileBuffer = Buffer.concat([fileBuffer, chunk]);
+ bytesRead += chunk.length;
+
+ if (bytesRead >= currentFile.size) {
+ if (currentFile.name) {
+ const destPath = path.join(destDir, execName);
+ fs.writeFileSync(destPath, fileBuffer);
+ if (verbose)
+ console.error(`[cliproxy] Extracted: ${currentFile.name} -> ${destPath}`);
+ }
+ const paddedSize = Math.ceil(currentFile.size / 512) * 512;
+ const padding = paddedSize - currentFile.size;
+ if (headerBuffer.length >= padding) headerBuffer = headerBuffer.subarray(padding);
+ currentFile = null;
+ fileBuffer = Buffer.alloc(0);
+ }
+ }
+ }
+ };
+
+ input.pipe(gunzip);
+ gunzip.on('data', processData);
+ gunzip.on('end', resolve);
+ gunzip.on('error', reject);
+ input.on('error', reject);
+ });
+}
diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts
new file mode 100644
index 00000000..461100f0
--- /dev/null
+++ b/src/cliproxy/binary/types.ts
@@ -0,0 +1,29 @@
+/**
+ * Binary Module Type Definitions
+ * Types specific to binary management operations.
+ */
+
+/** Version cache file structure */
+export interface VersionCache {
+ latestVersion: string;
+ checkedAt: number;
+}
+
+/** Update check result */
+export interface UpdateCheckResult {
+ hasUpdate: boolean;
+ currentVersion: string;
+ latestVersion: string;
+ fromCache: boolean;
+ checkedAt: number; // Unix timestamp of last check
+}
+
+/** Cache duration for version check (1 hour in milliseconds) */
+export const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
+
+/** Version pin file name - stores user's explicit version choice */
+export const VERSION_PIN_FILE = '.version-pin';
+
+/** GitHub API URL for latest release */
+export const GITHUB_API_LATEST_RELEASE =
+ 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest';
diff --git a/src/cliproxy/binary/updater.ts b/src/cliproxy/binary/updater.ts
new file mode 100644
index 00000000..b95c356b
--- /dev/null
+++ b/src/cliproxy/binary/updater.ts
@@ -0,0 +1,16 @@
+/**
+ * Binary Updater
+ * Re-exports installer and lifecycle functionality.
+ */
+
+// Re-export installer functions
+export {
+ downloadAndInstall,
+ deleteBinary,
+ getBinaryPath,
+ isBinaryInstalled,
+ getBinaryInfo,
+} from './installer';
+
+// Re-export lifecycle functions
+export { ensureBinary } from './lifecycle';
diff --git a/src/cliproxy/binary/verifier.ts b/src/cliproxy/binary/verifier.ts
new file mode 100644
index 00000000..c0b4bd47
--- /dev/null
+++ b/src/cliproxy/binary/verifier.ts
@@ -0,0 +1,66 @@
+/**
+ * Binary Verifier
+ * Handles checksum verification for downloaded binaries.
+ */
+
+import * as fs from 'fs';
+import * as crypto from 'crypto';
+import { ChecksumResult } from '../types';
+import { fetchText } from './downloader';
+
+/**
+ * Compute SHA256 checksum of file
+ */
+export function computeChecksum(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256');
+ const stream = fs.createReadStream(filePath);
+
+ stream.on('data', (data) => hash.update(data));
+ stream.on('end', () => resolve(hash.digest('hex')));
+ stream.on('error', reject);
+ });
+}
+
+/**
+ * Parse checksum from checksums.txt content
+ */
+export function parseChecksum(content: string, binaryName: string): string | null {
+ const lines = content.split('\n');
+ for (const line of lines) {
+ // Format: "hash filename" or "hash filename"
+ const parts = line.trim().split(/\s+/);
+ if (parts.length >= 2 && parts[1] === binaryName) {
+ return parts[0].toLowerCase();
+ }
+ }
+ return null;
+}
+
+/**
+ * Verify file checksum against checksums.txt
+ */
+export async function verifyChecksum(
+ filePath: string,
+ binaryName: string,
+ checksumsUrl: string,
+ verbose = false
+): Promise {
+ // Download checksums.txt
+ const checksumsContent = await fetchText(checksumsUrl, verbose);
+
+ // Parse expected checksum
+ const expectedHash = parseChecksum(checksumsContent, binaryName);
+ if (!expectedHash) {
+ throw new Error(`Checksum not found for ${binaryName} in checksums.txt`);
+ }
+
+ // Compute actual checksum
+ const actualHash = await computeChecksum(filePath);
+
+ return {
+ valid: actualHash === expectedHash,
+ expected: expectedHash,
+ actual: actualHash,
+ };
+}
diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts
new file mode 100644
index 00000000..d77cf7cf
--- /dev/null
+++ b/src/cliproxy/binary/version-cache.ts
@@ -0,0 +1,142 @@
+/**
+ * Version Cache Manager
+ * Handles reading/writing version cache to avoid excessive GitHub API calls.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { getCliproxyDir, getBinDir } from '../config-generator';
+import { VersionCache, VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE } from './types';
+
+/**
+ * Get path to version cache file
+ */
+export function getVersionCachePath(): string {
+ return path.join(getCliproxyDir(), '.version-cache.json');
+}
+
+/**
+ * Get path to version pin file
+ */
+export function getVersionPinPath(): string {
+ return path.join(getBinDir(), VERSION_PIN_FILE);
+}
+
+/**
+ * Read version cache if still valid
+ */
+export function readVersionCache(): VersionCache | null {
+ const cachePath = getVersionCachePath();
+ if (!fs.existsSync(cachePath)) {
+ return null;
+ }
+
+ try {
+ const content = fs.readFileSync(cachePath, 'utf8');
+ const cache: VersionCache = JSON.parse(content);
+
+ // Check if cache is still valid
+ if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
+ return cache;
+ }
+
+ // Cache expired
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Write version to cache
+ */
+export function writeVersionCache(version: string): void {
+ const cachePath = getVersionCachePath();
+ const cache: VersionCache = {
+ latestVersion: version,
+ checkedAt: Date.now(),
+ };
+
+ try {
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
+ fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
+ } catch {
+ // Silent fail - caching is optional
+ }
+}
+
+/**
+ * Read installed version from .version file
+ */
+export function readInstalledVersion(binPath: string, fallbackVersion: string): string {
+ const versionFile = path.join(binPath, '.version');
+ if (fs.existsSync(versionFile)) {
+ try {
+ return fs.readFileSync(versionFile, 'utf8').trim();
+ } catch {
+ return fallbackVersion;
+ }
+ }
+ return fallbackVersion;
+}
+
+/**
+ * Write installed version to .version file
+ */
+export function writeInstalledVersion(binPath: string, version: string): void {
+ const versionFile = path.join(binPath, '.version');
+ try {
+ fs.writeFileSync(versionFile, version, 'utf8');
+ } catch {
+ // Silent fail - not critical
+ }
+}
+
+/**
+ * Get pinned version if one exists
+ */
+export function getPinnedVersion(): string | null {
+ const pinPath = getVersionPinPath();
+ if (!fs.existsSync(pinPath)) {
+ return null;
+ }
+ try {
+ return fs.readFileSync(pinPath, 'utf8').trim();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Save pinned version to persist user's explicit choice
+ */
+export function savePinnedVersion(version: string): void {
+ const pinPath = getVersionPinPath();
+ try {
+ fs.mkdirSync(path.dirname(pinPath), { recursive: true });
+ fs.writeFileSync(pinPath, version, 'utf8');
+ } catch {
+ // Silent fail - not critical
+ }
+}
+
+/**
+ * Clear pinned version (unpin)
+ */
+export function clearPinnedVersion(): void {
+ const pinPath = getVersionPinPath();
+ if (fs.existsSync(pinPath)) {
+ try {
+ fs.unlinkSync(pinPath);
+ } catch {
+ // Silent fail
+ }
+ }
+}
+
+/**
+ * Check if a version is currently pinned
+ */
+export function isVersionPinned(): boolean {
+ return getPinnedVersion() !== null;
+}
diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts
new file mode 100644
index 00000000..c21398e7
--- /dev/null
+++ b/src/cliproxy/binary/version-checker.ts
@@ -0,0 +1,82 @@
+/**
+ * Version Checker
+ * Handles checking GitHub API for latest version and comparing semver.
+ */
+
+import { fetchJson } from './downloader';
+import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache';
+import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types';
+
+/**
+ * Compare semver versions (true if latest > current)
+ */
+export function isNewerVersion(latest: string, current: string): boolean {
+ const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0);
+ const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0);
+
+ // Pad arrays to same length
+ while (latestParts.length < 3) latestParts.push(0);
+ while (currentParts.length < 3) currentParts.push(0);
+
+ for (let i = 0; i < 3; i++) {
+ if (latestParts[i] > currentParts[i]) return true;
+ if (latestParts[i] < currentParts[i]) return false;
+ }
+
+ return false; // Equal versions
+}
+
+/**
+ * Fetch latest version from GitHub API
+ */
+export async function fetchLatestVersion(verbose = false): Promise {
+ const response = await fetchJson(GITHUB_API_LATEST_RELEASE, verbose);
+
+ // Extract version from tag_name (format: "v6.5.27" or "6.5.27")
+ const tagName = response.tag_name as string;
+ if (!tagName) {
+ throw new Error('No tag_name in GitHub API response');
+ }
+
+ return tagName.replace(/^v/, '');
+}
+
+/**
+ * Check for updates by comparing installed version with latest release
+ * Uses cache to avoid hitting GitHub API on every run
+ */
+export async function checkForUpdates(
+ binPath: string,
+ configVersion: string,
+ verbose = false
+): Promise {
+ const currentVersion = readInstalledVersion(binPath, configVersion);
+
+ // Try cache first
+ const cache = readVersionCache();
+ if (cache) {
+ if (verbose) {
+ console.error(`[cliproxy] Using cached version: ${cache.latestVersion}`);
+ }
+ return {
+ hasUpdate: isNewerVersion(cache.latestVersion, currentVersion),
+ currentVersion,
+ latestVersion: cache.latestVersion,
+ fromCache: true,
+ checkedAt: cache.checkedAt,
+ };
+ }
+
+ // Fetch from GitHub API
+ const latestVersion = await fetchLatestVersion(verbose);
+ const now = Date.now();
+ writeVersionCache(latestVersion);
+
+ return {
+ hasUpdate: isNewerVersion(latestVersion, currentVersion),
+ currentVersion,
+ latestVersion,
+ fromCache: false,
+ checkedAt: now,
+ };
+}
diff --git a/src/cliproxy/binary/zip-extractor.ts b/src/cliproxy/binary/zip-extractor.ts
new file mode 100644
index 00000000..5cc078ae
--- /dev/null
+++ b/src/cliproxy/binary/zip-extractor.ts
@@ -0,0 +1,95 @@
+/**
+ * Zip Archive Extractor
+ * Handles extraction of zip archives using Node.js built-in modules.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as zlib from 'zlib';
+import { getExecutableName, getArchiveBinaryName } from '../platform-detector';
+
+/**
+ * Extract zip archive using Node.js (simple implementation)
+ */
+export function extractZip(archivePath: string, destDir: string, verbose = false): Promise {
+ return new Promise((resolve, reject) => {
+ const execName = getExecutableName();
+ const archiveBinaryName = getArchiveBinaryName();
+ const buffer = fs.readFileSync(archivePath);
+
+ // Find End of Central Directory record (EOCD)
+ let eocdOffset = buffer.length - 22;
+ while (eocdOffset >= 0) {
+ if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) break;
+ eocdOffset--;
+ }
+
+ if (eocdOffset < 0) {
+ reject(new Error('Invalid ZIP file: EOCD not found'));
+ return;
+ }
+
+ const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16);
+ let offset = centralDirOffset;
+
+ while (offset < eocdOffset) {
+ const sig = buffer.readUInt32LE(offset);
+ if (sig !== 0x02014b50) break;
+
+ const compressionMethod = buffer.readUInt16LE(offset + 10);
+ const compressedSize = buffer.readUInt32LE(offset + 20);
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
+ const fileNameLength = buffer.readUInt16LE(offset + 28);
+ const extraFieldLength = buffer.readUInt16LE(offset + 30);
+ const commentLength = buffer.readUInt16LE(offset + 32);
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
+
+ const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength);
+ const baseName = path.basename(fileName);
+
+ if (
+ baseName === execName ||
+ baseName === archiveBinaryName ||
+ baseName === 'cli-proxy-api.exe'
+ ) {
+ const localOffset = localHeaderOffset;
+ const localSig = buffer.readUInt32LE(localOffset);
+
+ if (localSig !== 0x04034b50) {
+ reject(new Error('Invalid local file header'));
+ return;
+ }
+
+ const localFileNameLength = buffer.readUInt16LE(localOffset + 26);
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
+ const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength;
+
+ let fileData: Buffer;
+ if (compressionMethod === 0) {
+ fileData = buffer.subarray(dataOffset, dataOffset + compressedSize);
+ } else if (compressionMethod === 8) {
+ const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
+ fileData = zlib.inflateRawSync(compressed);
+ } else {
+ reject(new Error(`Unsupported compression method: ${compressionMethod}`));
+ return;
+ }
+
+ if (fileData.length !== uncompressedSize) {
+ reject(new Error('Decompression size mismatch'));
+ return;
+ }
+
+ const destPath = path.join(destDir, execName);
+ fs.writeFileSync(destPath, fileData);
+ if (verbose) console.error(`[cliproxy] Extracted: ${fileName} -> ${destPath}`);
+ resolve();
+ return;
+ }
+
+ offset += 46 + fileNameLength + extraFieldLength + commentLength;
+ }
+
+ reject(new Error(`Executable not found in archive: ${execName}`));
+ });
+}
diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts
new file mode 100644
index 00000000..c044aca4
--- /dev/null
+++ b/src/cliproxy/services/binary-service.ts
@@ -0,0 +1,176 @@
+/**
+ * CLIProxy Binary Service
+ *
+ * Handles CLIProxyAPI binary version management:
+ * - Get current version status
+ * - Install specific versions
+ * - Install latest version
+ * - Version pinning
+ */
+
+import {
+ getInstalledCliproxyVersion,
+ installCliproxyVersion,
+ fetchLatestCliproxyVersion,
+ isCLIProxyInstalled,
+ getCLIProxyPath,
+ getPinnedVersion,
+ savePinnedVersion,
+ clearPinnedVersion,
+ isVersionPinned,
+} from '../binary-manager';
+import { CLIPROXY_FALLBACK_VERSION } from '../platform-detector';
+
+/** Binary status result */
+export interface BinaryStatusResult {
+ installed: boolean;
+ currentVersion: string | null;
+ pinnedVersion: string | null;
+ binaryPath: string;
+ fallbackVersion: string;
+}
+
+/** Install result */
+export interface InstallResult {
+ success: boolean;
+ version: string;
+ error?: string;
+ wasPinned?: boolean;
+}
+
+/** Latest version check result */
+export interface LatestVersionResult {
+ success: boolean;
+ latestVersion?: string;
+ currentVersion?: string;
+ updateAvailable?: boolean;
+ error?: string;
+}
+
+/**
+ * Get current binary status
+ */
+export function getBinaryStatus(): BinaryStatusResult {
+ return {
+ installed: isCLIProxyInstalled(),
+ currentVersion: getInstalledCliproxyVersion(),
+ pinnedVersion: getPinnedVersion(),
+ binaryPath: getCLIProxyPath(),
+ fallbackVersion: CLIPROXY_FALLBACK_VERSION,
+ };
+}
+
+/**
+ * Check for latest version
+ */
+export async function checkLatestVersion(): Promise {
+ try {
+ const latestVersion = await fetchLatestCliproxyVersion();
+ const currentVersion = getInstalledCliproxyVersion();
+ const updateAvailable = latestVersion !== currentVersion;
+
+ return {
+ success: true,
+ latestVersion,
+ currentVersion: currentVersion || undefined,
+ updateAvailable,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: (error as Error).message,
+ };
+ }
+}
+
+/**
+ * Validate version format
+ */
+export function isValidVersionFormat(version: string): boolean {
+ return /^\d+\.\d+\.\d+$/.test(version);
+}
+
+/**
+ * Install a specific version and pin it
+ */
+export async function installVersion(version: string, verbose = false): Promise {
+ if (!isValidVersionFormat(version)) {
+ return {
+ success: false,
+ version,
+ error: 'Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)',
+ };
+ }
+
+ try {
+ await installCliproxyVersion(version, verbose);
+ savePinnedVersion(version);
+
+ return {
+ success: true,
+ version,
+ wasPinned: true,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ version,
+ error: (error as Error).message,
+ };
+ }
+}
+
+/**
+ * Install latest version and clear any pin
+ */
+export async function installLatest(verbose = false): Promise {
+ try {
+ const latestVersion = await fetchLatestCliproxyVersion();
+ const currentVersion = getInstalledCliproxyVersion();
+ const wasPinned = isVersionPinned();
+
+ if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) {
+ return {
+ success: true,
+ version: latestVersion,
+ error: `Already running latest version: v${latestVersion}`,
+ };
+ }
+
+ await installCliproxyVersion(latestVersion, verbose);
+ clearPinnedVersion();
+
+ return {
+ success: true,
+ version: latestVersion,
+ wasPinned,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ version: '',
+ error: (error as Error).message,
+ };
+ }
+}
+
+/**
+ * Check if a version is pinned
+ */
+export function isPinned(): boolean {
+ return isVersionPinned();
+}
+
+/**
+ * Get pinned version if any
+ */
+export function getPinned(): string | null {
+ return getPinnedVersion();
+}
+
+/**
+ * Clear version pin
+ */
+export function clearPin(): void {
+ clearPinnedVersion();
+}
diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts
new file mode 100644
index 00000000..c4ece9c5
--- /dev/null
+++ b/src/cliproxy/services/index.ts
@@ -0,0 +1,40 @@
+/**
+ * CLIProxy Services
+ * Central export point for cliproxy service layer
+ */
+
+// Variant management
+export {
+ validateProfileName,
+ variantExists,
+ listVariants,
+ createVariant,
+ removeVariant,
+ type VariantConfig,
+ type VariantOperationResult,
+} from './variant-service';
+
+// Proxy lifecycle
+export {
+ getProxyStatus,
+ stopProxy,
+ isProxyRunning,
+ getActiveSessionCount,
+ type ProxyStatusResult,
+ type StopProxyResult,
+} from './proxy-lifecycle-service';
+
+// Binary management
+export {
+ getBinaryStatus,
+ checkLatestVersion,
+ isValidVersionFormat,
+ installVersion,
+ installLatest,
+ isPinned,
+ getPinned,
+ clearPin,
+ type BinaryStatusResult,
+ type InstallResult,
+ type LatestVersionResult,
+} from './binary-service';
diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts
new file mode 100644
index 00000000..7ea4c124
--- /dev/null
+++ b/src/cliproxy/services/proxy-lifecycle-service.ts
@@ -0,0 +1,58 @@
+/**
+ * CLIProxy Proxy Lifecycle Service
+ *
+ * Handles start/stop/status operations for CLIProxy instances.
+ * Delegates to session-tracker for actual process management.
+ */
+
+import {
+ stopProxy as stopProxySession,
+ getProxyStatus as getProxyStatusSession,
+} from '../session-tracker';
+
+/** Proxy status result */
+export interface ProxyStatusResult {
+ running: boolean;
+ port?: number;
+ pid?: number;
+ sessionCount?: number;
+ startedAt?: string;
+}
+
+/** Stop proxy result */
+export interface StopProxyResult {
+ stopped: boolean;
+ pid?: number;
+ sessionCount?: number;
+ error?: string;
+}
+
+/**
+ * Get current proxy status
+ */
+export function getProxyStatus(): ProxyStatusResult {
+ return getProxyStatusSession();
+}
+
+/**
+ * Stop the running CLIProxy instance
+ */
+export async function stopProxy(): Promise {
+ return stopProxySession();
+}
+
+/**
+ * Check if proxy is currently running
+ */
+export function isProxyRunning(): boolean {
+ const status = getProxyStatusSession();
+ return status.running;
+}
+
+/**
+ * Get active session count
+ */
+export function getActiveSessionCount(): number {
+ const status = getProxyStatusSession();
+ return status.sessionCount ?? 0;
+}
diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts
new file mode 100644
index 00000000..a4b09058
--- /dev/null
+++ b/src/cliproxy/services/variant-config-adapter.ts
@@ -0,0 +1,182 @@
+/**
+ * CLIProxy Variant Config Adapters
+ *
+ * Handles reading/writing variant config in both unified and legacy formats.
+ */
+
+import * as fs from 'fs';
+import { getConfigPath, loadConfig } from '../../utils/config-manager';
+import { CLIProxyProvider } from '../types';
+import {
+ loadOrCreateUnifiedConfig,
+ saveUnifiedConfig,
+ isUnifiedMode,
+} from '../../config/unified-config-loader';
+
+/** Variant configuration structure */
+export interface VariantConfig {
+ provider: string;
+ settings?: string;
+ account?: string;
+ model?: string;
+}
+
+/**
+ * Check if variant exists in config
+ */
+export function variantExistsInConfig(name: string): boolean {
+ try {
+ if (isUnifiedMode()) {
+ const config = loadOrCreateUnifiedConfig();
+ return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
+ }
+ const config = loadConfig();
+ return !!(config.cliproxy && name in config.cliproxy);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * List variants from config
+ */
+export function listVariantsFromConfig(): Record {
+ try {
+ if (isUnifiedMode()) {
+ const unifiedConfig = loadOrCreateUnifiedConfig();
+ const variants = unifiedConfig.cliproxy?.variants || {};
+ const result: Record = {};
+ for (const name of Object.keys(variants)) {
+ const v = variants[name];
+ result[name] = { provider: v.provider, settings: v.settings, account: v.account };
+ }
+ return result;
+ }
+
+ const config = loadConfig();
+ const variants = config.cliproxy || {};
+ const result: Record = {};
+ for (const name of Object.keys(variants)) {
+ const v = variants[name] as { provider: string; settings: string; account?: string };
+ result[name] = { provider: v.provider, settings: v.settings, account: v.account };
+ }
+ return result;
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Save variant to unified config
+ */
+export function saveVariantUnified(
+ name: string,
+ provider: CLIProxyProvider,
+ settingsPath: string,
+ account?: string
+): void {
+ const config = loadOrCreateUnifiedConfig();
+
+ if (!config.cliproxy) {
+ config.cliproxy = {
+ oauth_accounts: {},
+ providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'],
+ variants: {},
+ };
+ }
+ if (!config.cliproxy.variants) {
+ config.cliproxy.variants = {};
+ }
+
+ config.cliproxy.variants[name] = {
+ provider,
+ account,
+ settings: settingsPath,
+ };
+
+ saveUnifiedConfig(config);
+}
+
+/**
+ * Save variant to legacy JSON config
+ */
+export function saveVariantLegacy(
+ name: string,
+ provider: string,
+ settingsPath: string,
+ account?: string
+): void {
+ const configPath = getConfigPath();
+
+ let config: { profiles: Record; cliproxy?: Record };
+ try {
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ } catch {
+ config = { profiles: {} };
+ }
+
+ if (!config.cliproxy) {
+ config.cliproxy = {};
+ }
+
+ const variantConfig: { provider: string; settings: string; account?: string } = {
+ provider,
+ settings: settingsPath,
+ };
+ if (account) {
+ variantConfig.account = account;
+ }
+ config.cliproxy[name] = variantConfig;
+
+ const tempPath = configPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
+ fs.renameSync(tempPath, configPath);
+}
+
+/**
+ * Remove variant from unified config
+ */
+export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null {
+ const config = loadOrCreateUnifiedConfig();
+
+ if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
+ return null;
+ }
+
+ const variant = config.cliproxy.variants[name];
+ delete config.cliproxy.variants[name];
+ saveUnifiedConfig(config);
+
+ return { provider: variant.provider, settings: variant.settings };
+}
+
+/**
+ * Remove variant from legacy JSON config
+ */
+export function removeVariantFromLegacyConfig(name: string): VariantConfig | null {
+ const configPath = getConfigPath();
+
+ let config: { profiles: Record; cliproxy?: Record };
+ try {
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ } catch {
+ return null;
+ }
+
+ if (!config.cliproxy || !(name in config.cliproxy)) {
+ return null;
+ }
+
+ const variant = config.cliproxy[name] as { provider: string; settings: string };
+ delete config.cliproxy[name];
+
+ if (Object.keys(config.cliproxy).length === 0) {
+ delete config.cliproxy;
+ }
+
+ const tempPath = configPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
+ fs.renameSync(tempPath, configPath);
+
+ return variant;
+}
diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts
new file mode 100644
index 00000000..8429ce08
--- /dev/null
+++ b/src/cliproxy/services/variant-service.ts
@@ -0,0 +1,139 @@
+/**
+ * CLIProxy Variant Service
+ *
+ * Handles CRUD operations for CLIProxy variant profiles.
+ * Supports both unified config (config.yaml) and legacy JSON format.
+ */
+
+import * as path from 'path';
+import { CLIProxyProfileName } from '../../auth/profile-detector';
+import { CLIProxyProvider } from '../types';
+import { isReservedName } from '../../config/reserved-names';
+import { isUnifiedMode } from '../../config/unified-config-loader';
+import {
+ createSettingsFile,
+ createSettingsFileUnified,
+ deleteSettingsFile,
+ getRelativeSettingsPath,
+} from './variant-settings';
+import {
+ VariantConfig,
+ variantExistsInConfig,
+ listVariantsFromConfig,
+ saveVariantUnified,
+ saveVariantLegacy,
+ removeVariantFromUnifiedConfig,
+ removeVariantFromLegacyConfig,
+} from './variant-config-adapter';
+
+// Re-export VariantConfig from adapter
+export type { VariantConfig } from './variant-config-adapter';
+
+/** Result of variant operations */
+export interface VariantOperationResult {
+ success: boolean;
+ error?: string;
+ variant?: VariantConfig;
+ settingsPath?: string;
+}
+
+/**
+ * Validate CLIProxy profile name
+ */
+export function validateProfileName(name: string): string | null {
+ if (!name) {
+ return 'Profile name is required';
+ }
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
+ return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore';
+ }
+ if (name.length > 32) {
+ return 'Name must be 32 characters or less';
+ }
+ if (isReservedName(name)) {
+ return `'${name}' is a reserved name`;
+ }
+ return null;
+}
+
+/**
+ * Check if CLIProxy variant profile exists
+ */
+export function variantExists(name: string): boolean {
+ return variantExistsInConfig(name);
+}
+
+/**
+ * List all CLIProxy variants
+ */
+export function listVariants(): Record {
+ return listVariantsFromConfig();
+}
+
+/**
+ * Create a new CLIProxy variant
+ */
+export function createVariant(
+ name: string,
+ provider: CLIProxyProfileName,
+ model: string,
+ account?: string
+): VariantOperationResult {
+ try {
+ let settingsPath: string;
+
+ if (isUnifiedMode()) {
+ settingsPath = createSettingsFileUnified(name, provider, model);
+ saveVariantUnified(
+ name,
+ provider as CLIProxyProvider,
+ getRelativeSettingsPath(provider, name),
+ account
+ );
+ } else {
+ settingsPath = createSettingsFile(name, provider, model);
+ saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account);
+ }
+
+ return {
+ success: true,
+ settingsPath,
+ variant: { provider, model, account },
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: (error as Error).message,
+ };
+ }
+}
+
+/**
+ * Remove a CLIProxy variant
+ */
+export function removeVariant(name: string): VariantOperationResult {
+ try {
+ let variant: VariantConfig | null;
+
+ if (isUnifiedMode()) {
+ const unifiedVariant = removeVariantFromUnifiedConfig(name);
+ if (unifiedVariant?.settings) {
+ deleteSettingsFile(unifiedVariant.settings);
+ }
+ variant = unifiedVariant;
+ } else {
+ variant = removeVariantFromLegacyConfig(name);
+ if (variant?.settings) {
+ deleteSettingsFile(variant.settings);
+ }
+ }
+
+ if (!variant) {
+ return { success: false, error: `Variant '${name}' not found` };
+ }
+
+ return { success: true, variant };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+}
diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts
new file mode 100644
index 00000000..b3c4b185
--- /dev/null
+++ b/src/cliproxy/services/variant-settings.ts
@@ -0,0 +1,136 @@
+/**
+ * CLIProxy Variant Settings File Manager
+ *
+ * Handles creation of settings.json files for CLIProxy variants.
+ * These files contain environment variables for Claude CLI integration.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CLIProxyProfileName } from '../../auth/profile-detector';
+import { getCcsDir } from '../../utils/config-manager';
+import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
+import { CLIProxyProvider } from '../types';
+
+/** Environment settings structure */
+interface SettingsEnv {
+ 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;
+}
+
+interface SettingsFile {
+ env: SettingsEnv;
+}
+
+/**
+ * Build settings env object for a variant
+ */
+function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv {
+ const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
+
+ return {
+ ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
+ ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
+ ANTHROPIC_MODEL: model,
+ ANTHROPIC_DEFAULT_OPUS_MODEL: model,
+ ANTHROPIC_DEFAULT_SONNET_MODEL: model,
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
+ };
+}
+
+/**
+ * Ensure directory exists
+ */
+function ensureDir(dir: string): void {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+}
+
+/**
+ * Write settings file atomically
+ */
+function writeSettings(filePath: string, settings: SettingsFile): void {
+ fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
+}
+
+/**
+ * Get settings file path for a variant
+ */
+export function getSettingsFilePath(provider: CLIProxyProfileName, name: string): string {
+ const ccsDir = getCcsDir();
+ return path.join(ccsDir, `${provider}-${name}.settings.json`);
+}
+
+/**
+ * Get settings file name (without path)
+ */
+export function getSettingsFileName(provider: CLIProxyProfileName, name: string): string {
+ return `${provider}-${name}.settings.json`;
+}
+
+/**
+ * Get relative settings path with ~ prefix
+ */
+export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: string): string {
+ return `~/.ccs/${getSettingsFileName(provider, name)}`;
+}
+
+/**
+ * Create settings.json file for CLIProxy variant (legacy mode)
+ */
+export function createSettingsFile(
+ name: string,
+ provider: CLIProxyProfileName,
+ model: string
+): string {
+ const ccsDir = getCcsDir();
+ const settingsPath = getSettingsFilePath(provider, name);
+
+ const settings: SettingsFile = {
+ env: buildSettingsEnv(provider, model),
+ };
+
+ ensureDir(ccsDir);
+ writeSettings(settingsPath, settings);
+
+ return settingsPath;
+}
+
+/**
+ * Create settings.json file for CLIProxy variant (unified mode)
+ */
+export function createSettingsFileUnified(
+ name: string,
+ provider: CLIProxyProfileName,
+ model: string
+): string {
+ const ccsDir = path.join(os.homedir(), '.ccs');
+ const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
+
+ const settings: SettingsFile = {
+ env: buildSettingsEnv(provider, model),
+ };
+
+ ensureDir(ccsDir);
+ writeSettings(settingsPath, settings);
+
+ return settingsPath;
+}
+
+/**
+ * Delete settings file if it exists
+ */
+export function deleteSettingsFile(settingsPath: string): boolean {
+ const resolvedPath = settingsPath.replace(/^~/, os.homedir());
+ if (fs.existsSync(resolvedPath)) {
+ fs.unlinkSync(resolvedPath);
+ return true;
+ }
+ return false;
+}
diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts
index a541fdf5..9aa579ec 100644
--- a/src/commands/api-command.ts
+++ b/src/commands/api-command.ts
@@ -4,14 +4,10 @@
* Manages CCS API profiles for custom API providers.
* Commands: create, list, remove
*
- * Supports dual-mode configuration:
- * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists
- * - Legacy JSON format (config.json, *.settings.json) as fallback
+ * CLI parsing and output formatting only.
+ * Business logic delegated to src/api/services/.
*/
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
import {
initUI,
header,
@@ -26,15 +22,19 @@ import {
infoBox,
} from '../utils/ui';
import { InteractivePrompt } from '../utils/prompt';
-import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager';
-import { isReservedName } from '../config/reserved-names';
import {
- hasUnifiedConfig,
- loadOrCreateUnifiedConfig,
- saveUnifiedConfig,
-} from '../config/unified-config-loader';
-import { deleteAllProfileSecrets } from '../config/secrets-manager';
-import { isUnifiedConfigEnabled } from '../config/feature-flags';
+ validateApiName,
+ validateUrl,
+ getUrlWarning,
+ sanitizeBaseUrl,
+ apiProfileExists,
+ listApiProfiles,
+ createApiProfile,
+ removeApiProfile,
+ getApiProfileNames,
+ isUsingUnifiedConfig,
+ type ModelMapping,
+} from '../api/services';
interface ApiCommandArgs {
name?: string;
@@ -45,9 +45,7 @@ interface ApiCommandArgs {
yes?: boolean;
}
-/**
- * Parse command line arguments for api commands
- */
+/** Parse command line arguments for api commands */
function parseArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {};
@@ -72,228 +70,7 @@ function parseArgs(args: string[]): ApiCommandArgs {
return result;
}
-/**
- * Validate API profile name
- */
-function validateApiName(name: string): string | null {
- if (!name) {
- return 'API name is required';
- }
- if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
- return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore';
- }
- if (name.length > 32) {
- return 'API name must be 32 characters or less';
- }
- // Use centralized reserved names list
- if (isReservedName(name)) {
- return `'${name}' is a reserved name`;
- }
- return null;
-}
-
-/**
- * Validate URL format and warn about common mistakes
- */
-function validateUrl(url: string): string | null {
- if (!url) {
- return 'Base URL is required';
- }
- try {
- new URL(url);
- return null;
- } catch {
- return 'Invalid URL format (must include protocol, e.g., https://)';
- }
-}
-
-/**
- * Check if URL looks like it includes endpoint path (common mistake)
- * Returns warning message if problematic, null if OK
- */
-function getUrlWarning(url: string): string | null {
- const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
- const lowerUrl = url.toLowerCase();
-
- for (const path of problematicPaths) {
- if (lowerUrl.endsWith(path)) {
- return (
- `URL ends with "${path}" - Claude appends this automatically.\n` +
- ` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}`
- );
- }
- }
- return null;
-}
-
-/**
- * Check if unified config mode is active
- */
-function isUnifiedMode(): boolean {
- return hasUnifiedConfig() || isUnifiedConfigEnabled();
-}
-
-/**
- * Check if API profile already exists in config
- */
-function apiExists(name: string): boolean {
- try {
- if (isUnifiedMode()) {
- const config = loadOrCreateUnifiedConfig();
- return name in config.profiles;
- }
- const config = loadConfig();
- return name in config.profiles;
- } catch {
- return false;
- }
-}
-
-/** Model mapping for API profiles */
-interface ModelMapping {
- default: string;
- opus: string;
- sonnet: string;
- haiku: string;
-}
-
-/**
- * Create settings.json file for API profile
- * Includes all 4 model fields for proper Claude CLI integration
- */
-function createSettingsFile(
- name: string,
- baseUrl: string,
- apiKey: string,
- models: ModelMapping
-): string {
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${name}.settings.json`);
-
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: baseUrl,
- ANTHROPIC_AUTH_TOKEN: apiKey,
- ANTHROPIC_MODEL: models.default,
- ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
- ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
- ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
- },
- };
-
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
- return settingsPath;
-}
-
-/**
- * Update config.json with new API profile
- */
-function updateConfig(name: string, _settingsPath: string): void {
- const configPath = getConfigPath();
- const ccsDir = getCcsDir();
-
- // Read existing config or create new
- let config: { profiles: Record; cliproxy?: Record };
- try {
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- } catch {
- config = { profiles: {} };
- }
-
- // Use relative path with ~ for portability
- const relativePath = `~/.ccs/${name}.settings.json`;
- config.profiles[name] = relativePath;
-
- // Ensure directory exists
- if (!fs.existsSync(ccsDir)) {
- fs.mkdirSync(ccsDir, { recursive: true });
- }
-
- // Write config atomically (write to temp, then rename)
- const tempPath = configPath + '.tmp';
- fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tempPath, configPath);
-}
-
-/**
- * Create API profile in unified config
- * Creates *.settings.json file and stores reference in config.yaml
- * (matching Claude's ~/.claude/settings.json pattern)
- */
-function createApiProfileUnified(
- name: string,
- baseUrl: string,
- apiKey: string,
- models: ModelMapping
-): void {
- const ccsDir = path.join(os.homedir(), '.ccs');
- const settingsFile = `${name}.settings.json`;
- const settingsPath = path.join(ccsDir, settingsFile);
-
- // Create settings file with all env vars (matching Claude's pattern)
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: baseUrl,
- ANTHROPIC_AUTH_TOKEN: apiKey,
- ANTHROPIC_MODEL: models.default,
- ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
- ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
- ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
- },
- };
-
- // Ensure directory exists
- if (!fs.existsSync(ccsDir)) {
- fs.mkdirSync(ccsDir, { recursive: true });
- }
-
- // Write settings file
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
-
- // Store reference in config.yaml
- const config = loadOrCreateUnifiedConfig();
- config.profiles[name] = {
- type: 'api',
- settings: `~/.ccs/${settingsFile}`,
- };
- saveUnifiedConfig(config);
-}
-
-/**
- * Remove API profile from unified config
- */
-function removeApiProfileUnified(name: string): void {
- const config = loadOrCreateUnifiedConfig();
- const profile = config.profiles[name];
-
- if (!profile) {
- throw new Error(`API profile not found: ${name}`);
- }
-
- // Delete the settings file if it exists
- if (profile.settings) {
- const settingsPath = profile.settings.replace(/^~/, os.homedir());
- if (fs.existsSync(settingsPath)) {
- fs.unlinkSync(settingsPath);
- }
- }
-
- delete config.profiles[name];
-
- // Clear default if it was the deleted profile
- if (config.default === name) {
- config.default = undefined;
- }
-
- saveUnifiedConfig(config);
-
- // Remove any legacy secrets (backward compat)
- deleteAllProfileSecrets(name);
-}
-
-/**
- * Handle 'ccs api create' command
- */
+/** Handle 'ccs api create' command */
async function handleCreate(args: string[]): Promise {
await initUI();
const parsedArgs = parseArgs(args);
@@ -316,7 +93,7 @@ async function handleCreate(args: string[]): Promise {
}
// Check if exists
- if (apiExists(name) && !parsedArgs.force) {
+ if (apiProfileExists(name) && !parsedArgs.force) {
console.log(fail(`API '${name}' already exists`));
console.log(` Use ${color('--force', 'command')} to overwrite`);
process.exit(1);
@@ -327,9 +104,7 @@ async function handleCreate(args: string[]): Promise {
if (!baseUrl) {
baseUrl = await InteractivePrompt.input(
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
- {
- validate: validateUrl,
- }
+ { validate: validateUrl }
);
} else {
const error = validateUrl(baseUrl);
@@ -348,10 +123,9 @@ async function handleCreate(args: string[]): Promise {
default: false,
});
if (!continueAnyway) {
- // Let user re-enter URL
baseUrl = await InteractivePrompt.input('API Base URL', {
validate: validateUrl,
- default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''),
+ default: sanitizeBaseUrl(baseUrl),
});
}
}
@@ -366,7 +140,7 @@ async function handleCreate(args: string[]): Promise {
}
}
- // Step 4: Model (optional)
+ // Step 4: Model configuration
const defaultModel = 'claude-sonnet-4-5-20250929';
let model = parsedArgs.model;
if (!model && !parsedArgs.yes) {
@@ -377,16 +151,12 @@ async function handleCreate(args: string[]): Promise {
model = model || defaultModel;
// Step 5: Model mapping for Opus/Sonnet/Haiku
- // Auto-show if user entered a custom model, otherwise ask
let opusModel = model;
let sonnetModel = model;
let haikuModel = model;
-
const isCustomModel = model !== defaultModel;
if (!parsedArgs.yes) {
- // If user entered custom model, auto-prompt for model mapping
- // Otherwise, ask if they want to configure it
let wantCustomMapping = isCustomModel;
if (!isCustomModel) {
@@ -400,11 +170,13 @@ async function handleCreate(args: string[]): Promise {
if (wantCustomMapping) {
console.log('');
- if (isCustomModel) {
- console.log(dim('Configure model IDs for each tier (defaults to your model):'));
- } else {
- console.log(dim('Leave blank to use the default model for each.'));
- }
+ console.log(
+ dim(
+ isCustomModel
+ ? 'Configure model IDs for each tier (defaults to your model):'
+ : 'Leave blank to use the default model for each.'
+ )
+ );
opusModel =
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
default: model,
@@ -420,7 +192,6 @@ async function handleCreate(args: string[]): Promise {
}
}
- // Build model mapping
const models: ModelMapping = {
default: model,
opus: opusModel,
@@ -428,234 +199,102 @@ async function handleCreate(args: string[]): Promise {
haiku: haikuModel,
};
- // Check if custom model mapping is configured
- const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
-
- // Create files
+ // Create profile
console.log('');
console.log(info('Creating API profile...'));
- try {
- const settingsFile = `~/.ccs/${name}.settings.json`;
+ const result = createApiProfile(name, baseUrl, apiKey, models);
- if (isUnifiedMode()) {
- // Use unified config format
- createApiProfileUnified(name, baseUrl, apiKey, models);
- console.log('');
-
- // Build info message
- let infoMsg =
- `API: ${name}\n` +
- `Config: ~/.ccs/config.yaml\n` +
- `Settings: ${settingsFile}\n` +
- `Base URL: ${baseUrl}\n` +
- `Model: ${model}`;
-
- if (hasCustomMapping) {
- infoMsg +=
- `\n\nModel Mapping:\n` +
- ` Opus: ${opusModel}\n` +
- ` Sonnet: ${sonnetModel}\n` +
- ` Haiku: ${haikuModel}`;
- }
-
- console.log(infoBox(infoMsg, 'API Profile Created'));
- } else {
- // Use legacy JSON format
- const settingsPath = createSettingsFile(name, baseUrl, apiKey, models);
- updateConfig(name, settingsPath);
- console.log('');
-
- let infoMsg =
- `API: ${name}\n` +
- `Settings: ${settingsFile}\n` +
- `Base URL: ${baseUrl}\n` +
- `Model: ${model}`;
-
- if (hasCustomMapping) {
- infoMsg +=
- `\n\nModel Mapping:\n` +
- ` Opus: ${opusModel}\n` +
- ` Sonnet: ${sonnetModel}\n` +
- ` Haiku: ${haikuModel}`;
- }
-
- console.log(infoBox(infoMsg, 'API Profile Created'));
- }
-
- console.log('');
- console.log(header('Usage'));
- console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
- console.log('');
- console.log(header('Edit Settings'));
- console.log(` ${dim('To modify env vars later:')}`);
- console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`);
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to create API profile: ${(error as Error).message}`));
+ if (!result.success) {
+ console.log(fail(`Failed to create API profile: ${result.error}`));
process.exit(1);
}
-}
-/**
- * Check if API profile has real API key (not placeholder)
- */
-function isApiConfigured(apiName: string): boolean {
- try {
- if (isUnifiedMode()) {
- // Check secrets.yaml for unified config
- const { getProfileSecrets } = require('../config/secrets-manager');
- const secrets = getProfileSecrets(apiName);
- const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
- return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
- }
- // Legacy: check settings.json file
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
- if (!fs.existsSync(settingsPath)) return false;
+ // Display success
+ console.log('');
+ const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
+ let infoMsg =
+ `API: ${name}\n` +
+ `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
+ `Settings: ${result.settingsFile}\n` +
+ `Base URL: ${baseUrl}\n` +
+ `Model: ${model}`;
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
- const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
- // Check if it's a placeholder or empty
- return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
- } catch {
- return false;
+ if (hasCustomMapping) {
+ infoMsg +=
+ `\n\nModel Mapping:\n` +
+ ` Opus: ${opusModel}\n` +
+ ` Sonnet: ${sonnetModel}\n` +
+ ` Haiku: ${haikuModel}`;
}
+
+ console.log(infoBox(infoMsg, 'API Profile Created'));
+ console.log('');
+ console.log(header('Usage'));
+ console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
+ console.log('');
+ console.log(header('Edit Settings'));
+ console.log(` ${dim('To modify env vars later:')}`);
+ console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`);
+ console.log('');
}
-/**
- * Handle 'ccs api list' command
- */
+/** Handle 'ccs api list' command */
async function handleList(): Promise {
await initUI();
console.log(header('CCS API Profiles'));
console.log('');
- try {
- if (isUnifiedMode()) {
- // List from unified config
- const unifiedConfig = loadOrCreateUnifiedConfig();
- const apis = Object.keys(unifiedConfig.profiles);
+ const { profiles, variants } = listApiProfiles();
- if (apis.length === 0) {
- console.log(warn('No API profiles configured'));
- console.log('');
- console.log('To create an API profile:');
- console.log(` ${color('ccs api create', 'command')}`);
- console.log('');
- return;
- }
+ if (profiles.length === 0) {
+ console.log(warn('No API profiles configured'));
+ console.log('');
+ console.log('To create an API profile:');
+ console.log(` ${color('ccs api create', 'command')}`);
+ console.log('');
+ return;
+ }
- // Build table data with status indicators
- const rows: string[][] = apis.map((name) => {
- const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning');
- return [name, 'config.yaml', status];
- });
+ // Build table data
+ const rows: string[][] = profiles.map((p) => {
+ const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning');
+ return [p.name, p.settingsPath, status];
+ });
- // Print table
- console.log(
- table(rows, {
- head: ['API', 'Config', 'Status'],
- colWidths: [15, 20, 10],
- })
- );
- console.log('');
+ const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10];
+ console.log(
+ table(rows, {
+ head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
+ colWidths,
+ })
+ );
+ console.log('');
- // Show CLIProxy variants if any
- const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
- if (variants.length > 0) {
- console.log(subheader('CLIProxy Variants'));
- const cliproxyRows = variants.map((name) => {
- const variant = unifiedConfig.cliproxy?.variants[name];
- return [name, variant?.provider || 'unknown', variant?.settings || '-'];
- });
-
- console.log(
- table(cliproxyRows, {
- head: ['Variant', 'Provider', 'Settings'],
- colWidths: [15, 15, 30],
- })
- );
- console.log('');
- }
-
- console.log(dim(`Total: ${apis.length} API profile(s)`));
- console.log('');
- return;
- }
-
- // Legacy: list from config.json
- const config = loadConfig();
- const apis = Object.keys(config.profiles);
-
- if (apis.length === 0) {
- console.log(warn('No API profiles configured'));
- console.log('');
- console.log('To create an API profile:');
- console.log(` ${color('ccs api create', 'command')}`);
- console.log('');
- return;
- }
-
- // Build table data with status indicators
- const rows: string[][] = apis.map((name) => {
- const settingsPath = config.profiles[name];
- const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning');
-
- return [name, settingsPath, status];
- });
-
- // Print table
+ // Show CLIProxy variants if any
+ if (variants.length > 0) {
+ console.log(subheader('CLIProxy Variants'));
+ const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]);
console.log(
- table(rows, {
- head: ['API', 'Settings File', 'Status'],
- colWidths: [15, 35, 10],
+ table(cliproxyRows, {
+ head: ['Variant', 'Provider', 'Settings'],
+ colWidths: [15, 15, 30],
})
);
console.log('');
-
- // Show CLIProxy variants if any
- if (config.cliproxy && Object.keys(config.cliproxy).length > 0) {
- console.log(subheader('CLIProxy Variants'));
- const cliproxyRows = Object.entries(config.cliproxy).map(([name, v]) => {
- const variant = v as { provider: string; settings: string };
- return [name, variant.provider, variant.settings];
- });
-
- console.log(
- table(cliproxyRows, {
- head: ['Variant', 'Provider', 'Settings'],
- colWidths: [15, 15, 30],
- })
- );
- console.log('');
- }
-
- console.log(dim(`Total: ${apis.length} API profile(s)`));
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to list API profiles: ${(error as Error).message}`));
- process.exit(1);
}
+
+ console.log(dim(`Total: ${profiles.length} API profile(s)`));
+ console.log('');
}
-/**
- * Handle 'ccs api remove' command
- */
+/** Handle 'ccs api remove' command */
async function handleRemove(args: string[]): Promise {
await initUI();
const parsedArgs = parseArgs(args);
- // Get available APIs based on config mode
- let apis: string[];
- if (isUnifiedMode()) {
- const unifiedConfig = loadOrCreateUnifiedConfig();
- apis = Object.keys(unifiedConfig.profiles);
- } else {
- const config = loadConfig();
- apis = Object.keys(config.profiles);
- }
+ const apis = getApiProfileNames();
if (apis.length === 0) {
console.log(warn('No API profiles to remove'));
@@ -691,7 +330,7 @@ async function handleRemove(args: string[]): Promise {
// Confirm deletion
console.log('');
console.log(`API '${color(name, 'command')}' will be removed.`);
- if (isUnifiedMode()) {
+ if (isUsingUnifiedConfig()) {
console.log(' Config: ~/.ccs/config.yaml');
console.log(' Secrets: ~/.ccs/secrets.yaml');
} else {
@@ -708,37 +347,18 @@ async function handleRemove(args: string[]): Promise {
process.exit(0);
}
- try {
- if (isUnifiedMode()) {
- // Remove from unified config
- removeApiProfileUnified(name);
- } else {
- // Remove from legacy config.json
- const config = loadConfig();
- delete config.profiles[name];
- const configPath = getConfigPath();
- const tempPath = configPath + '.tmp';
- fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tempPath, configPath);
+ const result = removeApiProfile(name);
- // Remove settings file if it exists
- const expandedPath = path.join(getCcsDir(), `${name}.settings.json`);
- if (fs.existsSync(expandedPath)) {
- fs.unlinkSync(expandedPath);
- }
- }
-
- console.log(ok(`API profile removed: ${name}`));
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to remove API profile: ${(error as Error).message}`));
+ if (!result.success) {
+ console.log(fail(`Failed to remove API profile: ${result.error}`));
process.exit(1);
}
+
+ console.log(ok(`API profile removed: ${name}`));
+ console.log('');
}
-/**
- * Show help for api commands
- */
+/** Show help for api commands */
async function showHelp(): Promise {
await initUI();
@@ -774,9 +394,7 @@ async function showHelp(): Promise {
console.log('');
}
-/**
- * Main api command router
- */
+/** Main api command router */
export async function handleApiCommand(args: string[]): Promise {
const command = args[0];
diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts
index 5c7cf58e..38090a9b 100644
--- a/src/commands/cliproxy-command.ts
+++ b/src/commands/cliproxy-command.ts
@@ -18,27 +18,14 @@
* - Legacy JSON format (config.json) as fallback
*/
-import * as fs from 'fs';
import * as path from 'path';
-import {
- getInstalledCliproxyVersion,
- installCliproxyVersion,
- fetchLatestCliproxyVersion,
- isCLIProxyInstalled,
- getCLIProxyPath,
- getPinnedVersion,
- savePinnedVersion,
- clearPinnedVersion,
- isVersionPinned,
-} from '../cliproxy';
import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler';
import { getProviderAccounts } from '../cliproxy/account-manager';
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
-import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager';
-import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
-import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../cliproxy/model-catalog';
+import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
import { CLIProxyProvider } from '../cliproxy/types';
+import { isUnifiedMode } from '../config/unified-config-loader';
import {
initUI,
header,
@@ -53,17 +40,24 @@ import {
infoBox,
} from '../utils/ui';
import { InteractivePrompt } from '../utils/prompt';
-import { isReservedName } from '../config/reserved-names';
+
+// Import services
import {
- hasUnifiedConfig,
- loadOrCreateUnifiedConfig,
- saveUnifiedConfig,
-} from '../config/unified-config-loader';
-import { isUnifiedConfigEnabled } from '../config/feature-flags';
-import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker';
+ validateProfileName,
+ variantExists,
+ listVariants,
+ createVariant,
+ removeVariant,
+ getProxyStatus,
+ stopProxy,
+ getBinaryStatus,
+ checkLatestVersion,
+ installVersion,
+ installLatest,
+} from '../cliproxy/services';
// ============================================================================
-// PROFILE MANAGEMENT
+// ARGUMENT PARSING
// ============================================================================
interface CliproxyProfileArgs {
@@ -75,15 +69,10 @@ interface CliproxyProfileArgs {
yes?: boolean;
}
-/**
- * Parse command line arguments for profile commands
- */
function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = {};
-
for (let i = 0; i < args.length; i++) {
const arg = args[i];
-
if (arg === '--provider' && args[i + 1]) {
result.provider = args[++i] as CLIProxyProfileName;
} else if (arg === '--model' && args[i + 1]) {
@@ -98,270 +87,21 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs {
result.name = arg;
}
}
-
return result;
}
-/**
- * Validate CLIProxy profile name
- */
-function validateProfileName(name: string): string | null {
- if (!name) {
- return 'Profile name is required';
- }
- if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
- return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore';
- }
- if (name.length > 32) {
- return 'Name must be 32 characters or less';
- }
- // Use centralized reserved names list (includes built-in cliproxy profiles)
- if (isReservedName(name)) {
- return `'${name}' is a reserved name`;
- }
- return null;
-}
-
-/**
- * Check if unified config mode is active
- */
-function isUnifiedMode(): boolean {
- return hasUnifiedConfig() || isUnifiedConfigEnabled();
-}
-
-/**
- * Check if CLIProxy variant profile exists
- */
-function cliproxyVariantExists(name: string): boolean {
- try {
- if (isUnifiedMode()) {
- const config = loadOrCreateUnifiedConfig();
- return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
- }
- const config = loadConfig();
- return !!(config.cliproxy && name in config.cliproxy);
- } catch {
- return false;
- }
-}
-
-/**
- * Create settings.json file for CLIProxy variant
- * Includes all 6 fields for proper Claude CLI integration
- */
-function createCliproxySettingsFile(
- name: string,
- provider: CLIProxyProfileName,
- model: string,
- _account?: string
-): string {
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`);
-
- // Get base env vars from provider config
- const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
-
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
- ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
- ANTHROPIC_MODEL: model,
- ANTHROPIC_DEFAULT_OPUS_MODEL: model,
- ANTHROPIC_DEFAULT_SONNET_MODEL: model,
- ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
- },
- };
-
- // Ensure directory exists
- if (!fs.existsSync(ccsDir)) {
- fs.mkdirSync(ccsDir, { recursive: true });
- }
-
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
- return settingsPath;
-}
-
-/**
- * Update config.json with new CLIProxy variant
- */
-function addCliproxyVariant(
- name: string,
- provider: CLIProxyProfileName,
- settingsPath: string,
- account?: string
-): void {
- const configPath = getConfigPath();
-
- // Read existing config or create new
- let config: { profiles: Record; cliproxy?: Record };
- try {
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- } catch {
- config = { profiles: {} };
- }
-
- // Ensure cliproxy section exists
- if (!config.cliproxy) {
- config.cliproxy = {};
- }
-
- // Use relative path with ~ for portability
- const relativePath = `~/.ccs/${path.basename(settingsPath)}`;
-
- // Build variant config with optional account
- const variantConfig: { provider: string; settings: string; account?: string } = {
- provider,
- settings: relativePath,
- };
- if (account) {
- variantConfig.account = account;
- }
- config.cliproxy[name] = variantConfig;
-
- // Write config atomically
- const tempPath = configPath + '.tmp';
- fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tempPath, configPath);
-}
-
-/**
- * Remove CLIProxy variant from config.json
- */
-function removeCliproxyVariant(name: string): { provider: string; settings: string } | null {
- const configPath = getConfigPath();
-
- let config: { profiles: Record; cliproxy?: Record };
- try {
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- } catch {
- return null;
- }
-
- if (!config.cliproxy || !(name in config.cliproxy)) {
- return null;
- }
-
- const variant = config.cliproxy[name] as { provider: string; settings: string };
- delete config.cliproxy[name];
-
- // Clean up empty cliproxy section
- if (Object.keys(config.cliproxy).length === 0) {
- delete config.cliproxy;
- }
-
- // Write config atomically
- const tempPath = configPath + '.tmp';
- fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tempPath, configPath);
-
- return variant;
-}
-
-/**
- * Add CLIProxy variant to unified config (config.yaml)
- * Creates *.settings.json file and stores reference in config.yaml
- */
-function addCliproxyVariantUnified(
- name: string,
- provider: CLIProxyProfileName,
- model: string,
- account?: string
-): void {
- const ccsDir = path.join(require('os').homedir(), '.ccs');
- const settingsFile = `${provider}-${name}.settings.json`;
- const settingsPath = path.join(ccsDir, settingsFile);
-
- // Get base env vars from provider config
- const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
-
- // Create settings file with model override
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
- ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
- ANTHROPIC_MODEL: model,
- ANTHROPIC_DEFAULT_OPUS_MODEL: model,
- ANTHROPIC_DEFAULT_SONNET_MODEL: model,
- ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
- },
- };
-
- // Ensure directory exists
- if (!fs.existsSync(ccsDir)) {
- fs.mkdirSync(ccsDir, { recursive: true });
- }
-
- // Write settings file
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
-
- // Update config.yaml with reference
- const config = loadOrCreateUnifiedConfig();
-
- // Ensure cliproxy.variants section exists
- if (!config.cliproxy) {
- config.cliproxy = {
- oauth_accounts: {},
- providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'],
- variants: {},
- };
- }
- if (!config.cliproxy.variants) {
- config.cliproxy.variants = {};
- }
-
- // Add variant with settings file reference
- config.cliproxy.variants[name] = {
- provider: provider as CLIProxyProvider,
- account,
- settings: `~/.ccs/${settingsFile}`,
- };
-
- saveUnifiedConfig(config);
-}
-
-/**
- * Remove CLIProxy variant from unified config
- */
-function removeCliproxyVariantUnified(
- name: string
-): { provider: string; settings?: string } | null {
- const config = loadOrCreateUnifiedConfig();
-
- if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) {
- return null;
- }
-
- const variant = config.cliproxy.variants[name];
-
- // Delete the settings file if it exists
- if (variant.settings) {
- const settingsPath = variant.settings.replace(/^~/, require('os').homedir());
- if (fs.existsSync(settingsPath)) {
- fs.unlinkSync(settingsPath);
- }
- }
-
- delete config.cliproxy.variants[name];
- saveUnifiedConfig(config);
-
- return { provider: variant.provider, settings: variant.settings };
-}
-
-/**
- * Format model entry for display
- */
function formatModelOption(model: ModelEntry): string {
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
return `${model.name}${tierBadge}`;
}
-/**
- * Handle 'ccs cliproxy create' command
- */
+// ============================================================================
+// COMMAND HANDLERS
+// ============================================================================
+
async function handleCreate(args: string[]): Promise {
await initUI();
const parsedArgs = parseProfileArgs(args);
-
console.log(header('Create CLIProxy Variant'));
console.log('');
@@ -379,8 +119,7 @@ async function handleCreate(args: string[]): Promise {
}
}
- // Check if exists
- if (cliproxyVariantExists(name) && !parsedArgs.force) {
+ if (variantExists(name) && !parsedArgs.force) {
console.log(fail(`Variant '${name}' already exists`));
console.log(` Use ${color('--force', 'command')} to overwrite`);
process.exit(1);
@@ -393,7 +132,6 @@ async function handleCreate(args: string[]): Promise {
id: p,
label: p.charAt(0).toUpperCase() + p.slice(1),
}));
-
provider = (await InteractivePrompt.selectFromList(
'Select provider:',
providerOptions
@@ -410,76 +148,57 @@ async function handleCreate(args: string[]): Promise {
if (!account) {
if (providerAccounts.length === 0) {
- // No accounts - prompt to authenticate first
console.log('');
console.log(warn(`No accounts authenticated for ${provider}`));
console.log('');
-
const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, {
default: true,
});
-
if (!shouldAuth) {
console.log('');
console.log(info('Run authentication first:'));
console.log(` ${color(`ccs ${provider} --auth`, 'command')}`);
process.exit(0);
}
-
- // Trigger OAuth inline
console.log('');
const newAccount = await triggerOAuth(provider as CLIProxyProvider, {
add: true,
verbose: args.includes('--verbose'),
});
-
if (!newAccount) {
console.log(fail('Authentication failed'));
process.exit(1);
}
-
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
} else if (providerAccounts.length === 1) {
- // Single account - auto-select
account = providerAccounts[0].id;
} else {
- // Multiple accounts - show selector with "Add new" option
const ADD_NEW_ID = '__add_new__';
-
const accountOptions = [
...providerAccounts.map((acc) => ({
id: acc.id,
label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`,
})),
- {
- id: ADD_NEW_ID,
- label: color('[+ Add new account...]', 'info'),
- },
+ { id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
];
-
const defaultIdx = providerAccounts.findIndex((a) => a.isDefault);
-
const selectedAccount = await InteractivePrompt.selectFromList(
'Select account:',
accountOptions,
{ defaultIndex: defaultIdx >= 0 ? defaultIdx : 0 }
);
-
if (selectedAccount === ADD_NEW_ID) {
- // Add new account inline
console.log('');
const newAccount = await triggerOAuth(provider as CLIProxyProvider, {
add: true,
verbose: args.includes('--verbose'),
});
-
if (!newAccount) {
console.log(fail('Authentication failed'));
process.exit(1);
}
-
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
@@ -488,15 +207,14 @@ async function handleCreate(args: string[]): Promise {
}
}
} else {
- // Validate provided account exists
const exists = providerAccounts.find((a) => a.id === account);
if (!exists) {
console.log(fail(`Account '${account}' not found for ${provider}`));
console.log('');
console.log('Available accounts:');
- providerAccounts.forEach((a) => {
- console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`);
- });
+ providerAccounts.forEach((a) =>
+ console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
+ );
process.exit(1);
}
}
@@ -504,24 +222,16 @@ async function handleCreate(args: string[]): Promise {
// Step 3: Model selection
let model = parsedArgs.model;
if (!model) {
- // Check if provider has model catalog for interactive selection
if (supportsModelConfig(provider as CLIProxyProvider)) {
const catalog = getProviderCatalog(provider as CLIProxyProvider);
if (catalog) {
- const modelOptions = catalog.models.map((m) => ({
- id: m.id,
- label: formatModelOption(m),
- }));
-
+ const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) }));
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
-
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
});
}
}
-
- // Fallback to manual input if no catalog
if (!model) {
model = await InteractivePrompt.input('Model name', {
validate: (val) => (val ? null : 'Model is required'),
@@ -529,189 +239,103 @@ async function handleCreate(args: string[]): Promise {
}
}
- // Create files
+ // Create variant
console.log('');
console.log(info('Creating CLIProxy variant...'));
+ const result = createVariant(name, provider, model, account);
- try {
- if (isUnifiedMode()) {
- // Use unified config format (no settings file needed - env vars derived at runtime)
- addCliproxyVariantUnified(name, provider, model, account);
-
- console.log('');
- console.log(
- infoBox(
- `Variant: ${name}\n` +
- `Provider: ${provider}\n` +
- `Model: ${model}\n` +
- (account ? `Account: ${account}\n` : '') +
- `Config: ~/.ccs/config.yaml`,
- 'CLIProxy Variant Created (Unified Config)'
- )
- );
- } else {
- // Legacy: create settings.json file
- const settingsPath = createCliproxySettingsFile(name, provider, model, account);
- addCliproxyVariant(name, provider, settingsPath, account);
-
- console.log('');
- console.log(
- infoBox(
- `Variant: ${name}\n` +
- `Provider: ${provider}\n` +
- `Model: ${model}\n` +
- (account ? `Account: ${account}\n` : '') +
- `Settings: ~/.ccs/${path.basename(settingsPath)}`,
- 'CLIProxy Variant Created'
- )
- );
- }
- console.log('');
- console.log(header('Usage'));
- console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
- console.log('');
- console.log(dim('To change model later:'));
- console.log(` ${color(`ccs ${name} --config`, 'command')}`);
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to create variant: ${(error as Error).message}`));
+ if (!result.success) {
+ console.log(fail(`Failed to create variant: ${result.error}`));
process.exit(1);
}
+
+ console.log('');
+ const configType = isUnifiedMode()
+ ? 'CLIProxy Variant Created (Unified Config)'
+ : 'CLIProxy Variant Created';
+ const settingsDisplay = isUnifiedMode()
+ ? '~/.ccs/config.yaml'
+ : `~/.ccs/${path.basename(result.settingsPath || '')}`;
+ console.log(
+ infoBox(
+ `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
+ configType
+ )
+ );
+ console.log('');
+ console.log(header('Usage'));
+ console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
+ console.log('');
+ console.log(dim('To change model later:'));
+ console.log(` ${color(`ccs ${name} --config`, 'command')}`);
+ console.log('');
}
-/**
- * Handle 'ccs cliproxy list' command
- */
async function handleList(): Promise {
await initUI();
-
console.log(header('CLIProxy Profiles'));
console.log('');
- try {
- // Show auth status for built-in profiles
- console.log(subheader('Built-in Profiles'));
- const authStatuses = getAllAuthStatus();
-
- for (const status of authStatuses) {
- const oauthConfig = getOAuthConfig(status.provider);
- const icon = status.authenticated ? ok('') : warn('');
- const authLabel = status.authenticated
- ? color('authenticated', 'success')
- : dim('not authenticated');
- const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : '';
-
- console.log(
- ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}`
- );
- }
- console.log('');
- console.log(dim(' To authenticate: ccs --auth'));
- console.log(dim(' To logout: ccs --logout'));
- console.log('');
-
- // Show custom variants if any (from unified or legacy config)
- let variantNames: string[];
- let variantData: Record;
-
- if (isUnifiedMode()) {
- const unifiedConfig = loadOrCreateUnifiedConfig();
- const variants = unifiedConfig.cliproxy?.variants || {};
- variantNames = Object.keys(variants);
- variantData = {};
- for (const name of variantNames) {
- const v = variants[name];
- variantData[name] = { provider: v.provider, settings: v.settings };
- }
- } else {
- const config = loadConfig();
- const variants = config.cliproxy || {};
- variantNames = Object.keys(variants);
- variantData = {};
- for (const name of variantNames) {
- const v = variants[name] as { provider: string; settings: string };
- variantData[name] = { provider: v.provider, settings: v.settings };
- }
- }
-
- if (variantNames.length > 0) {
- console.log(subheader('Custom Variants'));
-
- // Build table data
- const rows: string[][] = variantNames.map((name) => {
- const variant = variantData[name];
- return [name, variant.provider, variant.settings || '-'];
- });
-
- // Print table
- console.log(
- table(rows, {
- head: ['Variant', 'Provider', 'Settings'],
- colWidths: [15, 12, 35],
- })
- );
- console.log('');
- console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
- console.log('');
- }
-
- console.log(dim('To create a custom variant:'));
- console.log(` ${color('ccs cliproxy create', 'command')}`);
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to list profiles: ${(error as Error).message}`));
- process.exit(1);
+ // Built-in profiles
+ console.log(subheader('Built-in Profiles'));
+ const authStatuses = getAllAuthStatus();
+ for (const status of authStatuses) {
+ const oauthConfig = getOAuthConfig(status.provider);
+ const icon = status.authenticated ? ok('') : warn('');
+ const authLabel = status.authenticated
+ ? color('authenticated', 'success')
+ : dim('not authenticated');
+ const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : '';
+ console.log(
+ ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}`
+ );
}
+ console.log('');
+ console.log(dim(' To authenticate: ccs --auth'));
+ console.log(dim(' To logout: ccs --logout'));
+ console.log('');
+
+ // Custom variants
+ const variants = listVariants();
+ const variantNames = Object.keys(variants);
+
+ if (variantNames.length > 0) {
+ console.log(subheader('Custom Variants'));
+ const rows = variantNames.map((name) => {
+ const variant = variants[name];
+ return [name, variant.provider, variant.settings || '-'];
+ });
+ console.log(
+ table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] })
+ );
+ console.log('');
+ console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
+ console.log('');
+ }
+
+ console.log(dim('To create a custom variant:'));
+ console.log(` ${color('ccs cliproxy create', 'command')}`);
+ console.log('');
}
-/**
- * Handle 'ccs cliproxy remove' command
- */
async function handleRemove(args: string[]): Promise {
await initUI();
const parsedArgs = parseProfileArgs(args);
-
- // Get available variants based on config mode
- let variantNames: string[];
- let variantData: Record;
-
- if (isUnifiedMode()) {
- const unifiedConfig = loadOrCreateUnifiedConfig();
- const variants = unifiedConfig.cliproxy?.variants || {};
- variantNames = Object.keys(variants);
- variantData = {};
- for (const name of variantNames) {
- const v = variants[name];
- variantData[name] = { provider: v.provider, settings: v.settings };
- }
- } else {
- const config = loadConfig();
- const variants = config.cliproxy || {};
- variantNames = Object.keys(variants);
- variantData = {};
- for (const name of variantNames) {
- const v = variants[name] as { provider: string; settings: string };
- variantData[name] = { provider: v.provider, settings: v.settings };
- }
- }
+ const variants = listVariants();
+ const variantNames = Object.keys(variants);
if (variantNames.length === 0) {
console.log(warn('No CLIProxy variants to remove'));
process.exit(0);
}
- // Interactive selection if not provided
let name = parsedArgs.name;
if (!name) {
console.log(header('Remove CLIProxy Variant'));
console.log('');
console.log('Available variants:');
- variantNames.forEach((n, i) => {
- const v = variantData[n];
- console.log(` ${i + 1}. ${n} (${v.provider})`);
- });
+ variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`));
console.log('');
-
name = await InteractivePrompt.input('Variant name to remove', {
validate: (val) => {
if (!val) return 'Variant name is required';
@@ -729,9 +353,7 @@ async function handleRemove(args: string[]): Promise {
process.exit(1);
}
- const variant = variantData[name];
-
- // Confirm deletion
+ const variant = variants[name];
console.log('');
console.log(`Variant '${color(name, 'command')}' will be removed.`);
console.log(` Provider: ${variant.provider}`);
@@ -740,337 +362,27 @@ async function handleRemove(args: string[]): Promise {
const confirmed =
parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false }));
-
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
- try {
- if (isUnifiedMode()) {
- // Remove from unified config
- removeCliproxyVariantUnified(name);
- } else {
- // Remove from legacy config
- const removed = removeCliproxyVariant(name);
- if (!removed) {
- console.log(fail('Failed to remove variant from config'));
- process.exit(1);
- }
-
- // Remove settings file if it exists
- const settingsFile = removed.settings.replace(/^~/, process.env.HOME || '');
- if (fs.existsSync(settingsFile)) {
- fs.unlinkSync(settingsFile);
- }
- }
-
- console.log(ok(`Variant removed: ${name}`));
- console.log('');
- } catch (error) {
- console.log(fail(`Failed to remove variant: ${(error as Error).message}`));
- process.exit(1);
- }
-}
-
-// ============================================================================
-// BINARY MANAGEMENT
-// ============================================================================
-
-/**
- * Show cliproxy command help with styled UI
- */
-async function showHelp(): Promise {
- await initUI();
-
- console.log('');
- console.log(header('CLIProxy Management'));
- console.log('');
-
- // Usage
- console.log(subheader('Usage:'));
- console.log(` ${color('ccs cliproxy', 'command')} [options]`);
- console.log('');
-
- // Profile Commands
- console.log(subheader('Profile Commands:'));
- const profileCmds: [string, string][] = [
- ['create [name]', 'Create new CLIProxy variant profile'],
- ['list', 'List all CLIProxy variant profiles'],
- ['remove ', 'Remove a CLIProxy variant profile'],
- ];
- const maxProfileLen = Math.max(...profileCmds.map(([cmd]) => cmd.length));
- for (const [cmd, desc] of profileCmds) {
- console.log(` ${color(cmd.padEnd(maxProfileLen + 2), 'command')} ${desc}`);
- }
- console.log('');
-
- // Proxy Lifecycle Commands
- console.log(subheader('Proxy Lifecycle:'));
- const lifecycleCmds: [string, string][] = [
- ['status', 'Show running CLIProxy status'],
- ['stop', 'Stop running CLIProxy instance'],
- ];
- const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length));
- for (const [cmd, desc] of lifecycleCmds) {
- console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`);
- }
- console.log('');
- console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
- console.log('');
-
- // Binary Commands
- console.log(subheader('Binary Commands:'));
- const binaryCmds: [string, string][] = [
- ['--install ', 'Install and pin a specific version'],
- ['--latest', 'Install the latest version (no pin)'],
- ['--update', 'Unpin and update to latest version'],
- ];
- const maxBinaryLen = Math.max(...binaryCmds.map(([cmd]) => cmd.length));
- for (const [cmd, desc] of binaryCmds) {
- console.log(` ${color(cmd.padEnd(maxBinaryLen + 2), 'command')} ${desc}`);
- }
- console.log('');
-
- // Multi-Account Commands
- console.log(subheader('Multi-Account Commands:'));
- const multiAcctCmds: [string, string][] = [
- ['--auth', 'Authenticate with a provider (first account)'],
- ['--auth --add', 'Add another account to a provider'],
- ['--nickname ', 'Set friendly name for account'],
- ['--accounts', 'List all accounts for a provider'],
- ['--use ', 'Switch to account by nickname/email'],
- ];
- const maxMultiLen = Math.max(...multiAcctCmds.map(([cmd]) => cmd.length));
- for (const [cmd, desc] of multiAcctCmds) {
- console.log(` ${color(cmd.padEnd(maxMultiLen + 2), 'command')} ${desc}`);
- }
- console.log('');
-
- // Create Options
- console.log(subheader('Create Options:'));
- const createOpts: [string, string][] = [
- ['--provider ', 'Provider (gemini, codex, agy, qwen)'],
- ['--model ', 'Model name'],
- ['--account ', 'Account ID (email or default)'],
- ['--force', 'Overwrite existing variant'],
- ['--yes, -y', 'Skip confirmation prompts'],
- ];
- const maxOptLen = Math.max(...createOpts.map(([opt]) => opt.length));
- for (const [opt, desc] of createOpts) {
- console.log(` ${color(opt.padEnd(maxOptLen + 2), 'command')} ${desc}`);
- }
- console.log('');
-
- // Examples
- console.log(subheader('Examples:'));
- console.log(
- ` $ ${color('ccs cliproxy create', 'command')} ${dim('# Interactive wizard')}`
- );
- console.log(` $ ${color('ccs cliproxy create g3 --provider gemini', 'command')}`);
- console.log(
- ` ${color('--model gemini-3-pro-preview', 'command')} ${dim('# Non-interactive')}`
- );
- console.log(
- ` $ ${color('ccs cliproxy list', 'command')} ${dim('# Show all variants')}`
- );
- console.log(
- ` $ ${color('ccs cliproxy remove g3', 'command')} ${dim('# Remove variant')}`
- );
- console.log(
- ` $ ${color('ccs cliproxy --latest', 'command')} ${dim('# Update binary')}`
- );
- console.log('');
- console.log(subheader('Multi-Account Examples:'));
- console.log(
- ` $ ${color('ccs gemini --auth', 'command')} ${dim('# First account')}`
- );
- console.log(
- ` $ ${color('ccs gemini --auth --add', 'command')} ${dim('# Add second account')}`
- );
- console.log(
- ` $ ${color('ccs gemini --auth --add --nickname work', 'command')} ${dim('# With nickname')}`
- );
- console.log(
- ` $ ${color('ccs agy --accounts', 'command')} ${dim('# List accounts')}`
- );
- console.log(
- ` $ ${color('ccs agy --use work', 'command')} ${dim('# Switch account')}`
- );
- console.log('');
-
- // Notes
- console.log(subheader('Notes:'));
- console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`);
- console.log(
- ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}`
- );
- console.log('');
-}
-
-/**
- * Show current cliproxy status
- */
-async function showStatus(verbose: boolean): Promise {
- await initUI();
-
- const installed = isCLIProxyInstalled();
- const currentVersion = getInstalledCliproxyVersion();
- const binaryPath = getCLIProxyPath();
- const pinnedVersion = getPinnedVersion();
-
- console.log('');
- console.log(color('CLIProxyAPI Status', 'primary'));
- console.log('');
-
- if (installed) {
- console.log(` Installed: ${color('Yes', 'success')}`);
- const versionLabel = pinnedVersion
- ? `${color(`v${currentVersion}`, 'info')} ${color('(pinned)', 'warning')}`
- : color(`v${currentVersion}`, 'info');
- console.log(` Version: ${versionLabel}`);
- console.log(` Binary: ${dim(binaryPath)}`);
- } else {
- console.log(` Installed: ${color('No', 'error')}`);
- console.log(` Fallback: ${color(`v${CLIPROXY_FALLBACK_VERSION}`, 'info')}`);
- console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`);
- }
-
- // Try to fetch latest version
- try {
- console.log('');
- console.log(` ${dim('Checking for updates...')}`);
- const latestVersion = await fetchLatestCliproxyVersion();
-
- if (latestVersion !== currentVersion) {
- if (pinnedVersion) {
- console.log(
- ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(pinned to v' + pinnedVersion + ')')}`
- );
- console.log('');
- console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`);
- } else {
- console.log(
- ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}`
- );
- console.log('');
- console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`);
- }
- } else {
- console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`);
- }
- } catch (error) {
- if (verbose) {
- const err = error as Error;
- console.log(` Latest: ${dim(`Could not fetch (${err.message})`)}`);
- }
- }
-
- console.log('');
- console.log(dim(`Run "ccs cliproxy --help" for all available commands`));
- console.log('');
-}
-
-/**
- * Install a specific version (pins the version to prevent auto-update)
- */
-async function installVersion(version: string, verbose: boolean): Promise {
- // Validate version format (basic semver check)
- if (!/^\d+\.\d+\.\d+$/.test(version)) {
- console.error(fail('Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)'));
+ const result = removeVariant(name);
+ if (!result.success) {
+ console.log(fail(`Failed to remove variant: ${result.error}`));
process.exit(1);
}
- console.log(info(`Installing CLIProxyAPI v${version}...`));
+ console.log(ok(`Variant removed: ${name}`));
console.log('');
-
- try {
- await installCliproxyVersion(version, verbose);
-
- // Pin the version to prevent auto-update
- savePinnedVersion(version);
-
- console.log('');
- console.log(ok(`CLIProxyAPI v${version} installed (pinned)`));
- console.log('');
- console.log(dim('This version will be used until you run:'));
- console.log(
- ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}`
- );
- console.log('');
- } catch (error) {
- const err = error as Error;
- console.error('');
- console.error(fail(`Failed to install CLIProxyAPI v${version}`));
- console.error(` ${err.message}`);
- console.error('');
- console.error('Possible causes:');
- console.error(' 1. Version does not exist on GitHub');
- console.error(' 2. Network connectivity issues');
- console.error(' 3. GitHub API rate limiting');
- console.error('');
- console.error('Check available versions at:');
- console.error(' https://github.com/router-for-me/CLIProxyAPI/releases');
- process.exit(1);
- }
}
-/**
- * Install latest version (clears any version pin)
- */
-async function installLatest(verbose: boolean): Promise {
- console.log(info('Fetching latest CLIProxyAPI version...'));
-
- try {
- const latestVersion = await fetchLatestCliproxyVersion();
- const currentVersion = getInstalledCliproxyVersion();
- const wasPinned = isVersionPinned();
-
- if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) {
- console.log(ok(`Already running latest version: v${latestVersion}`));
- return;
- }
-
- console.log(info(`Latest version: v${latestVersion}`));
- if (isCLIProxyInstalled()) {
- console.log(info(`Current version: v${currentVersion}`));
- }
- if (wasPinned) {
- console.log(info(`Removing version pin (was v${getPinnedVersion()})`));
- }
- console.log('');
-
- await installCliproxyVersion(latestVersion, verbose);
-
- // Clear any version pin so auto-update works again
- clearPinnedVersion();
-
- console.log('');
- console.log(ok(`CLIProxyAPI updated to v${latestVersion}`));
- console.log(dim('Auto-update is now enabled.'));
- console.log('');
- } catch (error) {
- const err = error as Error;
- console.error(fail(`Failed to install latest version: ${err.message}`));
- process.exit(1);
- }
-}
-
-// ============================================================================
-// PROXY LIFECYCLE COMMANDS
-// ============================================================================
-
-/**
- * Handle 'ccs cliproxy stop' - Stop running CLIProxy instance
- */
async function handleStop(): Promise {
await initUI();
-
console.log(header('Stop CLIProxy'));
console.log('');
const result = await stopProxy();
-
if (result.stopped) {
console.log(ok(`CLIProxy stopped (PID ${result.pid})`));
if (result.sessionCount && result.sessionCount > 0) {
@@ -1082,25 +394,19 @@ async function handleStop(): Promise {
console.log('');
}
-/**
- * Handle 'ccs cliproxy status' - Show running proxy status
- */
async function handleProxyStatus(): Promise {
await initUI();
-
console.log(header('CLIProxy Status'));
console.log('');
const status = getProxyStatus();
-
if (status.running) {
console.log(` Status: ${color('Running', 'success')}`);
console.log(` PID: ${status.pid}`);
console.log(` Port: ${status.port}`);
console.log(` Sessions: ${status.sessionCount || 0} active`);
if (status.startedAt) {
- const started = new Date(status.startedAt);
- console.log(` Started: ${started.toLocaleString()}`);
+ console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`);
}
console.log('');
console.log(dim('To stop: ccs cliproxy stop'));
@@ -1112,24 +418,175 @@ async function handleProxyStatus(): Promise {
console.log('');
}
+async function showStatus(verbose: boolean): Promise {
+ await initUI();
+ const status = getBinaryStatus();
+
+ console.log('');
+ console.log(color('CLIProxyAPI Status', 'primary'));
+ console.log('');
+
+ if (status.installed) {
+ console.log(` Installed: ${color('Yes', 'success')}`);
+ const versionLabel = status.pinnedVersion
+ ? `${color(`v${status.currentVersion}`, 'info')} ${color('(pinned)', 'warning')}`
+ : color(`v${status.currentVersion}`, 'info');
+ console.log(` Version: ${versionLabel}`);
+ console.log(` Binary: ${dim(status.binaryPath)}`);
+ } else {
+ console.log(` Installed: ${color('No', 'error')}`);
+ console.log(` Fallback: ${color(`v${status.fallbackVersion}`, 'info')}`);
+ console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`);
+ }
+
+ const latestCheck = await checkLatestVersion();
+ if (latestCheck.success && latestCheck.latestVersion) {
+ console.log('');
+ if (latestCheck.updateAvailable) {
+ if (status.pinnedVersion) {
+ console.log(
+ ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(pinned to v' + status.pinnedVersion + ')')}`
+ );
+ console.log('');
+ console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`);
+ } else {
+ console.log(
+ ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(update available)')}`
+ );
+ console.log('');
+ console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`);
+ }
+ } else {
+ console.log(
+ ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(up to date)')}`
+ );
+ }
+ } else if (verbose && latestCheck.error) {
+ console.log(` Latest: ${dim(`Could not fetch (${latestCheck.error})`)}`);
+ }
+
+ console.log('');
+ console.log(dim('Run "ccs cliproxy --help" for all available commands'));
+ console.log('');
+}
+
+async function handleInstallVersion(version: string, verbose: boolean): Promise {
+ console.log(info(`Installing CLIProxyAPI v${version}...`));
+ console.log('');
+
+ const result = await installVersion(version, verbose);
+ if (!result.success) {
+ console.error('');
+ console.error(fail(`Failed to install CLIProxyAPI v${version}`));
+ console.error(` ${result.error}`);
+ console.error('');
+ console.error('Possible causes:');
+ console.error(' 1. Version does not exist on GitHub');
+ console.error(' 2. Network connectivity issues');
+ console.error(' 3. GitHub API rate limiting');
+ console.error('');
+ console.error('Check available versions at:');
+ console.error(' https://github.com/router-for-me/CLIProxyAPI/releases');
+ process.exit(1);
+ }
+
+ console.log('');
+ console.log(ok(`CLIProxyAPI v${version} installed (pinned)`));
+ console.log('');
+ console.log(dim('This version will be used until you run:'));
+ console.log(
+ ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}`
+ );
+ console.log('');
+}
+
+async function handleInstallLatest(verbose: boolean): Promise {
+ console.log(info('Fetching latest CLIProxyAPI version...'));
+
+ const result = await installLatest(verbose);
+ if (!result.success) {
+ console.error(fail(`Failed to install latest version: ${result.error}`));
+ process.exit(1);
+ }
+
+ if (result.error?.startsWith('Already')) {
+ console.log(ok(result.error));
+ return;
+ }
+
+ console.log('');
+ console.log(ok(`CLIProxyAPI updated to v${result.version}`));
+ console.log(dim('Auto-update is now enabled.'));
+ console.log('');
+}
+
+async function showHelp(): Promise {
+ await initUI();
+ console.log('');
+ console.log(header('CLIProxy Management'));
+ console.log('');
+ console.log(subheader('Usage:'));
+ console.log(` ${color('ccs cliproxy', 'command')} [options]`);
+ console.log('');
+
+ const sections: [string, [string, string][]][] = [
+ [
+ 'Profile Commands:',
+ [
+ ['create [name]', 'Create new CLIProxy variant profile'],
+ ['list', 'List all CLIProxy variant profiles'],
+ ['remove ', 'Remove a CLIProxy variant profile'],
+ ],
+ ],
+ [
+ 'Proxy Lifecycle:',
+ [
+ ['status', 'Show running CLIProxy status'],
+ ['stop', 'Stop running CLIProxy instance'],
+ ],
+ ],
+ [
+ 'Binary Commands:',
+ [
+ ['--install ', 'Install and pin a specific version'],
+ ['--latest', 'Install the latest version (no pin)'],
+ ['--update', 'Unpin and update to latest version'],
+ ],
+ ],
+ ];
+
+ for (const [title, cmds] of sections) {
+ console.log(subheader(title));
+ const maxLen = Math.max(...cmds.map(([cmd]) => cmd.length));
+ for (const [cmd, desc] of cmds) {
+ console.log(` ${color(cmd.padEnd(maxLen + 2), 'command')} ${desc}`);
+ }
+ console.log('');
+ }
+
+ console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
+ console.log('');
+ console.log(subheader('Notes:'));
+ console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`);
+ console.log(
+ ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}`
+ );
+ console.log('');
+}
+
// ============================================================================
// MAIN ROUTER
// ============================================================================
-/**
- * Main cliproxy command handler
- */
export async function handleCliproxyCommand(args: string[]): Promise {
const verbose = args.includes('--verbose') || args.includes('-v');
const command = args[0];
- // Handle --help
if (args.includes('--help') || args.includes('-h')) {
await showHelp();
return;
}
- // Handle profile commands
if (command === 'create') {
await handleCreate(args.slice(1));
return;
@@ -1145,7 +602,6 @@ export async function handleCliproxyCommand(args: string[]): Promise {
return;
}
- // Handle proxy lifecycle commands
if (command === 'stop') {
await handleStop();
return;
@@ -1156,7 +612,6 @@ export async function handleCliproxyCommand(args: string[]): Promise {
return;
}
- // Handle --install
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {
const version = args[installIdx + 1];
@@ -1166,22 +621,14 @@ export async function handleCliproxyCommand(args: string[]): Promise {
console.error(' Example: ccs cliproxy --install 6.5.53');
process.exit(1);
}
- await installVersion(version, verbose);
+ await handleInstallVersion(version, verbose);
return;
}
- // Handle --latest
- if (args.includes('--latest')) {
- await installLatest(verbose);
+ if (args.includes('--latest') || args.includes('--update')) {
+ await handleInstallLatest(verbose);
return;
}
- // Handle --update (unpin and update to latest)
- if (args.includes('--update')) {
- await installLatest(verbose);
- return;
- }
-
- // Default: show status
await showStatus(verbose);
}
diff --git a/src/commands/index.ts b/src/commands/index.ts
new file mode 100644
index 00000000..007664e5
--- /dev/null
+++ b/src/commands/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Commands module barrel export
+ */
+
+export { handleApiCommand } from './api-command';
+export { handleCleanupCommand } from './cleanup-command';
+export { handleCliproxyCommand } from './cliproxy-command';
+export { handleConfigCommand } from './config-command';
+export { handleCopilotCommand } from './copilot-command';
+export { handleDoctorCommand } from './doctor-command';
+export { handleHelpCommand } from './help-command';
+export { handleInstallCommand } from './install-command';
+export { handleMigrateCommand } from './migrate-command';
+export { handleShellCompletionCommand } from './shell-completion-command';
+export { handleSyncCommand } from './sync-command';
+export { handleUpdateCommand } from './update-command';
+export { handleVersionCommand } from './version-command';
diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts
index 24a36b0b..4189a994 100644
--- a/src/config/unified-config-loader.ts
+++ b/src/config/unified-config-loader.ts
@@ -449,6 +449,16 @@ export function updateUnifiedConfig(updates: Partial): UnifiedCon
return updated;
}
+/**
+ * Check if unified config mode is active.
+ * Returns true if config.yaml exists OR CCS_UNIFIED_CONFIG=1.
+ *
+ * Use this centralized function instead of duplicating the logic.
+ */
+export function isUnifiedMode(): boolean {
+ return hasUnifiedConfig() || isUnifiedConfigEnabled();
+}
+
/**
* Get or set default profile name.
*/
diff --git a/src/delegation/executor/index.ts b/src/delegation/executor/index.ts
new file mode 100644
index 00000000..5919df20
--- /dev/null
+++ b/src/delegation/executor/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Barrel export for executor module
+ */
+
+export * from './types';
+export { StreamBuffer, formatToolVerbose } from './stream-parser';
+export { buildExecutionResult, extractSessionInfo } from './result-aggregator';
diff --git a/src/delegation/executor/result-aggregator.ts b/src/delegation/executor/result-aggregator.ts
new file mode 100644
index 00000000..c7415ba6
--- /dev/null
+++ b/src/delegation/executor/result-aggregator.ts
@@ -0,0 +1,80 @@
+/**
+ * Result aggregation utilities for headless executor
+ */
+
+import type { ExecutionResult, StreamMessage } from './types';
+import { warn } from '../../utils/ui';
+
+/**
+ * Build execution result from stream messages
+ * @param params - Parameters for building result
+ * @returns ExecutionResult with all fields populated
+ */
+export function buildExecutionResult(params: {
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+ cwd: string;
+ profile: string;
+ duration: number;
+ timedOut: boolean;
+ messages: StreamMessage[];
+}): ExecutionResult {
+ const { exitCode, stdout, stderr, cwd, profile, duration, timedOut, messages } = params;
+
+ const result: ExecutionResult = {
+ exitCode,
+ stdout,
+ stderr,
+ cwd,
+ profile,
+ duration,
+ timedOut,
+ success: exitCode === 0 && !timedOut,
+ messages,
+ };
+
+ // Extract metadata from final 'result' message in stream-json
+ const resultMessage = messages.find((m) => m.type === 'result');
+ if (resultMessage) {
+ result.sessionId = resultMessage.session_id || undefined;
+ result.totalCost = resultMessage.total_cost_usd || 0;
+ result.numTurns = resultMessage.num_turns || 0;
+ result.isError = resultMessage.is_error || false;
+ result.type = resultMessage.type || null;
+ result.subtype = resultMessage.subtype || undefined;
+ result.durationApi = resultMessage.duration_api_ms || 0;
+ result.permissionDenials = resultMessage.permission_denials || [];
+ result.errors = resultMessage.errors || [];
+ result.content = resultMessage.result || '';
+ } else {
+ // Fallback: no result message found (shouldn't happen)
+ result.content = stdout;
+ if (process.env.CCS_DEBUG) {
+ console.error(warn('No result message found in stream-json output'));
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Extract session info from result for session management
+ * @param result - Execution result
+ * @returns Session info or null
+ */
+export function extractSessionInfo(result: ExecutionResult): {
+ sessionId: string;
+ totalCost?: number;
+ cwd: string;
+} | null {
+ if (!result.sessionId) {
+ return null;
+ }
+
+ return {
+ sessionId: result.sessionId,
+ totalCost: result.totalCost,
+ cwd: result.cwd,
+ };
+}
diff --git a/src/delegation/executor/stream-parser.ts b/src/delegation/executor/stream-parser.ts
new file mode 100644
index 00000000..a78ce8c3
--- /dev/null
+++ b/src/delegation/executor/stream-parser.ts
@@ -0,0 +1,151 @@
+/**
+ * Stream parsing utilities for Claude CLI stream-json output
+ */
+
+import type { StreamMessage, ToolInput } from './types';
+import { warn } from '../../utils/ui';
+
+/**
+ * Buffer for incomplete JSON lines during streaming
+ */
+export class StreamBuffer {
+ private partialLine = '';
+
+ /**
+ * Process incoming data chunk and extract complete JSON messages
+ * @param dataStr - Raw data string from stream
+ * @returns Array of parsed StreamMessage objects
+ */
+ parseChunk(dataStr: string): StreamMessage[] {
+ const messages: StreamMessage[] = [];
+ const chunk = this.partialLine + dataStr;
+ const lines = chunk.split('\n');
+ this.partialLine = lines.pop() || ''; // Save incomplete line for next chunk
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+
+ try {
+ const msg: StreamMessage = JSON.parse(line);
+ messages.push(msg);
+ } catch (parseError) {
+ // Skip malformed JSON lines (shouldn't happen with stream-json)
+ if (process.env.CCS_DEBUG) {
+ console.error(warn(`Failed to parse stream-json line: ${(parseError as Error).message}`));
+ }
+ }
+ }
+
+ return messages;
+ }
+
+ /**
+ * Reset buffer state
+ */
+ reset(): void {
+ this.partialLine = '';
+ }
+}
+
+/**
+ * Format tool use message for verbose logging
+ * @param toolName - Name of the tool
+ * @param toolInput - Tool input parameters
+ * @returns Formatted verbose message
+ */
+export function formatToolVerbose(toolName: string, toolInput: ToolInput): string {
+ let verboseMsg = `[Tool] ${toolName}`;
+
+ switch (toolName) {
+ case 'Bash':
+ if (toolInput.command) {
+ const command = toolInput.command as string;
+ const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command;
+ verboseMsg += `: ${cmd}`;
+ }
+ break;
+
+ case 'Edit':
+ case 'Write':
+ case 'Read':
+ if (toolInput.file_path) {
+ verboseMsg += `: ${toolInput.file_path}`;
+ }
+ break;
+
+ case 'NotebookEdit':
+ case 'NotebookRead':
+ if (toolInput.notebook_path) {
+ verboseMsg += `: ${toolInput.notebook_path}`;
+ }
+ break;
+
+ case 'Grep':
+ if (toolInput.pattern) {
+ verboseMsg += `: searching for "${toolInput.pattern}"`;
+ if (toolInput.path) {
+ verboseMsg += ` in ${toolInput.path}`;
+ }
+ }
+ break;
+
+ case 'Glob':
+ if (toolInput.pattern) {
+ verboseMsg += `: ${toolInput.pattern}`;
+ }
+ break;
+
+ case 'SlashCommand':
+ if (toolInput.command) {
+ verboseMsg += `: ${toolInput.command}`;
+ }
+ break;
+
+ case 'Task':
+ if (toolInput.description) {
+ verboseMsg += `: ${toolInput.description}`;
+ } else if (toolInput.prompt) {
+ const promptText = toolInput.prompt as string;
+ const prompt = promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText;
+ verboseMsg += `: ${prompt}`;
+ }
+ break;
+
+ case 'TodoWrite':
+ if (toolInput.todos && Array.isArray(toolInput.todos)) {
+ const inProgressTask = toolInput.todos.find(
+ (t: { status: string; activeForm?: string }) => t.status === 'in_progress'
+ );
+ if (inProgressTask && inProgressTask.activeForm) {
+ verboseMsg += `: ${inProgressTask.activeForm}`;
+ } else {
+ verboseMsg += `: ${toolInput.todos.length} task(s)`;
+ }
+ }
+ break;
+
+ case 'WebFetch':
+ if (toolInput.url) {
+ verboseMsg += `: ${toolInput.url}`;
+ }
+ break;
+
+ case 'WebSearch':
+ if (toolInput.query) {
+ verboseMsg += `: "${toolInput.query}"`;
+ }
+ break;
+
+ default:
+ // For unknown tools, show first meaningful parameter
+ if (Object.keys(toolInput).length > 0) {
+ const firstKey = Object.keys(toolInput)[0];
+ const firstValue = toolInput[firstKey];
+ if (typeof firstValue === 'string' && firstValue.length < 60) {
+ verboseMsg += `: ${firstValue}`;
+ }
+ }
+ }
+
+ return verboseMsg;
+}
diff --git a/src/delegation/executor/types.ts b/src/delegation/executor/types.ts
new file mode 100644
index 00000000..4efd61c6
--- /dev/null
+++ b/src/delegation/executor/types.ts
@@ -0,0 +1,124 @@
+/**
+ * Type definitions for headless executor
+ */
+
+/**
+ * Claude message from stream-json output
+ */
+export interface ClaudeMessage {
+ type: string;
+ content?: string;
+ thinking?: string;
+ tool_use?: {
+ id: string;
+ name: string;
+ input: Record;
+ };
+ tool_result?: {
+ tool_use_id: string;
+ content: string;
+ };
+}
+
+/**
+ * Permission denial information
+ */
+export interface PermissionDenial {
+ tool_name?: string;
+ reason?: string;
+ tool_input?: {
+ command?: string;
+ description?: string;
+ [key: string]: unknown;
+ };
+}
+
+/**
+ * Execution error information
+ */
+export interface ExecutionError {
+ message?: string;
+ error?: string;
+ type?: string;
+ tool_name?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Options for headless execution
+ */
+export interface ExecutionOptions {
+ cwd?: string;
+ timeout?: number;
+ outputFormat?: string;
+ permissionMode?: string;
+ resumeSession?: boolean;
+ sessionId?: string;
+ maxRetries?: number;
+ extraArgs?: string[]; // Passthrough args for Claude CLI
+}
+
+/**
+ * Result of headless execution
+ */
+export interface ExecutionResult {
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+ cwd: string;
+ profile: string;
+ duration: number;
+ timedOut: boolean;
+ success: boolean;
+ messages: StreamMessage[];
+ sessionId?: string;
+ totalCost?: number;
+ numTurns?: number;
+ isError?: boolean;
+ type?: string | null;
+ subtype?: string;
+ durationApi?: number;
+ permissionDenials?: PermissionDenial[];
+ errors?: ExecutionError[];
+ content?: string;
+}
+
+/**
+ * Stream message from Claude CLI stream-json output
+ */
+export interface StreamMessage {
+ type: string;
+ message?: {
+ content?: Array<{
+ type: string;
+ name?: string;
+ input?: Record;
+ }>;
+ };
+ session_id?: string;
+ total_cost_usd?: number;
+ num_turns?: number;
+ is_error?: boolean;
+ result?: string;
+ duration_api_ms?: number;
+ permission_denials?: PermissionDenial[];
+ errors?: ExecutionError[];
+ subtype?: string;
+}
+
+/**
+ * Tool input types for verbose logging
+ */
+export interface ToolInput {
+ command?: string;
+ file_path?: string;
+ notebook_path?: string;
+ pattern?: string;
+ path?: string;
+ description?: string;
+ prompt?: string;
+ todos?: Array<{ status: string; activeForm?: string }>;
+ url?: string;
+ query?: string;
+ [key: string]: unknown;
+}
diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts
index c0217593..815c73a2 100644
--- a/src/delegation/headless-executor.ts
+++ b/src/delegation/headless-executor.ts
@@ -1,5 +1,10 @@
#!/usr/bin/env node
+/**
+ * Headless executor for Claude CLI delegation
+ * Spawns claude with -p flag for single-turn execution
+ */
+
import { spawn } from 'child_process';
import * as path from 'path';
import * as os from 'os';
@@ -7,92 +12,15 @@ import * as fs from 'fs';
import { SessionManager } from './session-manager';
import { SettingsParser } from './settings-parser';
import { ui, warn, info } from '../utils/ui';
+import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
+import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
+import { buildExecutionResult } from './executor/result-aggregator';
-// Type definitions for delegation responses
-interface ClaudeMessage {
- type: string;
- content?: string;
- thinking?: string;
- tool_use?: {
- id: string;
- name: string;
- input: Record;
- };
- tool_result?: {
- tool_use_id: string;
- content: string;
- };
-}
-
-interface PermissionDenial {
- tool_name: string;
- reason: string;
-}
-
-interface ExecutionError {
- message?: string;
- error?: string;
- type?: string;
- tool_name?: string;
- [key: string]: unknown;
-}
-
-interface ExecutionOptions {
- cwd?: string;
- timeout?: number;
- outputFormat?: string;
- permissionMode?: string;
- resumeSession?: boolean;
- sessionId?: string;
- maxRetries?: number;
- extraArgs?: string[]; // Passthrough args for Claude CLI
-}
-
-interface ExecutionResult {
- exitCode: number;
- stdout: string;
- stderr: string;
- cwd: string;
- profile: string;
- duration: number;
- timedOut: boolean;
- success: boolean;
- messages: ClaudeMessage[];
- sessionId?: string;
- totalCost?: number;
- numTurns?: number;
- isError?: boolean;
- type?: string | null;
- subtype?: string;
- durationApi?: number;
- permissionDenials?: PermissionDenial[];
- errors?: ExecutionError[];
- content?: string;
-}
-
-interface StreamMessage {
- type: string;
- message?: {
- content?: Array<{
- type: string;
- name?: string;
- input?: Record;
- }>;
- };
- session_id?: string;
- total_cost_usd?: number;
- num_turns?: number;
- is_error?: boolean;
- result?: string;
- duration_api_ms?: number;
- permission_denials?: PermissionDenial[];
- errors?: ExecutionError[];
- subtype?: string;
-}
+// Re-export types for consumers
+export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
/**
* Headless executor for Claude CLI delegation
- * Spawns claude with -p flag for single-turn execution
*/
export class HeadlessExecutor {
/**
@@ -141,26 +69,20 @@ export class HeadlessExecutor {
}
// Smart slash command detection and preservation
- // Detects if prompt contains slash command and restructures for proper execution
const processedPrompt = this._processSlashCommand(enhancedPrompt);
// Prepare arguments
const args: string[] = ['-p', processedPrompt, '--settings', settingsPath];
// Always use stream-json for real-time progress visibility
- // Note: --verbose is required when using --print with stream-json
args.push('--output-format', 'stream-json', '--verbose');
// Add permission mode
if (permissionMode && permissionMode !== 'default') {
if (permissionMode === 'bypassPermissions') {
args.push('--dangerously-skip-permissions');
- // Warn about dangerous mode
if (process.env.CCS_DEBUG) {
console.warn(warn('WARNING: Using --dangerously-skip-permissions mode'));
- console.warn(
- warn('This bypasses ALL permission checks. Use only in trusted environments.')
- );
}
} else {
args.push('--permission-mode', permissionMode);
@@ -170,56 +92,35 @@ export class HeadlessExecutor {
// Add resume flag for multi-turn sessions
if (resumeSession) {
const lastSession = sessionMgr.getLastSession(profile);
-
if (lastSession) {
args.push('--resume', lastSession.sessionId);
if (process.env.CCS_DEBUG) {
- const cost =
- lastSession.totalCost !== undefined && lastSession.totalCost !== null
- ? lastSession.totalCost.toFixed(4)
- : '0.0000';
- console.error(
- info(
- `Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`
- )
- );
+ const cost = lastSession.totalCost?.toFixed(4) || '0.0000';
+ console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`));
}
} else if (sessionId) {
args.push('--resume', sessionId);
- if (process.env.CCS_DEBUG) {
- console.error(info(`Resuming specific session: ${sessionId}`));
- }
} else {
console.warn(warn('No previous session found, starting new session'));
}
} else if (sessionId) {
args.push('--resume', sessionId);
- if (process.env.CCS_DEBUG) {
- console.error(info(`Resuming specific session: ${sessionId}`));
- }
}
// Add tool restrictions from settings
const toolRestrictions = SettingsParser.parseToolRestrictions(cwd);
-
if (toolRestrictions.allowedTools.length > 0) {
- args.push('--allowedTools');
- toolRestrictions.allowedTools.forEach((tool) => args.push(tool));
+ args.push('--allowedTools', ...toolRestrictions.allowedTools);
}
-
if (toolRestrictions.disallowedTools.length > 0) {
- args.push('--disallowedTools');
- toolRestrictions.disallowedTools.forEach((tool) => args.push(tool));
+ args.push('--disallowedTools', ...toolRestrictions.disallowedTools);
}
- // Note: No max-turns limit - using time-based limits instead (default 10min timeout)
-
- // Passthrough extra args (from Claude CLI flags like --agent, --system-prompt-file, etc.)
+ // Passthrough extra args
if (extraArgs.length > 0) {
args.push(...extraArgs);
}
- // Debug log args
if (process.env.CCS_DEBUG) {
console.error(info(`Claude CLI args: ${args.join(' ')}`));
}
@@ -228,13 +129,38 @@ export class HeadlessExecutor {
await ui.init();
// Execute with spawn
+ return this._spawnAndExecute(claudeCli, args, {
+ cwd,
+ profile,
+ timeout,
+ resumeSession,
+ sessionId,
+ sessionMgr,
+ });
+ }
+
+ /**
+ * Spawn Claude CLI and handle execution
+ */
+ private static _spawnAndExecute(
+ claudeCli: string,
+ args: string[],
+ ctx: {
+ cwd: string;
+ profile: string;
+ timeout: number;
+ resumeSession: boolean;
+ sessionId: string | null;
+ sessionMgr: SessionManager;
+ }
+ ): Promise {
+ const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx;
+
return new Promise((resolve, reject) => {
const startTime = Date.now();
-
- // Show progress unless explicitly disabled with CCS_QUIET
const showProgress = !process.env.CCS_QUIET;
+ const streamBuffer = new StreamBuffer();
- // Show initial progress message
if (showProgress) {
const modelName =
profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase();
@@ -250,40 +176,28 @@ export class HeadlessExecutor {
let stdout = '';
let stderr = '';
let progressInterval: NodeJS.Timeout | undefined;
- const messages: StreamMessage[] = []; // Accumulate stream-json messages
- let partialLine = ''; // Buffer for incomplete JSON lines
+ const messages: StreamMessage[] = [];
+ let timedOut = false;
- // Handle parent process termination (Ctrl+C or Esc in Claude)
- // When main Claude session is killed, cleanup spawned child process
+ // Setup signal handlers for cleanup
const cleanupHandler = () => {
if (!proc.killed) {
- if (process.env.CCS_DEBUG) {
- console.error(warn('Parent process terminating, killing delegated session...'));
- }
proc.kill('SIGTERM');
- // Force kill if not dead after 2s
setTimeout(() => {
- if (!proc.killed) {
- proc.kill('SIGKILL');
- }
+ if (!proc.killed) proc.kill('SIGKILL');
}, 2000);
}
};
-
- // Register signal handlers for parent process termination
process.once('SIGINT', cleanupHandler);
process.once('SIGTERM', cleanupHandler);
-
- // Cleanup signal handlers when child process exits
const removeSignalHandlers = () => {
process.removeListener('SIGINT', cleanupHandler);
process.removeListener('SIGTERM', cleanupHandler);
};
-
proc.on('close', removeSignalHandlers);
proc.on('error', removeSignalHandlers);
- // Progress indicator (show elapsed time every 5 seconds)
+ // Progress indicator
if (showProgress) {
progressInterval = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -291,155 +205,34 @@ export class HeadlessExecutor {
}, 5000);
}
- // Capture stdout (stream-json format - jsonl)
+ // Capture stdout (stream-json format)
proc.stdout?.on('data', (data: Buffer) => {
const dataStr = data.toString();
stdout += dataStr;
- // Parse stream-json messages (jsonl format - one JSON per line)
- const chunk = partialLine + dataStr;
- const lines = chunk.split('\n');
- partialLine = lines.pop() || ''; // Save incomplete line for next chunk
+ const parsedMessages = streamBuffer.parseChunk(dataStr);
+ for (const msg of parsedMessages) {
+ messages.push(msg);
- for (const line of lines) {
- if (!line.trim()) continue;
-
- try {
- const msg: StreamMessage = JSON.parse(line);
- messages.push(msg);
-
- // Show real-time tool use with verbose details
- if (showProgress && msg.type === 'assistant') {
- const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || [];
-
- for (const tool of toolUses) {
- process.stderr.write('\r\x1b[K'); // Clear line
-
- // Show verbose tool use with description/input if available
- const toolInput = tool.input || {};
- let verboseMsg = `[Tool] ${tool.name}`;
-
- // Add context based on tool type (all Claude Code tools)
- switch (tool.name) {
- case 'Bash':
- if (toolInput.command) {
- // Truncate long commands
- const command = toolInput.command as string;
- const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command;
- verboseMsg += `: ${cmd}`;
- }
- break;
-
- case 'Edit':
- case 'Write':
- case 'Read':
- if (toolInput.file_path) {
- verboseMsg += `: ${toolInput.file_path}`;
- }
- break;
-
- case 'NotebookEdit':
- case 'NotebookRead':
- if (toolInput.notebook_path) {
- verboseMsg += `: ${toolInput.notebook_path}`;
- }
- break;
-
- case 'Grep':
- if (toolInput.pattern) {
- verboseMsg += `: searching for "${toolInput.pattern}"`;
- if (toolInput.path) {
- verboseMsg += ` in ${toolInput.path}`;
- }
- }
- break;
-
- case 'Glob':
- if (toolInput.pattern) {
- verboseMsg += `: ${toolInput.pattern}`;
- }
- break;
-
- case 'SlashCommand':
- if (toolInput.command) {
- verboseMsg += `: ${toolInput.command}`;
- }
- break;
-
- case 'Task':
- if (toolInput.description) {
- verboseMsg += `: ${toolInput.description}`;
- } else if (toolInput.prompt) {
- const promptText = toolInput.prompt as string;
- const prompt =
- promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText;
- verboseMsg += `: ${prompt}`;
- }
- break;
-
- case 'TodoWrite':
- if (toolInput.todos && Array.isArray(toolInput.todos)) {
- // Show in_progress task instead of just count
- const inProgressTask = toolInput.todos.find(
- (t: { status: string; activeForm?: string }) => t.status === 'in_progress'
- );
- if (inProgressTask && inProgressTask.activeForm) {
- verboseMsg += `: ${inProgressTask.activeForm}`;
- } else {
- // Fallback to count if no in_progress task
- verboseMsg += `: ${toolInput.todos.length} task(s)`;
- }
- }
- break;
-
- case 'WebFetch':
- if (toolInput.url) {
- verboseMsg += `: ${toolInput.url}`;
- }
- break;
-
- case 'WebSearch':
- if (toolInput.query) {
- verboseMsg += `: "${toolInput.query}"`;
- }
- break;
-
- default:
- // For unknown tools, show first meaningful parameter
- if (Object.keys(toolInput).length > 0) {
- const firstKey = Object.keys(toolInput)[0];
- const firstValue = toolInput[firstKey];
- if (typeof firstValue === 'string' && firstValue.length < 60) {
- verboseMsg += `: ${firstValue}`;
- }
- }
- }
-
- process.stderr.write(`${verboseMsg}\n`);
- }
- }
- } catch (parseError) {
- // Skip malformed JSON lines (shouldn't happen with stream-json)
- if (process.env.CCS_DEBUG) {
- console.error(
- warn(`Failed to parse stream-json line: ${(parseError as Error).message}`)
- );
+ // Show real-time tool use
+ if (showProgress && msg.type === 'assistant') {
+ const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || [];
+ for (const tool of toolUses) {
+ process.stderr.write('\r\x1b[K');
+ const toolInput = tool.input || {};
+ const verboseMsg = formatToolVerbose(tool.name || 'Unknown', toolInput);
+ process.stderr.write(`${verboseMsg}\n`);
}
}
}
});
- // Stream stderr in real-time (progress messages from Claude CLI)
+ // Stream stderr in real-time
proc.stderr?.on('data', (data: Buffer) => {
const stderrText = data.toString();
stderr += stderrText;
-
- // Show stderr in real-time if in TTY
if (showProgress) {
- // Clear progress line before showing stderr
- if (progressInterval) {
- process.stderr.write('\r\x1b[K'); // Clear line
- }
+ if (progressInterval) process.stderr.write('\r\x1b[K');
process.stderr.write(stderrText);
}
});
@@ -448,80 +241,44 @@ export class HeadlessExecutor {
proc.on('close', (exitCode: number | null) => {
const duration = Date.now() - startTime;
- // Clear progress indicator
if (progressInterval) {
clearInterval(progressInterval);
- process.stderr.write('\r\x1b[K'); // Clear line
+ process.stderr.write('\r\x1b[K');
}
- // Show completion message
if (showProgress) {
const durationSec = (duration / 1000).toFixed(1);
- if (timedOut) {
- console.error(ui.warn(`Execution timed out after ${durationSec}s`));
- } else {
- console.error(ui.info(`Execution completed in ${durationSec}s`));
- }
- console.error(''); // Blank line before formatted output
+ console.error(
+ timedOut
+ ? ui.warn(`Timed out after ${durationSec}s`)
+ : ui.info(`Completed in ${durationSec}s`)
+ );
+ console.error('');
}
- const result: ExecutionResult = {
+ const result = buildExecutionResult({
exitCode: exitCode || 0,
stdout,
stderr,
cwd,
profile,
duration,
- timedOut: false,
- success: exitCode === 0 && !timedOut,
- messages, // Include all stream-json messages
- };
+ timedOut,
+ messages,
+ });
- // Extract metadata from final 'result' message in stream-json
- const resultMessage = messages.find((m) => m.type === 'result');
- if (resultMessage) {
- // Add parsed fields from result message
- result.sessionId = resultMessage.session_id || undefined;
- result.totalCost = resultMessage.total_cost_usd || 0;
- result.numTurns = resultMessage.num_turns || 0;
- result.isError = resultMessage.is_error || false;
- result.type = resultMessage.type || null;
- result.subtype = resultMessage.subtype || undefined;
- result.durationApi = resultMessage.duration_api_ms || 0;
- result.permissionDenials = resultMessage.permission_denials || [];
- result.errors = resultMessage.errors || [];
-
- // Extract content from result message
- result.content = resultMessage.result || '';
- } else {
- // Fallback: no result message found (shouldn't happen)
- result.content = stdout;
- if (process.env.CCS_DEBUG) {
- console.error(warn('No result message found in stream-json output'));
- }
- }
-
- // Store or update session if we have session ID (even on timeout, for :continue support)
+ // Store session
if (result.sessionId) {
if (resumeSession || sessionId) {
- // Update existing session
- sessionMgr.updateSession(profile, result.sessionId, {
- totalCost: result.totalCost,
- });
+ sessionMgr.updateSession(profile, result.sessionId, { totalCost: result.totalCost });
} else {
- // Store new session
sessionMgr.storeSession(profile, {
sessionId: result.sessionId,
totalCost: result.totalCost,
- cwd: result.cwd,
+ cwd,
});
}
-
- // Cleanup expired sessions periodically
- if (Math.random() < 0.1) {
- // 10% chance
- sessionMgr.cleanupExpired();
- }
+ if (Math.random() < 0.1) sessionMgr.cleanupExpired();
}
resolve(result);
@@ -529,57 +286,31 @@ export class HeadlessExecutor {
// Handle errors
proc.on('error', (error: Error) => {
- if (progressInterval) {
- clearInterval(progressInterval);
- }
+ if (progressInterval) clearInterval(progressInterval);
reject(new Error(`Failed to execute Claude CLI: ${error.message}`));
});
- // Handle timeout with graceful SIGTERM then forceful SIGKILL
- let timedOut = false;
+ // Handle timeout
if (timeout > 0) {
const timeoutHandle = setTimeout(() => {
if (!proc.killed) {
timedOut = true;
-
if (progressInterval) {
clearInterval(progressInterval);
- process.stderr.write('\r\x1b[K'); // Clear line
+ process.stderr.write('\r\x1b[K');
}
-
- if (process.env.CCS_DEBUG) {
- console.error(
- warn(`Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`)
- );
- }
-
- // Send SIGTERM for graceful shutdown
proc.kill('SIGTERM');
-
- // If process doesn't terminate within 10s, force kill
setTimeout(() => {
- if (!proc.killed) {
- if (process.env.CCS_DEBUG) {
- console.error(warn('Process did not terminate gracefully, sending SIGKILL...'));
- }
- proc.kill('SIGKILL');
- }
- }, 10000); // Give 10s for graceful shutdown instead of 5s
+ if (!proc.killed) proc.kill('SIGKILL');
+ }, 10000);
}
}, timeout);
-
- // Clear timeout on successful completion
proc.on('close', () => clearTimeout(timeoutHandle));
}
});
}
- /**
- * Validate permission mode
- * @param mode - Permission mode
- * @throws {Error} If mode is invalid
- * @private
- */
+ /** Validate permission mode */
private static _validatePermissionMode(mode: string): void {
const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
if (!VALID_MODES.includes(mode)) {
@@ -587,34 +318,18 @@ export class HeadlessExecutor {
}
}
- /**
- * Detect Claude CLI executable
- * @returns Path to claude CLI or null if not found
- * @private
- */
+ /** Detect Claude CLI executable */
private static _detectClaudeCli(): string | null {
- // Check environment variable override
- if (process.env.CCS_CLAUDE_PATH) {
- return process.env.CCS_CLAUDE_PATH;
- }
-
- // Try to find in PATH
+ if (process.env.CCS_CLAUDE_PATH) return process.env.CCS_CLAUDE_PATH;
const { execSync } = require('child_process');
try {
- const result = execSync('command -v claude', { encoding: 'utf8' });
- return result.trim();
- } catch (_error) {
+ return execSync('command -v claude', { encoding: 'utf8' }).trim();
+ } catch {
return null;
}
}
- /**
- * Execute with retry logic
- * @param profile - Profile name
- * @param enhancedPrompt - Enhanced prompt
- * @param options - Execution options
- * @returns execution result
- */
+ /** Execute with retry logic */
static async executeWithRetry(
profile: string,
enhancedPrompt: string,
@@ -626,103 +341,59 @@ export class HeadlessExecutor {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await this.execute(profile, enhancedPrompt, execOptions);
-
- // If successful, return immediately
- if (result.success) {
- return result;
- }
-
- // If not last attempt, retry
+ if (result.success) return result;
if (attempt < maxRetries) {
console.error(warn(`Attempt ${attempt + 1} failed, retrying...`));
- await this._sleep(1000 * (attempt + 1)); // Exponential backoff
+ await this._sleep(1000 * (attempt + 1));
continue;
}
-
- // Last attempt failed, return result anyway
return result;
} catch (error) {
lastError = error as Error;
-
if (attempt < maxRetries) {
- console.error(
- warn(`Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...`)
- );
+ console.error(warn(`Attempt ${attempt + 1} errored, retrying...`));
await this._sleep(1000 * (attempt + 1));
}
}
}
-
- // All retries exhausted
throw lastError || new Error('Execution failed after all retry attempts');
}
- /**
- * Sleep utility for retry backoff
- * @param ms - Milliseconds to sleep
- * @returns Promise
- * @private
- */
+ /** Sleep utility for retry backoff */
private static _sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
- /**
- * Process prompt to detect and preserve slash commands
- * Implements smart enhancement: preserves slash command at start, allows context in rest
- * @param prompt - Original prompt (may contain slash command)
- * @returns Processed prompt with slash command preserved
- * @private
- */
+ /** Process prompt to detect and preserve slash commands */
private static _processSlashCommand(prompt: string): string {
const trimmed = prompt.trim();
- // Case 1: Already starts with slash command - keep as-is
- if (trimmed.match(/^\/[\w:-]+(\s|$)/)) {
- return prompt;
- }
+ // Case 1: Already starts with slash command
+ if (trimmed.match(/^\/[\w:-]+(\s|$)/)) return prompt;
// Case 2: Find slash command embedded in text
- // Look for /command that's NOT part of a file path
- // File paths: /home/user, /path/to/file (have / before or after)
- // Commands: /cook, /plan (standalone, preceded by space/colon/start)
- // Strategy: Find LAST occurrence that looks like a command, not a path
const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/);
-
if (embeddedSlash) {
- const command = embeddedSlash[1]; // e.g., "/cook"
- const args = (embeddedSlash[2] || '').trim(); // Everything after command
-
- // Calculate where the command starts (excluding preceding char if any)
+ const command = embeddedSlash[1];
+ const args = (embeddedSlash[2] || '').trim();
const matchIndex = embeddedSlash.index || 0;
const matchStart = matchIndex + (embeddedSlash[0][0] === '/' ? 0 : 1);
const beforeCommand = trimmed.substring(0, matchStart).trim();
- // Restructure: command first, context after
- if (beforeCommand && args) {
- return `${command} ${args}\n\nContext: ${beforeCommand}`;
- } else if (beforeCommand) {
- return `${command}\n\nContext: ${beforeCommand}`;
- }
+ if (beforeCommand && args) return `${command} ${args}\n\nContext: ${beforeCommand}`;
+ if (beforeCommand) return `${command}\n\nContext: ${beforeCommand}`;
return args ? `${command} ${args}` : command;
}
- // No slash command detected, return as-is
return prompt;
}
- /**
- * Test if profile is executable (quick health check)
- * @param profile - Profile name
- * @returns True if profile can execute
- */
+ /** Test if profile is executable */
static async testProfile(profile: string): Promise {
try {
- const result = await this.execute(profile, 'Say "test successful"', {
- timeout: 10000,
- });
+ const result = await this.execute(profile, 'Say "test successful"', { timeout: 10000 });
return result.success;
- } catch (_error) {
+ } catch {
return false;
}
}
diff --git a/src/delegation/index.ts b/src/delegation/index.ts
new file mode 100644
index 00000000..c7274a60
--- /dev/null
+++ b/src/delegation/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Delegation module barrel export
+ */
+
+export { HeadlessExecutor } from './headless-executor';
+export type { ExecutionOptions, ExecutionResult, StreamMessage } from './headless-executor';
+export { SessionManager } from './session-manager';
+export { SettingsParser } from './settings-parser';
+export { ResultFormatter } from './result-formatter';
+export { DelegationHandler } from './delegation-handler';
+
+// Re-export executor sub-module
+export * from './executor';
diff --git a/src/delegation/result-formatter.ts b/src/delegation/result-formatter.ts
index 1b2497d8..4eae4f9f 100644
--- a/src/delegation/result-formatter.ts
+++ b/src/delegation/result-formatter.ts
@@ -9,40 +9,10 @@ import * as path from 'path';
import { execSync } from 'child_process';
import * as fs from 'fs';
import { ui } from '../utils/ui';
+import type { ExecutionResult, ExecutionError, PermissionDenial } from './executor/types';
-interface ExecutionResult {
- profile: string;
- cwd: string;
- exitCode: number;
- stdout: string;
- stderr: string;
- duration: number;
- success: boolean;
- content?: string;
- sessionId?: string;
- totalCost?: number;
- numTurns?: number;
- subtype?: string;
- permissionDenials?: PermissionDenial[];
- errors?: ErrorInfo[];
- // json?: any; // Removed: unused parameter
- timedOut?: boolean;
-}
-
-interface PermissionDenial {
- tool_name?: string;
- tool_input?: {
- command?: string;
- description?: string;
- [key: string]: unknown;
- };
-}
-
-interface ErrorInfo {
- message?: string;
- error?: string;
- [key: string]: unknown;
-}
+// Alias for backward compatibility
+type ErrorInfo = ExecutionError;
interface FileChanges {
created: string[];
diff --git a/src/errors/cleanup-registry.ts b/src/errors/cleanup-registry.ts
new file mode 100644
index 00000000..151eaf4d
--- /dev/null
+++ b/src/errors/cleanup-registry.ts
@@ -0,0 +1,151 @@
+/**
+ * Cleanup registry for CCS CLI
+ *
+ * Manages cleanup callbacks that should be run on exit or error.
+ * Used to clean up resources like:
+ * - Spawned processes (proxies, child processes)
+ * - Temporary files
+ * - Network connections
+ * - Open file handles
+ */
+
+/**
+ * Cleanup callback type
+ * Callbacks should be synchronous and non-throwing
+ */
+export type CleanupCallback = () => void;
+
+/**
+ * Registry of cleanup callbacks
+ * Executed in LIFO order (last registered = first executed)
+ */
+const cleanupCallbacks: CleanupCallback[] = [];
+
+/**
+ * Flag to prevent double execution
+ */
+let cleanupRan = false;
+
+/**
+ * Register a cleanup callback
+ * Callbacks are executed in LIFO order (stack-like behavior)
+ *
+ * @param fn - Cleanup function to register
+ * @returns Unregister function to remove the callback
+ */
+export function registerCleanup(fn: CleanupCallback): () => void {
+ cleanupCallbacks.push(fn);
+
+ // Return unregister function
+ return () => {
+ const index = cleanupCallbacks.indexOf(fn);
+ if (index !== -1) {
+ cleanupCallbacks.splice(index, 1);
+ }
+ };
+}
+
+/**
+ * Run all registered cleanup callbacks
+ * Executes in LIFO order, catches and logs individual errors
+ * Can only be run once per process
+ */
+export function runCleanup(): void {
+ if (cleanupRan) {
+ return;
+ }
+ cleanupRan = true;
+
+ const isDebug = process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true';
+
+ // Execute in reverse order (LIFO)
+ while (cleanupCallbacks.length > 0) {
+ const callback = cleanupCallbacks.pop();
+ if (callback) {
+ try {
+ callback();
+ } catch (error) {
+ // Log cleanup errors in debug mode but don't throw
+ if (isDebug) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`[!] Cleanup error: ${message}`);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Clear all registered cleanup callbacks
+ * Primarily used for testing
+ */
+export function clearCleanup(): void {
+ cleanupCallbacks.length = 0;
+ cleanupRan = false;
+}
+
+/**
+ * Get the number of registered cleanup callbacks
+ * Primarily used for testing
+ */
+export function getCleanupCount(): number {
+ return cleanupCallbacks.length;
+}
+
+/**
+ * Check if cleanup has already run
+ * Primarily used for testing
+ */
+export function hasCleanupRun(): boolean {
+ return cleanupRan;
+}
+
+/**
+ * Create a cleanup scope for automatic resource management
+ * Resources registered within the scope are cleaned up when done
+ *
+ * @example
+ * ```typescript
+ * const scope = createCleanupScope();
+ * scope.register(() => process.kill());
+ * try {
+ * // ... do work
+ * } finally {
+ * scope.cleanup();
+ * }
+ * ```
+ */
+export function createCleanupScope(): {
+ register: (fn: CleanupCallback) => void;
+ cleanup: () => void;
+} {
+ const scopeCallbacks: CleanupCallback[] = [];
+ const unregisters: Array<() => void> = [];
+
+ return {
+ register: (fn: CleanupCallback) => {
+ scopeCallbacks.push(fn);
+ // Also register with global cleanup in case of unexpected exit
+ const unregister = registerCleanup(fn);
+ unregisters.push(unregister);
+ },
+ cleanup: () => {
+ // Unregister from global cleanup first
+ for (const unregister of unregisters) {
+ unregister();
+ }
+
+ // Execute scope callbacks in LIFO order
+ while (scopeCallbacks.length > 0) {
+ const callback = scopeCallbacks.pop();
+ if (callback) {
+ try {
+ callback();
+ } catch (_error) {
+ // Silently ignore scope cleanup errors
+ }
+ }
+ }
+ },
+ };
+}
diff --git a/src/errors/error-handler.ts b/src/errors/error-handler.ts
new file mode 100644
index 00000000..a5adf988
--- /dev/null
+++ b/src/errors/error-handler.ts
@@ -0,0 +1,167 @@
+/**
+ * Centralized error handler for CCS CLI
+ *
+ * Provides unified error handling with:
+ * - Consistent error formatting
+ * - Exit code management
+ * - Cleanup callback execution
+ * - Debug mode support
+ */
+
+import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes';
+import { isCCSError } from './error-types';
+import { runCleanup } from './cleanup-registry';
+
+/**
+ * Debug mode flag - set via CCS_DEBUG environment variable
+ */
+const isDebugMode = (): boolean => {
+ return process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true';
+};
+
+/**
+ * Format error message for display
+ * Uses ASCII-only formatting per codebase standards (no emojis)
+ */
+function formatErrorMessage(error: unknown): string {
+ if (isCCSError(error)) {
+ return `[X] ${error.message}`;
+ }
+
+ if (error instanceof Error) {
+ return `[X] ${error.message}`;
+ }
+
+ if (typeof error === 'string') {
+ return `[X] ${error}`;
+ }
+
+ return '[X] An unexpected error occurred';
+}
+
+/**
+ * Get exit code from error
+ * CCSError types have their own codes, others default to GENERAL_ERROR
+ */
+function getExitCode(error: unknown): ExitCode {
+ if (isCCSError(error)) {
+ return error.code;
+ }
+ return ExitCode.GENERAL_ERROR;
+}
+
+/**
+ * Log debug information for an error
+ * Only outputs when CCS_DEBUG is enabled
+ */
+function logDebugInfo(error: unknown, code: ExitCode): void {
+ if (!isDebugMode()) return;
+
+ console.error('');
+ console.error('[i] Debug information:');
+ console.error(` Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`);
+
+ if (error instanceof Error) {
+ console.error(` Error type: ${error.constructor.name}`);
+ if (error.stack) {
+ console.error(' Stack trace:');
+ const stackLines = error.stack.split('\n').slice(1, 6);
+ for (const line of stackLines) {
+ console.error(` ${line.trim()}`);
+ }
+ }
+ }
+
+ // Log additional properties for CCSError types
+ if (isCCSError(error)) {
+ console.error(` Recoverable: ${error.recoverable}`);
+ }
+}
+
+/**
+ * Central error handler
+ * Formats error, runs cleanup, and exits with appropriate code
+ *
+ * @param error - The error to handle
+ * @returns never - Always exits the process
+ */
+export function handleError(error: unknown): never {
+ // Run cleanup callbacks first
+ runCleanup();
+
+ const code = getExitCode(error);
+ const message = formatErrorMessage(error);
+
+ // Output error message to stderr
+ console.error(message);
+
+ // Log debug info if enabled
+ logDebugInfo(error, code);
+
+ // Exit with appropriate code
+ process.exit(code);
+}
+
+/**
+ * Exit with an error message and code
+ * Convenience function for simple error exits
+ *
+ * @param message - Error message to display
+ * @param code - Exit code (defaults to GENERAL_ERROR)
+ * @returns never - Always exits the process
+ */
+export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never {
+ runCleanup();
+ console.error(`[X] ${message}`);
+
+ if (isDebugMode()) {
+ console.error('');
+ console.error(`[i] Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`);
+ }
+
+ process.exit(code);
+}
+
+/**
+ * Exit with success
+ * Runs cleanup and exits with SUCCESS code
+ *
+ * @param message - Optional success message to display
+ * @returns never - Always exits the process
+ */
+export function exitWithSuccess(message?: string): never {
+ runCleanup();
+ if (message) {
+ console.log(`[OK] ${message}`);
+ }
+ process.exit(ExitCode.SUCCESS);
+}
+
+/**
+ * Create a wrapped error handler for async operations
+ * Wraps an async function to catch and handle errors
+ */
+export function withErrorHandling(
+ fn: (...args: T) => Promise
+): (...args: T) => Promise {
+ return async (...args: T): Promise => {
+ try {
+ await fn(...args);
+ } catch (error) {
+ handleError(error);
+ }
+ };
+}
+
+/**
+ * Assert a condition, throwing a CCSError if false
+ */
+export function assertOrExit(
+ condition: boolean,
+ message: string,
+ code: ExitCode = ExitCode.GENERAL_ERROR
+): asserts condition {
+ if (!condition) {
+ exitWithError(message, code);
+ }
+}
diff --git a/src/errors/error-types.ts b/src/errors/error-types.ts
new file mode 100644
index 00000000..92cf2cd7
--- /dev/null
+++ b/src/errors/error-types.ts
@@ -0,0 +1,173 @@
+/**
+ * Custom error types for CCS CLI
+ *
+ * All custom errors extend CCSError which provides:
+ * - Standardized exit codes
+ * - Recoverable flag for retry logic
+ * - Consistent error formatting
+ */
+
+import { ExitCode } from './exit-codes';
+
+/**
+ * Base error class for all CCS errors
+ * Extends standard Error with exit code and recovery information
+ */
+export class CCSError extends Error {
+ constructor(
+ message: string,
+ public readonly code: ExitCode = ExitCode.GENERAL_ERROR,
+ public readonly recoverable: boolean = false
+ ) {
+ super(message);
+ this.name = 'CCSError';
+ // Maintain proper stack trace in V8 environments
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, this.constructor);
+ }
+ }
+}
+
+/**
+ * Configuration-related errors
+ * Examples: missing config file, invalid JSON, corrupt settings
+ */
+export class ConfigError extends CCSError {
+ constructor(
+ message: string,
+ public readonly configPath?: string
+ ) {
+ super(message, ExitCode.CONFIG_ERROR, false);
+ this.name = 'ConfigError';
+ }
+}
+
+/**
+ * Network-related errors
+ * Examples: connection refused, timeout, DNS resolution failure
+ */
+export class NetworkError extends CCSError {
+ constructor(
+ message: string,
+ public readonly url?: string,
+ public readonly statusCode?: number
+ ) {
+ super(message, ExitCode.NETWORK_ERROR, true); // Network errors are typically recoverable
+ this.name = 'NetworkError';
+ }
+}
+
+/**
+ * Authentication/authorization errors
+ * Examples: invalid API key, expired token, insufficient permissions
+ */
+export class AuthError extends CCSError {
+ constructor(
+ message: string,
+ public readonly provider?: string
+ ) {
+ super(message, ExitCode.AUTH_ERROR, false);
+ this.name = 'AuthError';
+ }
+}
+
+/**
+ * Binary/executable errors
+ * Examples: Claude CLI not found, corrupted binary, permission denied
+ */
+export class BinaryError extends CCSError {
+ constructor(
+ message: string,
+ public readonly binaryPath?: string
+ ) {
+ super(message, ExitCode.BINARY_ERROR, false);
+ this.name = 'BinaryError';
+ }
+}
+
+/**
+ * Provider-specific errors
+ * Examples: API rate limit, service unavailable, invalid model
+ */
+export class ProviderError extends CCSError {
+ constructor(
+ message: string,
+ public readonly provider: string,
+ public readonly details?: unknown
+ ) {
+ super(message, ExitCode.PROVIDER_ERROR, true); // Provider errors may be recoverable
+ this.name = 'ProviderError';
+ }
+}
+
+/**
+ * Profile-related errors
+ * Examples: profile not found, invalid profile name, duplicate profile
+ */
+export class ProfileError extends CCSError {
+ constructor(
+ message: string,
+ public readonly profileName?: string,
+ public readonly availableProfiles?: string[]
+ ) {
+ super(message, ExitCode.PROFILE_ERROR, false);
+ this.name = 'ProfileError';
+ }
+}
+
+/**
+ * Proxy-related errors
+ * Examples: proxy startup failure, port conflict, proxy timeout
+ */
+export class ProxyError extends CCSError {
+ constructor(
+ message: string,
+ public readonly port?: number
+ ) {
+ super(message, ExitCode.PROXY_ERROR, false);
+ this.name = 'ProxyError';
+ }
+}
+
+/**
+ * Migration-related errors
+ * Examples: failed to migrate config, backup creation failed
+ */
+export class MigrationError extends CCSError {
+ constructor(
+ message: string,
+ public readonly fromVersion?: string,
+ public readonly toVersion?: string
+ ) {
+ super(message, ExitCode.MIGRATION_ERROR, false);
+ this.name = 'MigrationError';
+ }
+}
+
+/**
+ * User abort error (Ctrl+C, SIGINT)
+ * Used when user explicitly cancels an operation
+ */
+export class UserAbortError extends CCSError {
+ constructor(message: string = 'Operation cancelled by user') {
+ super(message, ExitCode.USER_ABORT, false);
+ this.name = 'UserAbortError';
+ }
+}
+
+/**
+ * Type guard to check if an error is a CCSError
+ */
+export function isCCSError(error: unknown): error is CCSError {
+ return error instanceof CCSError;
+}
+
+/**
+ * Type guard to check if an error is recoverable
+ */
+export function isRecoverableError(error: unknown): boolean {
+ if (isCCSError(error)) {
+ return error.recoverable;
+ }
+ return false;
+}
diff --git a/src/errors/exit-codes.ts b/src/errors/exit-codes.ts
new file mode 100644
index 00000000..a1b450cf
--- /dev/null
+++ b/src/errors/exit-codes.ts
@@ -0,0 +1,79 @@
+/**
+ * Standardized exit codes for CCS CLI
+ *
+ * Exit codes follow Unix conventions:
+ * - 0: Success
+ * - 1-125: Application errors
+ * - 126-127: Command execution errors (reserved by shell)
+ * - 128+N: Signal termination (128 + signal number)
+ * - 130: SIGINT (Ctrl+C) - 128 + 2
+ */
+
+export enum ExitCode {
+ /** Successful execution */
+ SUCCESS = 0,
+
+ /** General/unspecified error */
+ GENERAL_ERROR = 1,
+
+ /** Configuration file errors (missing, invalid, corrupt) */
+ CONFIG_ERROR = 2,
+
+ /** Network-related errors (connection, timeout, DNS) */
+ NETWORK_ERROR = 3,
+
+ /** Authentication/authorization errors (invalid token, expired, forbidden) */
+ AUTH_ERROR = 4,
+
+ /** Binary/executable errors (missing Claude CLI, corrupted binary) */
+ BINARY_ERROR = 5,
+
+ /** Provider-specific errors (API errors, rate limits, service unavailable) */
+ PROVIDER_ERROR = 6,
+
+ /** Profile not found or invalid */
+ PROFILE_ERROR = 7,
+
+ /** Proxy-related errors (startup failure, port conflict) */
+ PROXY_ERROR = 8,
+
+ /** Migration errors (failed to migrate config) */
+ MIGRATION_ERROR = 9,
+
+ /** User aborted operation (Ctrl+C, SIGINT) */
+ USER_ABORT = 130,
+}
+
+/**
+ * Human-readable descriptions for exit codes
+ * Used in error messages and documentation
+ */
+export const EXIT_CODE_DESCRIPTIONS: Record = {
+ [ExitCode.SUCCESS]: 'Success',
+ [ExitCode.GENERAL_ERROR]: 'General error',
+ [ExitCode.CONFIG_ERROR]: 'Configuration error',
+ [ExitCode.NETWORK_ERROR]: 'Network error',
+ [ExitCode.AUTH_ERROR]: 'Authentication error',
+ [ExitCode.BINARY_ERROR]: 'Binary/executable error',
+ [ExitCode.PROVIDER_ERROR]: 'Provider error',
+ [ExitCode.PROFILE_ERROR]: 'Profile error',
+ [ExitCode.PROXY_ERROR]: 'Proxy error',
+ [ExitCode.MIGRATION_ERROR]: 'Migration error',
+ [ExitCode.USER_ABORT]: 'User abort (Ctrl+C)',
+};
+
+/**
+ * Check if an exit code indicates success
+ */
+export function isSuccess(code: ExitCode | number): boolean {
+ return code === ExitCode.SUCCESS;
+}
+
+/**
+ * Check if an exit code indicates a recoverable error
+ * (errors that might succeed on retry)
+ */
+export function isRecoverable(code: ExitCode | number): boolean {
+ const recoverableCodes = [ExitCode.NETWORK_ERROR, ExitCode.PROVIDER_ERROR];
+ return recoverableCodes.includes(code as ExitCode);
+}
diff --git a/src/errors/index.ts b/src/errors/index.ts
new file mode 100644
index 00000000..40d2cd4b
--- /dev/null
+++ b/src/errors/index.ts
@@ -0,0 +1,63 @@
+/**
+ * CCS Error Handling Module
+ *
+ * Centralized error handling system for CCS CLI providing:
+ * - Standardized exit codes
+ * - Custom error types with exit code mapping
+ * - Centralized error handler with cleanup
+ * - Cleanup callback registry
+ *
+ * @example
+ * ```typescript
+ * import { handleError, ConfigError, ExitCode, registerCleanup } from './errors';
+ *
+ * // Register cleanup for spawned process
+ * registerCleanup(() => proxy.kill());
+ *
+ * // Throw typed error
+ * throw new ConfigError('Invalid config file', '~/.ccs/config.json');
+ *
+ * // Or handle directly
+ * handleError(new NetworkError('Connection refused'));
+ * ```
+ */
+
+// Exit codes
+export { ExitCode, EXIT_CODE_DESCRIPTIONS, isSuccess, isRecoverable } from './exit-codes';
+
+// Error types
+export {
+ CCSError,
+ ConfigError,
+ NetworkError,
+ AuthError,
+ BinaryError,
+ ProviderError,
+ ProfileError,
+ ProxyError,
+ MigrationError,
+ UserAbortError,
+ isCCSError,
+ isRecoverableError,
+} from './error-types';
+
+// Error handler
+export {
+ handleError,
+ exitWithError,
+ exitWithSuccess,
+ withErrorHandling,
+ assertOrExit,
+} from './error-handler';
+
+// Cleanup registry
+export {
+ registerCleanup,
+ runCleanup,
+ clearCleanup,
+ getCleanupCount,
+ hasCleanupRun,
+ createCleanupScope,
+} from './cleanup-registry';
+
+export type { CleanupCallback } from './cleanup-registry';
diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts
index dd038f6f..cc6aae19 100644
--- a/src/glmt/glmt-transformer.ts
+++ b/src/glmt/glmt-transformer.ts
@@ -1,452 +1,136 @@
/**
- * GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking and tool support
+ * GlmtTransformer - Orchestrator for Anthropic ↔ OpenAI format transformation
*
- * Features:
- * - Request: Anthropic → OpenAI (inject reasoning params, transform tools)
- * - Response: OpenAI reasoning_content → Anthropic thinking blocks
- * - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional)
- * - Streaming: Real-time tool calls with input_json deltas
- * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG=1)
- * - Verbose mode: Console logging with timestamps
- * - Validation: Self-test transformation results
- *
- * Control Tags (in user prompt):
- * - Enable/disable reasoning
- * - Control reasoning depth
+ * Pipeline Architecture:
+ * - RequestTransformer: Anthropic → OpenAI request conversion
+ * - StreamParser: Delta processing for streaming responses
+ * - ResponseBuilder: SSE event generation
+ * - ToolCallHandler: Tool call processing
*/
-import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { DeltaAccumulator } from './delta-accumulator';
-import { LocaleEnforcer } from './locale-enforcer';
-import { ReasoningEnforcer } from './reasoning-enforcer';
-
-// Types
-interface ContentBlock {
- type: string;
- text?: string;
- thinking?: string;
- signature?: ThinkingSignature;
- id?: string;
- name?: string;
- input?: Record;
- tool_use_id?: string;
- content?: string | unknown;
-}
-
-interface Message {
- role: string;
- content: string | ContentBlock[];
-}
-
-interface AnthropicTool {
- name: string;
- description: string;
- input_schema: Record;
-}
-
-interface AnthropicRequest {
- model: string;
- messages: Message[];
- max_tokens?: number;
- temperature?: number;
- top_p?: number;
- tools?: AnthropicTool[];
- stream?: boolean;
- thinking?: {
- type: 'enabled' | 'disabled';
- budget_tokens?: number;
- };
-}
-
-interface OpenAITool {
- type: 'function';
- function: {
- name: string;
- description: string;
- parameters: Record;
- };
-}
-
-interface OpenAIRequest {
- model: string;
- messages: Message[];
- max_tokens?: number;
- temperature?: number;
- top_p?: number;
- tools?: OpenAITool[];
- tool_choice?: string;
- stream?: boolean;
- do_sample?: boolean;
- reasoning?: boolean;
- reasoning_effort?: string;
-}
-
-interface ThinkingConfig {
- thinking: boolean;
- effort: string;
-}
-
-interface TransformResult {
- openaiRequest: OpenAIRequest | AnthropicRequest;
- thinkingConfig: ThinkingConfig;
- error?: string;
-}
-
-interface ThinkingSignature {
- type: string;
- hash: string;
- length: number;
- timestamp: number;
-}
-
-interface OpenAIToolCall {
- id: string;
- type: string;
- function: {
- name: string;
- arguments: string;
- };
-}
-
-interface OpenAIChoice {
- message: {
- role?: string;
- content?: string | null;
- reasoning_content?: string;
- tool_calls?: OpenAIToolCall[];
- };
- delta?: {
- role?: string;
- content?: string;
- reasoning_content?: string;
- tool_calls?: OpenAIToolCallDelta[];
- };
- finish_reason?: string;
-}
-
-interface OpenAIToolCallDelta {
- index: number;
- id?: string;
- type?: string;
- function?: {
- name?: string;
- arguments?: string;
- };
-}
-
-interface OpenAIResponse {
- id?: string;
- model?: string;
- choices?: OpenAIChoice[];
- usage?: {
- prompt_tokens?: number;
- completion_tokens?: number;
- };
-}
-
-interface AnthropicResponse {
- id: string;
- type: string;
- role: string;
- content: ContentBlock[];
- model: string;
- stop_reason: string;
- usage: {
- input_tokens: number;
- output_tokens: number;
- };
-}
-
-interface SSEEvent {
- event?: string;
- data?: OpenAIResponse & {
- usage?: {
- prompt_tokens?: number;
- completion_tokens?: number;
- };
- };
-}
-
-interface AnthropicSSEEvent {
- event: string;
- data: Record;
-}
-
-interface ValidationResult {
- checks: Record;
- passed: number;
- total: number;
- valid: boolean;
-}
-
-interface GlmtTransformerConfig {
- defaultThinking?: boolean;
- verbose?: boolean;
- debugLog?: boolean;
- debugMode?: boolean;
- debugLogDir?: string;
- explicitReasoning?: boolean;
-}
-
-interface AccumulatorBlock {
- index: number;
- type: string;
- content: string;
- stopped: boolean;
-}
+import {
+ RequestTransformer,
+ StreamParser,
+ ResponseBuilder,
+ ToolCallHandler,
+ ContentTransformer,
+ type AnthropicRequest,
+ type OpenAIResponse,
+ type AnthropicResponse,
+ type ThinkingConfig,
+ type TransformResult,
+ type SSEEvent,
+ type AnthropicSSEEvent,
+ type GlmtTransformerConfig,
+ type ContentBlock,
+ type Message,
+ type ThinkingSignature,
+ type ValidationResult,
+} from './pipeline';
export class GlmtTransformer {
- private defaultThinking: boolean;
private verbose: boolean;
private debugLog: boolean;
- private debugMode: boolean;
- debugLogDir: string; // public for external access
- private modelMaxTokens: Record;
- private localeEnforcer: LocaleEnforcer;
- private reasoningEnforcer: ReasoningEnforcer;
+ debugLogDir: string;
+
+ private requestTransformer: RequestTransformer;
+ private streamParser: StreamParser;
+ private responseBuilder: ResponseBuilder;
+ private toolCallHandler: ToolCallHandler;
+ private contentTransformer: ContentTransformer;
constructor(config: GlmtTransformerConfig = {}) {
- this.defaultThinking = config.defaultThinking ?? true;
this.verbose = config.verbose || false;
- // CCS_DEBUG controls all debug logging (file + console)
const debugEnabled = process.env.CCS_DEBUG === '1';
this.debugLog = config.debugLog ?? debugEnabled;
- this.debugMode = config.debugMode ?? debugEnabled;
-
this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
- this.modelMaxTokens = {
- 'GLM-4.6': 128000,
- 'GLM-4.5': 96000,
- 'GLM-4.5-air': 16000,
- };
- // Initialize locale enforcer (always enforce English)
- this.localeEnforcer = new LocaleEnforcer();
+ // Initialize pipeline components
+ this.requestTransformer = new RequestTransformer({
+ defaultThinking: config.defaultThinking ?? true,
+ verbose: this.verbose,
+ explicitReasoning: config.explicitReasoning ?? true,
+ log: (msg) => this.log(msg),
+ });
- // Initialize reasoning enforcer (enabled by default for all GLMT usage)
- this.reasoningEnforcer = new ReasoningEnforcer({
- enabled: config.explicitReasoning ?? true,
+ this.responseBuilder = new ResponseBuilder(this.verbose);
+ this.toolCallHandler = new ToolCallHandler();
+ this.contentTransformer = new ContentTransformer(config.defaultThinking ?? true);
+ this.streamParser = new StreamParser({
+ verbose: this.verbose,
+ debugMode: config.debugMode ?? debugEnabled,
+ debugLog: this.debugLog,
+ writeDebugLog: (type, data) => this.writeDebugLog(type, data),
});
}
- /**
- * Transform Anthropic request to OpenAI format
- */
+ /** Transform Anthropic request to OpenAI format */
transformRequest(anthropicRequest: AnthropicRequest): TransformResult {
- // Log original request
this.writeDebugLog('request-anthropic', anthropicRequest);
-
- try {
- // 1. Extract thinking control from messages (tags like )
- const thinkingConfig = this.extractThinkingControl(anthropicRequest.messages || []);
- const hasControlTags = this.hasThinkingTags(anthropicRequest.messages || []);
-
- // 2. Detect "think" keywords in user prompts (Anthropic-style)
- const keywordConfig = this.detectThinkKeywords(anthropicRequest.messages || []);
- if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) {
- thinkingConfig.thinking = keywordConfig.thinking;
- thinkingConfig.effort = keywordConfig.effort;
- this.log(
- `Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}`
- );
- }
-
- // 3. Check anthropicRequest.thinking parameter (takes precedence)
- if (anthropicRequest.thinking) {
- if (anthropicRequest.thinking.type === 'enabled') {
- thinkingConfig.thinking = true;
- this.log('Claude CLI explicitly enabled thinking (overrides budget)');
- } else if (anthropicRequest.thinking.type === 'disabled') {
- thinkingConfig.thinking = false;
- this.log('Claude CLI explicitly disabled thinking (overrides budget)');
- } else {
- this.log(`Warning: Unknown thinking type: ${anthropicRequest.thinking.type}`);
- }
- }
-
- this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`);
-
- // 3. Map model
- const glmModel = this.mapModel(anthropicRequest.model);
-
- // 4. Inject locale instruction before sanitization
- const messagesWithLocale = this.localeEnforcer.injectInstruction(
- (anthropicRequest.messages || []) as Parameters<
- typeof this.localeEnforcer.injectInstruction
- >[0]
- ) as unknown as Message[];
-
- // 4.5. Inject reasoning instruction (if enabled or thinking requested)
- const messagesWithReasoning = this.reasoningEnforcer.injectInstruction(
- messagesWithLocale as unknown as Parameters<
- typeof this.reasoningEnforcer.injectInstruction
- >[0],
- thinkingConfig
- ) as unknown as Message[];
-
- // 5. Convert to OpenAI format
- const openaiRequest: OpenAIRequest = {
- model: glmModel,
- messages: this.sanitizeMessages(messagesWithReasoning),
- max_tokens: this.getMaxTokens(glmModel),
- stream: anthropicRequest.stream ?? false,
- };
-
- // 5.5. Transform tools parameter if present
- if (anthropicRequest.tools && anthropicRequest.tools.length > 0) {
- openaiRequest.tools = this.transformTools(anthropicRequest.tools);
- // Always use "auto" as Z.AI doesn't support other modes
- openaiRequest.tool_choice = 'auto';
- this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`);
- }
-
- // 6. Preserve optional parameters
- if (anthropicRequest.temperature !== undefined) {
- openaiRequest.temperature = anthropicRequest.temperature;
- }
- if (anthropicRequest.top_p !== undefined) {
- openaiRequest.top_p = anthropicRequest.top_p;
- }
-
- // 7. Handle streaming
- if (anthropicRequest.stream !== undefined) {
- openaiRequest.stream = anthropicRequest.stream;
- }
-
- // 8. Inject reasoning parameters
- this.injectReasoningParams(openaiRequest, thinkingConfig);
-
- // Log transformed request
- this.writeDebugLog('request-openai', openaiRequest);
-
- return { openaiRequest, thinkingConfig };
- } catch (error) {
- const err = error as Error;
- console.error('[glmt-transformer] Request transformation error:', err);
- // Return original request with warning
- return {
- openaiRequest: anthropicRequest,
- thinkingConfig: { thinking: false, effort: 'medium' },
- error: err.message,
- };
- }
+ const result = this.requestTransformer.transform(anthropicRequest);
+ this.writeDebugLog('request-openai', result.openaiRequest);
+ return result;
}
- /**
- * Transform OpenAI response to Anthropic format
- */
+ /** Transform OpenAI response to Anthropic format */
transformResponse(
openaiResponse: OpenAIResponse,
_thinkingConfig: ThinkingConfig = { thinking: false, effort: 'medium' }
): AnthropicResponse {
- // Log original response
this.writeDebugLog('response-openai', openaiResponse);
try {
const choice = openaiResponse.choices?.[0];
- if (!choice) {
- throw new Error('No choices in OpenAI response');
- }
+ if (!choice) throw new Error('No choices in OpenAI response');
const message = choice.message;
const content: ContentBlock[] = [];
- // Add thinking block if reasoning_content exists
if (message.reasoning_content) {
- const length = message.reasoning_content.length;
- const lineCount = message.reasoning_content.split('\n').length;
- const preview = message.reasoning_content.substring(0, 100).replace(/\n/g, ' ').trim();
-
- this.log(`Detected reasoning_content:`);
- this.log(` Length: ${length} characters`);
- this.log(` Lines: ${lineCount}`);
- this.log(` Preview: ${preview}...`);
-
+ this.log(`Detected reasoning_content: ${message.reasoning_content.length} chars`);
content.push({
type: 'thinking',
thinking: message.reasoning_content,
- signature: this.generateThinkingSignature(message.reasoning_content),
+ signature: this.responseBuilder.generateThinkingSignature(message.reasoning_content),
});
- } else {
- this.log('No reasoning_content in OpenAI response');
- this.log('Note: This is expected if thinking not requested or model cannot reason');
}
- // Add text content
if (message.content) {
- content.push({
- type: 'text',
- text: message.content,
- });
+ content.push({ type: 'text', text: message.content });
}
- // Handle tool_calls if present
- if (message.tool_calls && message.tool_calls.length > 0) {
- message.tool_calls.forEach((toolCall) => {
- let parsedInput: Record;
- try {
- parsedInput = JSON.parse(toolCall.function.arguments || '{}');
- } catch (parseError) {
- const err = parseError as Error;
- this.log(`Warning: Invalid JSON in tool arguments: ${err.message}`);
- parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments };
- }
-
- content.push({
- type: 'tool_use',
- id: toolCall.id,
- name: toolCall.function.name,
- input: parsedInput,
- });
- });
+ if (message.tool_calls?.length) {
+ content.push(...this.toolCallHandler.processToolCalls(message.tool_calls));
}
const anthropicResponse: AnthropicResponse = {
id: openaiResponse.id || 'msg_' + Date.now(),
type: 'message',
role: 'assistant',
- content: content,
+ content,
model: openaiResponse.model || 'glm-4.6',
- stop_reason: this.mapStopReason(choice.finish_reason || 'stop'),
+ stop_reason: this.responseBuilder.mapStopReason(choice.finish_reason || 'stop'),
usage: {
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
output_tokens: openaiResponse.usage?.completion_tokens || 0,
},
};
- // Validate transformation in verbose mode
- if (this.verbose) {
- const validation = this.validateTransformation(anthropicResponse);
- this.log(
- `Transformation validation: ${validation.passed}/${validation.total} checks passed`
- );
- if (!validation.valid) {
- this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`);
- }
- }
-
- // Log transformed response
this.writeDebugLog('response-anthropic', anthropicResponse);
-
return anthropicResponse;
} catch (error) {
const err = error as Error;
console.error('[glmt-transformer] Response transformation error:', err);
- // Return minimal valid response
return {
id: 'msg_error_' + Date.now(),
type: 'message',
role: 'assistant',
- content: [
- {
- type: 'text',
- text: '[Transformation Error] ' + err.message,
- },
- ],
+ content: [{ type: 'text', text: '[Transformation Error] ' + err.message }],
model: 'glm-4.6',
stop_reason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0 },
@@ -454,261 +138,55 @@ export class GlmtTransformer {
}
}
- /**
- * Sanitize messages for OpenAI API compatibility
- */
- private sanitizeMessages(messages: Message[]): Message[] {
- const result: Message[] = [];
-
- for (const msg of messages) {
- // If content is a string, add as-is
- if (typeof msg.content === 'string') {
- result.push(msg);
- continue;
- }
-
- // If content is an array, process blocks
- if (Array.isArray(msg.content)) {
- // Separate tool_result blocks from other content
- const toolResults = msg.content.filter((block) => block.type === 'tool_result');
- const textBlocks = msg.content.filter((block) => block.type === 'text');
- // const toolUseBlocks = msg.content.filter(block => block.type === 'tool_use');
-
- // CRITICAL: Tool messages must come BEFORE user text in OpenAI API
- for (const toolResult of toolResults) {
- result.push({
- role: 'tool',
- content:
- typeof toolResult.content === 'string'
- ? toolResult.content
- : JSON.stringify(toolResult.content),
- } as Message & { tool_call_id: string });
- }
-
- // Add text content as user/assistant message AFTER tool messages
- if (textBlocks.length > 0) {
- const textContent =
- textBlocks.length === 1
- ? textBlocks[0].text || ''
- : textBlocks.map((b) => b.text || '').join('\n');
-
- result.push({
- role: msg.role,
- content: textContent,
- });
- }
-
- // If no content at all, add empty message
- if (textBlocks.length === 0 && toolResults.length === 0) {
- result.push({
- role: msg.role,
- content: '',
- });
- }
-
- continue;
- }
-
- // Fallback: return message as-is
- result.push(msg);
- }
-
- return result;
+ /** Transform streaming delta (delegates to StreamParser) */
+ transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ return this.streamParser.transformDelta(openaiEvent, accumulator);
}
- /**
- * Transform Anthropic tools to OpenAI tools format
- */
- private transformTools(anthropicTools: AnthropicTool[]): OpenAITool[] {
- return anthropicTools.map((tool) => ({
- type: 'function' as const,
- function: {
- name: tool.name,
- description: tool.description,
- parameters: tool.input_schema || {},
- },
- }));
+ /** Finalize streaming (delegates to StreamParser) */
+ finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ return this.streamParser.finalizeDelta(accumulator);
}
- /**
- * Check if messages contain thinking control tags
- */
- private hasThinkingTags(messages: Message[]): boolean {
- for (const msg of messages) {
- if (msg.role !== 'user') continue;
- const content = msg.content;
- if (typeof content !== 'string') continue;
-
- // Check for control tags
- if (//i.test(content) || //i.test(content)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Extract thinking control tags from user messages
- */
- private extractThinkingControl(messages: Message[]): ThinkingConfig {
- const config: ThinkingConfig = {
- thinking: this.defaultThinking,
- effort: 'medium',
- };
-
- // Scan user messages for control tags
- for (const msg of messages) {
- if (msg.role !== 'user') continue;
-
- const content = msg.content;
- if (typeof content !== 'string') continue;
-
- // Check for
- const thinkingMatch = content.match(//i);
- if (thinkingMatch) {
- config.thinking = thinkingMatch[1].toLowerCase() === 'on';
- }
-
- // Check for
- const effortMatch = content.match(//i);
- if (effortMatch) {
- config.effort = effortMatch[1].toLowerCase();
- }
- }
-
- return config;
- }
-
- /**
- * Generate thinking signature for Claude Code UI
- */
- private generateThinkingSignature(thinking: string): ThinkingSignature {
- // Generate signature hash
- const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16);
-
- return {
- type: 'thinking_signature',
- hash: hash,
- length: thinking.length,
- timestamp: Date.now(),
- };
- }
-
- /**
- * Detect Anthropic-style "think" keywords in user prompts
- */
- private detectThinkKeywords(
- messages: Message[]
- ): { thinking: boolean; effort: string; keyword: string } | null {
- if (!messages || messages.length === 0) return null;
-
- // Extract text from user messages
- const text = messages
- .filter((m) => m.role === 'user')
- .map((m) => {
- if (typeof m.content === 'string') return m.content;
- if (Array.isArray(m.content)) {
- return m.content
- .filter((block) => block.type === 'text')
- .map((block) => block.text || '')
- .join(' ');
- }
- return '';
- })
- .join(' ');
-
- // Priority: ultrathink > think harder > think hard > think
- if (/\bultrathink\b/i.test(text)) {
- return { thinking: true, effort: 'max', keyword: 'ultrathink' };
- }
- if (/\bthink\s+harder\b/i.test(text)) {
- return { thinking: true, effort: 'high', keyword: 'think harder' };
- }
- if (/\bthink\s+hard\b/i.test(text)) {
- return { thinking: true, effort: 'medium', keyword: 'think hard' };
- }
- if (/\bthink\b/i.test(text)) {
- return { thinking: true, effort: 'low', keyword: 'think' };
- }
-
- return null; // No keywords detected
- }
-
- /**
- * Inject reasoning parameters into OpenAI request
- */
- private injectReasoningParams(
- openaiRequest: OpenAIRequest,
- thinkingConfig: ThinkingConfig
- ): void {
- // Always enable sampling for temperature/top_p to work
- openaiRequest.do_sample = true;
-
- // Add thinking-specific parameters if enabled
- if (thinkingConfig.thinking) {
- openaiRequest.reasoning = true;
- openaiRequest.reasoning_effort = thinkingConfig.effort;
- }
- }
-
- /**
- * Map Anthropic model to GLM model
- */
- private mapModel(_anthropicModel: string): string {
- // Default to GLM-4.6 (latest and most capable)
- return 'GLM-4.6';
- }
-
- /**
- * Get max tokens for model
- */
- private getMaxTokens(model: string): number {
- return this.modelMaxTokens[model] || 128000;
- }
-
- /**
- * Map OpenAI stop reason to Anthropic stop reason
- */
- private mapStopReason(openaiReason: string): string {
- const mapping: Record = {
- stop: 'end_turn',
- length: 'max_tokens',
- tool_calls: 'tool_use',
- content_filter: 'stop_sequence',
- };
- return mapping[openaiReason] || 'end_turn';
- }
-
- /**
- * Write debug log to file
- */
private writeDebugLog(type: string, data: unknown): void {
if (!this.debugLog) return;
-
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
- const filename = `${timestamp}-${type}.json`;
- const filepath = path.join(this.debugLogDir, filename);
-
- // Ensure directory exists
+ const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`);
fs.mkdirSync(this.debugLogDir, { recursive: true });
-
- // Write file (pretty-printed)
fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8');
-
- if (this.verbose) {
- this.log(`Debug log written: ${filepath}`);
- }
} catch (error) {
- const err = error as Error;
- console.error(`[glmt-transformer] Failed to write debug log: ${err.message}`);
+ console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
}
}
- /**
- * Validate transformed Anthropic response
- */
- private validateTransformation(anthropicResponse: AnthropicResponse): ValidationResult {
+ private log(message: string): void {
+ if (this.verbose) {
+ console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
+ }
+ }
+
+ // ========== Backwards-compatible public methods ==========
+
+ /** Generate thinking signature (delegates to ResponseBuilder) */
+ generateThinkingSignature(thinking: string): ThinkingSignature {
+ return this.responseBuilder.generateThinkingSignature(thinking);
+ }
+
+ /** Map stop reason (delegates to ResponseBuilder) */
+ mapStopReason(openaiReason: string): string {
+ return this.responseBuilder.mapStopReason(openaiReason);
+ }
+
+ /** Detect think keywords (delegates to ContentTransformer) */
+ detectThinkKeywords(
+ messages: Message[]
+ ): { thinking: boolean; effort: string; keyword: string } | null {
+ return this.contentTransformer.detectThinkKeywords(messages);
+ }
+
+ /** Validate transformation result */
+ validateTransformation(anthropicResponse: AnthropicResponse): ValidationResult {
const checks: Record = {
hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0),
hasThinking: anthropicResponse.content?.some((block) => block.type === 'thinking') || false,
@@ -723,400 +201,6 @@ export class GlmtTransformer {
return { checks, passed, total, valid: passed === total };
}
-
- /**
- * Transform OpenAI streaming delta to Anthropic events
- */
- transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
- const events: AnthropicSSEEvent[] = [];
-
- // Debug logging for streaming deltas
- if (this.debugLog && openaiEvent.data) {
- this.writeDebugLog('delta-openai', openaiEvent.data);
- }
-
- // Handle [DONE] marker
- if (openaiEvent.event === 'done') {
- if (!accumulator.isFinalized()) {
- return this.finalizeDelta(accumulator);
- }
- return []; // Already finalized
- }
-
- // Usage update (appears in final chunk, may be before choice data)
- if (openaiEvent.data?.usage) {
- accumulator.updateUsage(openaiEvent.data.usage);
-
- // If we have both usage AND finish_reason, finalize immediately
- if (accumulator.getFinishReason()) {
- events.push(...this.finalizeDelta(accumulator));
- return events;
- }
- }
-
- const choice = openaiEvent.data?.choices?.[0];
- if (!choice) return events;
-
- const delta = choice.delta;
- if (!delta) return events;
-
- // Message start
- if (!accumulator.isMessageStarted()) {
- if (openaiEvent.data?.model) {
- accumulator.setModel(openaiEvent.data.model);
- }
- events.push(this.createMessageStartEvent(accumulator));
- accumulator.setMessageStarted(true);
- }
-
- // Role
- if (delta.role) {
- accumulator.setRole(delta.role);
- }
-
- // Reasoning content delta
- if (delta.reasoning_content) {
- const currentBlock = accumulator.getCurrentBlock();
-
- if (this.debugMode) {
- console.error(`[GLMT-DEBUG] Reasoning delta: ${delta.reasoning_content.length} chars`);
- console.error(
- `[GLMT-DEBUG] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`
- );
- }
-
- if (!currentBlock || currentBlock.type !== 'thinking') {
- // Start thinking block
- const block = accumulator.startBlock('thinking');
- events.push(this.createContentBlockStartEvent(block));
-
- if (this.debugMode) {
- console.error(`[GLMT-DEBUG] Started new thinking block ${block.index}`);
- }
- }
-
- accumulator.addDelta(delta.reasoning_content);
- const currentThinkingBlock = accumulator.getCurrentBlock();
- if (currentThinkingBlock) {
- events.push(this.createThinkingDeltaEvent(currentThinkingBlock, delta.reasoning_content));
- }
- }
-
- // Text content delta
- if (delta.content) {
- const currentBlock = accumulator.getCurrentBlock();
-
- // Close thinking block if transitioning from thinking to text
- if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
- const signatureEvent = this.createSignatureDeltaEvent(currentBlock);
- if (signatureEvent) {
- events.push(signatureEvent);
- }
- events.push(this.createContentBlockStopEvent(currentBlock));
- accumulator.stopCurrentBlock();
- }
-
- if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock()?.type !== 'text') {
- // Start text block
- const block = accumulator.startBlock('text');
- events.push(this.createContentBlockStartEvent(block));
- }
-
- accumulator.addDelta(delta.content);
- const currentTextBlock = accumulator.getCurrentBlock();
- if (currentTextBlock) {
- events.push(this.createTextDeltaEvent(currentTextBlock, delta.content));
- }
- }
-
- // Check for planning loop
- if (accumulator.checkForLoop()) {
- this.log(
- 'WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls'
- );
- this.log('Forcing early finalization to prevent unbounded planning');
-
- // Close current block if any
- const currentBlock = accumulator.getCurrentBlock();
- if (currentBlock && !currentBlock.stopped) {
- if (currentBlock.type === 'thinking') {
- const signatureEvent = this.createSignatureDeltaEvent(currentBlock);
- if (signatureEvent) {
- events.push(signatureEvent);
- }
- }
- events.push(this.createContentBlockStopEvent(currentBlock));
- accumulator.stopCurrentBlock();
- }
-
- // Force finalization
- events.push(...this.finalizeDelta(accumulator));
- return events;
- }
-
- // Tool calls deltas
- if (delta.tool_calls && delta.tool_calls.length > 0) {
- // Close current content block ONCE before processing any tool calls
- const currentBlock = accumulator.getCurrentBlock();
- if (currentBlock && !currentBlock.stopped) {
- if (currentBlock.type === 'thinking') {
- const signatureEvent = this.createSignatureDeltaEvent(currentBlock);
- if (signatureEvent) {
- events.push(signatureEvent);
- }
- }
- events.push(this.createContentBlockStopEvent(currentBlock));
- accumulator.stopCurrentBlock();
- }
-
- // Process each tool call delta
- for (const toolCallDelta of delta.tool_calls) {
- // Track tool call state
- const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
- accumulator.addToolCallDelta(toolCallDelta);
-
- // Emit tool use events (start + input_json deltas)
- if (isNewToolCall) {
- // Start new tool_use block in accumulator
- const block = accumulator.startBlock('tool_use');
- const toolCall = accumulator.getToolCall(toolCallDelta.index);
-
- events.push({
- event: 'content_block_start',
- data: {
- type: 'content_block_start',
- index: block.index,
- content_block: {
- type: 'tool_use',
- id: toolCall?.id || `tool_${toolCallDelta.index}`,
- name: toolCall?.function?.name || '',
- },
- },
- });
- }
-
- // Emit input_json delta if arguments present
- if (toolCallDelta.function?.arguments) {
- const currentToolBlock = accumulator.getCurrentBlock();
- if (currentToolBlock && currentToolBlock.type === 'tool_use') {
- events.push({
- event: 'content_block_delta',
- data: {
- type: 'content_block_delta',
- index: currentToolBlock.index,
- delta: {
- type: 'input_json_delta',
- partial_json: toolCallDelta.function.arguments,
- },
- },
- });
- }
- }
- }
- }
-
- // Finish reason
- if (choice.finish_reason) {
- accumulator.setFinishReason(choice.finish_reason);
-
- // If we have both finish_reason AND usage, finalize immediately
- if (accumulator.hasUsageReceived()) {
- events.push(...this.finalizeDelta(accumulator));
- }
- }
-
- // Debug logging for generated events
- if (this.debugLog && events.length > 0) {
- this.writeDebugLog('delta-anthropic-events', {
- events,
- accumulator: accumulator.getSummary(),
- });
- }
-
- return events;
- }
-
- /**
- * Finalize streaming and generate closing events
- */
- finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
- if (accumulator.isFinalized()) {
- return []; // Already finalized
- }
-
- const events: AnthropicSSEEvent[] = [];
-
- // Close current content block if any
- const currentBlock = accumulator.getCurrentBlock();
- if (currentBlock && !currentBlock.stopped) {
- if (currentBlock.type === 'thinking') {
- const signatureEvent = this.createSignatureDeltaEvent(currentBlock);
- if (signatureEvent) {
- events.push(signatureEvent);
- }
- }
- events.push(this.createContentBlockStopEvent(currentBlock));
- accumulator.stopCurrentBlock();
- }
-
- // Message delta (stop reason + usage)
- events.push({
- event: 'message_delta',
- data: {
- type: 'message_delta',
- delta: {
- stop_reason: this.mapStopReason(accumulator.getFinishReason() || 'stop'),
- },
- usage: {
- input_tokens: accumulator.getInputTokens(),
- output_tokens: accumulator.getOutputTokens(),
- },
- },
- });
-
- // Message stop
- events.push({
- event: 'message_stop',
- data: {
- type: 'message_stop',
- },
- });
-
- accumulator.setFinalized(true);
- return events;
- }
-
- /**
- * Create message_start event
- */
- private createMessageStartEvent(accumulator: DeltaAccumulator): AnthropicSSEEvent {
- return {
- event: 'message_start',
- data: {
- type: 'message_start',
- message: {
- id: accumulator.getMessageId(),
- type: 'message',
- role: accumulator.getRole(),
- content: [],
- model: accumulator.getModel() || 'glm-4.6',
- stop_reason: null,
- usage: {
- input_tokens: accumulator.getInputTokens(),
- output_tokens: 0,
- },
- },
- },
- };
- }
-
- /**
- * Create content_block_start event
- */
- private createContentBlockStartEvent(block: AccumulatorBlock): AnthropicSSEEvent {
- return {
- event: 'content_block_start',
- data: {
- type: 'content_block_start',
- index: block.index,
- content_block: {
- type: block.type,
- [block.type]: '',
- },
- },
- };
- }
-
- /**
- * Create thinking_delta event
- */
- private createThinkingDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent {
- return {
- event: 'content_block_delta',
- data: {
- type: 'content_block_delta',
- index: block.index,
- delta: {
- type: 'thinking_delta',
- thinking: delta,
- },
- },
- };
- }
-
- /**
- * Create text_delta event
- */
- private createTextDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent {
- return {
- event: 'content_block_delta',
- data: {
- type: 'content_block_delta',
- index: block.index,
- delta: {
- type: 'text_delta',
- text: delta,
- },
- },
- };
- }
-
- /**
- * Create thinking signature delta event
- */
- private createSignatureDeltaEvent(block: AccumulatorBlock): AnthropicSSEEvent | null {
- // FIX: Guard against empty content (signature timing race)
- if (!block.content || block.content.length === 0) {
- if (this.verbose) {
- this.log(`WARNING: Skipping signature for empty thinking block ${block.index}`);
- this.log(
- `This indicates a race condition - signature requested before content accumulated`
- );
- }
- return null;
- }
-
- const signature = this.generateThinkingSignature(block.content);
-
- if (this.verbose) {
- this.log(`Generating signature for block ${block.index}: ${block.content.length} chars`);
- }
-
- return {
- event: 'content_block_delta',
- data: {
- type: 'content_block_delta',
- index: block.index,
- delta: {
- type: 'thinking_signature_delta',
- signature: signature,
- },
- },
- };
- }
-
- /**
- * Create content_block_stop event
- */
- private createContentBlockStopEvent(block: AccumulatorBlock): AnthropicSSEEvent {
- return {
- event: 'content_block_stop',
- data: {
- type: 'content_block_stop',
- index: block.index,
- },
- };
- }
-
- /**
- * Log message if verbose
- */
- private log(message: string): void {
- if (this.verbose) {
- const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
- console.error(`[glmt-transformer] [${timestamp}] ${message}`);
- }
- }
}
export default GlmtTransformer;
diff --git a/src/glmt/index.ts b/src/glmt/index.ts
new file mode 100644
index 00000000..772a49b1
--- /dev/null
+++ b/src/glmt/index.ts
@@ -0,0 +1,21 @@
+/**
+ * GLMT (GLM Thinking) Module Barrel Export
+ *
+ * Provides OpenAI-to-Anthropic protocol translation for GLM models with
+ * extended thinking support.
+ */
+
+// Core proxy and transformer
+export { GlmtProxy } from './glmt-proxy';
+export { GlmtTransformer } from './glmt-transformer';
+
+// Streaming utilities
+export { SSEParser } from './sse-parser';
+export { DeltaAccumulator } from './delta-accumulator';
+
+// Content enforcers
+export { LocaleEnforcer } from './locale-enforcer';
+export { ReasoningEnforcer } from './reasoning-enforcer';
+
+// Pipeline components and types
+export * from './pipeline';
diff --git a/src/glmt/pipeline/content-transformer.ts b/src/glmt/pipeline/content-transformer.ts
new file mode 100644
index 00000000..21b67533
--- /dev/null
+++ b/src/glmt/pipeline/content-transformer.ts
@@ -0,0 +1,183 @@
+/**
+ * ContentTransformer - Handle message sanitization and content transformations
+ *
+ * Responsibilities:
+ * - Sanitize messages for OpenAI API compatibility
+ * - Extract thinking control tags from messages
+ * - Detect think keywords in user prompts
+ * - Transform tools between Anthropic and OpenAI formats
+ */
+
+import type { Message, ContentBlock, AnthropicTool, OpenAITool, ThinkingConfig } from './types';
+
+export class ContentTransformer {
+ private defaultThinking: boolean;
+
+ constructor(defaultThinking = true) {
+ this.defaultThinking = defaultThinking;
+ }
+
+ /**
+ * Sanitize messages for OpenAI API compatibility
+ */
+ sanitizeMessages(messages: Message[]): Message[] {
+ const result: Message[] = [];
+
+ for (const msg of messages) {
+ // If content is a string, add as-is
+ if (typeof msg.content === 'string') {
+ result.push(msg);
+ continue;
+ }
+
+ // If content is an array, process blocks
+ if (Array.isArray(msg.content)) {
+ // Separate tool_result blocks from other content
+ const toolResults = msg.content.filter((block) => block.type === 'tool_result');
+ const textBlocks = msg.content.filter((block) => block.type === 'text');
+
+ // CRITICAL: Tool messages must come BEFORE user text in OpenAI API
+ for (const toolResult of toolResults) {
+ result.push({
+ role: 'tool',
+ content:
+ typeof toolResult.content === 'string'
+ ? toolResult.content
+ : JSON.stringify(toolResult.content),
+ } as Message & { tool_call_id: string });
+ }
+
+ // Add text content as user/assistant message AFTER tool messages
+ if (textBlocks.length > 0) {
+ const textContent =
+ textBlocks.length === 1
+ ? textBlocks[0].text || ''
+ : textBlocks.map((b) => b.text || '').join('\n');
+
+ result.push({
+ role: msg.role,
+ content: textContent,
+ });
+ }
+
+ // If no content at all, add empty message
+ if (textBlocks.length === 0 && toolResults.length === 0) {
+ result.push({
+ role: msg.role,
+ content: '',
+ });
+ }
+
+ continue;
+ }
+
+ // Fallback: return message as-is
+ result.push(msg);
+ }
+
+ return result;
+ }
+
+ /**
+ * Transform Anthropic tools to OpenAI tools format
+ */
+ transformTools(anthropicTools: AnthropicTool[]): OpenAITool[] {
+ return anthropicTools.map((tool) => ({
+ type: 'function' as const,
+ function: {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.input_schema || {},
+ },
+ }));
+ }
+
+ /**
+ * Check if messages contain thinking control tags
+ */
+ hasThinkingTags(messages: Message[]): boolean {
+ for (const msg of messages) {
+ if (msg.role !== 'user') continue;
+ const content = msg.content;
+ if (typeof content !== 'string') continue;
+
+ // Check for control tags
+ if (//i.test(content) || //i.test(content)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Extract thinking control tags from user messages
+ */
+ extractThinkingControl(messages: Message[]): ThinkingConfig {
+ const config: ThinkingConfig = {
+ thinking: this.defaultThinking,
+ effort: 'medium',
+ };
+
+ // Scan user messages for control tags
+ for (const msg of messages) {
+ if (msg.role !== 'user') continue;
+
+ const content = msg.content;
+ if (typeof content !== 'string') continue;
+
+ // Check for
+ const thinkingMatch = content.match(//i);
+ if (thinkingMatch) {
+ config.thinking = thinkingMatch[1].toLowerCase() === 'on';
+ }
+
+ // Check for
+ const effortMatch = content.match(//i);
+ if (effortMatch) {
+ config.effort = effortMatch[1].toLowerCase();
+ }
+ }
+
+ return config;
+ }
+
+ /**
+ * Detect Anthropic-style "think" keywords in user prompts
+ */
+ detectThinkKeywords(
+ messages: Message[]
+ ): { thinking: boolean; effort: string; keyword: string } | null {
+ if (!messages || messages.length === 0) return null;
+
+ // Extract text from user messages
+ const text = messages
+ .filter((m) => m.role === 'user')
+ .map((m) => {
+ if (typeof m.content === 'string') return m.content;
+ if (Array.isArray(m.content)) {
+ return (m.content as ContentBlock[])
+ .filter((block) => block.type === 'text')
+ .map((block) => block.text || '')
+ .join(' ');
+ }
+ return '';
+ })
+ .join(' ');
+
+ // Priority: ultrathink > think harder > think hard > think
+ if (/\bultrathink\b/i.test(text)) {
+ return { thinking: true, effort: 'max', keyword: 'ultrathink' };
+ }
+ if (/\bthink\s+harder\b/i.test(text)) {
+ return { thinking: true, effort: 'high', keyword: 'think harder' };
+ }
+ if (/\bthink\s+hard\b/i.test(text)) {
+ return { thinking: true, effort: 'medium', keyword: 'think hard' };
+ }
+ if (/\bthink\b/i.test(text)) {
+ return { thinking: true, effort: 'low', keyword: 'think' };
+ }
+
+ return null; // No keywords detected
+ }
+}
diff --git a/src/glmt/pipeline/index.ts b/src/glmt/pipeline/index.ts
new file mode 100644
index 00000000..7b157513
--- /dev/null
+++ b/src/glmt/pipeline/index.ts
@@ -0,0 +1,35 @@
+/**
+ * Pipeline Module Exports
+ *
+ * Barrel file for the GLMT transformation pipeline
+ */
+
+// Types
+export type {
+ ContentBlock,
+ Message,
+ AnthropicTool,
+ OpenAITool,
+ OpenAIToolCall,
+ OpenAIToolCallDelta,
+ AnthropicRequest,
+ OpenAIRequest,
+ ThinkingConfig,
+ TransformResult,
+ ThinkingSignature,
+ OpenAIChoice,
+ OpenAIResponse,
+ AnthropicResponse,
+ SSEEvent,
+ AnthropicSSEEvent,
+ AccumulatorBlock,
+ ValidationResult,
+ GlmtTransformerConfig,
+} from './types';
+
+// Pipeline components
+export { ContentTransformer } from './content-transformer';
+export { ToolCallHandler } from './tool-call-handler';
+export { ResponseBuilder } from './response-builder';
+export { StreamParser, type StreamParserConfig } from './stream-parser';
+export { RequestTransformer, type RequestTransformerConfig } from './request-transformer';
diff --git a/src/glmt/pipeline/request-transformer.ts b/src/glmt/pipeline/request-transformer.ts
new file mode 100644
index 00000000..98a08ea9
--- /dev/null
+++ b/src/glmt/pipeline/request-transformer.ts
@@ -0,0 +1,161 @@
+/**
+ * RequestTransformer - Handle Anthropic → OpenAI request transformation
+ *
+ * Responsibilities:
+ * - Transform Anthropic request format to OpenAI format
+ * - Inject locale and reasoning instructions
+ * - Map models and configure parameters
+ */
+
+import { LocaleEnforcer } from '../locale-enforcer';
+import { ReasoningEnforcer } from '../reasoning-enforcer';
+import { ContentTransformer } from './content-transformer';
+import type {
+ Message,
+ AnthropicRequest,
+ OpenAIRequest,
+ ThinkingConfig,
+ TransformResult,
+} from './types';
+
+export interface RequestTransformerConfig {
+ defaultThinking?: boolean;
+ verbose?: boolean;
+ explicitReasoning?: boolean;
+ log?: (message: string) => void;
+}
+
+export class RequestTransformer {
+ private localeEnforcer: LocaleEnforcer;
+ private reasoningEnforcer: ReasoningEnforcer;
+ private contentTransformer: ContentTransformer;
+ private log: (message: string) => void;
+ private modelMaxTokens: Record;
+
+ constructor(config: RequestTransformerConfig = {}) {
+ const defaultThinking = config.defaultThinking ?? true;
+ this.log = config.log || (() => {});
+
+ this.localeEnforcer = new LocaleEnforcer();
+ this.reasoningEnforcer = new ReasoningEnforcer({
+ enabled: config.explicitReasoning ?? true,
+ });
+ this.contentTransformer = new ContentTransformer(defaultThinking);
+
+ this.modelMaxTokens = {
+ 'GLM-4.6': 128000,
+ 'GLM-4.5': 96000,
+ 'GLM-4.5-air': 16000,
+ };
+ }
+
+ /**
+ * Transform Anthropic request to OpenAI format
+ */
+ transform(anthropicRequest: AnthropicRequest): TransformResult {
+ try {
+ const messages = anthropicRequest.messages || [];
+
+ // 1. Extract thinking control from messages
+ const thinkingConfig = this.contentTransformer.extractThinkingControl(messages);
+ const hasControlTags = this.contentTransformer.hasThinkingTags(messages);
+
+ // 2. Detect "think" keywords in user prompts
+ const keywordConfig = this.contentTransformer.detectThinkKeywords(messages);
+ if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) {
+ thinkingConfig.thinking = keywordConfig.thinking;
+ thinkingConfig.effort = keywordConfig.effort;
+ this.log(
+ `Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}`
+ );
+ }
+
+ // 3. Check anthropicRequest.thinking parameter (takes precedence)
+ if (anthropicRequest.thinking) {
+ if (anthropicRequest.thinking.type === 'enabled') {
+ thinkingConfig.thinking = true;
+ this.log('Claude CLI explicitly enabled thinking');
+ } else if (anthropicRequest.thinking.type === 'disabled') {
+ thinkingConfig.thinking = false;
+ this.log('Claude CLI explicitly disabled thinking');
+ }
+ }
+
+ this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`);
+
+ // 4. Map model
+ const glmModel = this.mapModel(anthropicRequest.model);
+
+ // 5. Inject locale instruction
+ const messagesWithLocale = this.localeEnforcer.injectInstruction(
+ messages as Parameters[0]
+ ) as unknown as Message[];
+
+ // 6. Inject reasoning instruction
+ const messagesWithReasoning = this.reasoningEnforcer.injectInstruction(
+ messagesWithLocale as unknown as Parameters<
+ typeof this.reasoningEnforcer.injectInstruction
+ >[0],
+ thinkingConfig
+ ) as unknown as Message[];
+
+ // 7. Build OpenAI request
+ const openaiRequest: OpenAIRequest = {
+ model: glmModel,
+ messages: this.contentTransformer.sanitizeMessages(messagesWithReasoning),
+ max_tokens: this.getMaxTokens(glmModel),
+ stream: anthropicRequest.stream ?? false,
+ };
+
+ // 8. Transform tools if present
+ if (anthropicRequest.tools && anthropicRequest.tools.length > 0) {
+ openaiRequest.tools = this.contentTransformer.transformTools(anthropicRequest.tools);
+ openaiRequest.tool_choice = 'auto';
+ this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`);
+ }
+
+ // 9. Preserve optional parameters
+ if (anthropicRequest.temperature !== undefined) {
+ openaiRequest.temperature = anthropicRequest.temperature;
+ }
+ if (anthropicRequest.top_p !== undefined) {
+ openaiRequest.top_p = anthropicRequest.top_p;
+ }
+ if (anthropicRequest.stream !== undefined) {
+ openaiRequest.stream = anthropicRequest.stream;
+ }
+
+ // 10. Inject reasoning parameters
+ this.injectReasoningParams(openaiRequest, thinkingConfig);
+
+ return { openaiRequest, thinkingConfig };
+ } catch (error) {
+ const err = error as Error;
+ console.error('[RequestTransformer] Transformation error:', err);
+ return {
+ openaiRequest: anthropicRequest,
+ thinkingConfig: { thinking: false, effort: 'medium' },
+ error: err.message,
+ };
+ }
+ }
+
+ private injectReasoningParams(
+ openaiRequest: OpenAIRequest,
+ thinkingConfig: ThinkingConfig
+ ): void {
+ openaiRequest.do_sample = true;
+ if (thinkingConfig.thinking) {
+ openaiRequest.reasoning = true;
+ openaiRequest.reasoning_effort = thinkingConfig.effort;
+ }
+ }
+
+ private mapModel(_anthropicModel: string): string {
+ return 'GLM-4.6';
+ }
+
+ private getMaxTokens(model: string): number {
+ return this.modelMaxTokens[model] || 128000;
+ }
+}
diff --git a/src/glmt/pipeline/response-builder.ts b/src/glmt/pipeline/response-builder.ts
new file mode 100644
index 00000000..416a034f
--- /dev/null
+++ b/src/glmt/pipeline/response-builder.ts
@@ -0,0 +1,202 @@
+/**
+ * ResponseBuilder - Create SSE events for Anthropic streaming format
+ *
+ * Responsibilities:
+ * - Create message_start, message_delta, message_stop events
+ * - Create content_block_start, content_block_delta, content_block_stop events
+ * - Generate thinking signatures
+ * - Map stop reasons between formats
+ */
+
+import * as crypto from 'crypto';
+import type { DeltaAccumulator } from '../delta-accumulator';
+import type { AccumulatorBlock, AnthropicSSEEvent, ThinkingSignature } from './types';
+
+export class ResponseBuilder {
+ private verbose: boolean;
+
+ constructor(verbose = false) {
+ this.verbose = verbose;
+ }
+
+ /**
+ * Create message_start event
+ */
+ createMessageStartEvent(accumulator: DeltaAccumulator): AnthropicSSEEvent {
+ return {
+ event: 'message_start',
+ data: {
+ type: 'message_start',
+ message: {
+ id: accumulator.getMessageId(),
+ type: 'message',
+ role: accumulator.getRole(),
+ content: [],
+ model: accumulator.getModel() || 'glm-4.6',
+ stop_reason: null,
+ usage: {
+ input_tokens: accumulator.getInputTokens(),
+ output_tokens: 0,
+ },
+ },
+ },
+ };
+ }
+
+ /**
+ * Create content_block_start event
+ */
+ createContentBlockStartEvent(block: AccumulatorBlock): AnthropicSSEEvent {
+ return {
+ event: 'content_block_start',
+ data: {
+ type: 'content_block_start',
+ index: block.index,
+ content_block: {
+ type: block.type,
+ [block.type]: '',
+ },
+ },
+ };
+ }
+
+ /**
+ * Create thinking_delta event
+ */
+ createThinkingDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent {
+ return {
+ event: 'content_block_delta',
+ data: {
+ type: 'content_block_delta',
+ index: block.index,
+ delta: {
+ type: 'thinking_delta',
+ thinking: delta,
+ },
+ },
+ };
+ }
+
+ /**
+ * Create text_delta event
+ */
+ createTextDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent {
+ return {
+ event: 'content_block_delta',
+ data: {
+ type: 'content_block_delta',
+ index: block.index,
+ delta: {
+ type: 'text_delta',
+ text: delta,
+ },
+ },
+ };
+ }
+
+ /**
+ * Create thinking signature delta event
+ */
+ createSignatureDeltaEvent(block: AccumulatorBlock): AnthropicSSEEvent | null {
+ // FIX: Guard against empty content (signature timing race)
+ if (!block.content || block.content.length === 0) {
+ if (this.verbose) {
+ console.error(
+ `[ResponseBuilder] WARNING: Skipping signature for empty thinking block ${block.index}`
+ );
+ console.error(
+ `This indicates a race condition - signature requested before content accumulated`
+ );
+ }
+ return null;
+ }
+
+ const signature = this.generateThinkingSignature(block.content);
+
+ if (this.verbose) {
+ console.error(
+ `[ResponseBuilder] Generating signature for block ${block.index}: ${block.content.length} chars`
+ );
+ }
+
+ return {
+ event: 'content_block_delta',
+ data: {
+ type: 'content_block_delta',
+ index: block.index,
+ delta: {
+ type: 'thinking_signature_delta',
+ signature: signature,
+ },
+ },
+ };
+ }
+
+ /**
+ * Create content_block_stop event
+ */
+ createContentBlockStopEvent(block: AccumulatorBlock): AnthropicSSEEvent {
+ return {
+ event: 'content_block_stop',
+ data: {
+ type: 'content_block_stop',
+ index: block.index,
+ },
+ };
+ }
+
+ /**
+ * Generate finalization events (message_delta + message_stop)
+ */
+ createFinalizationEvents(accumulator: DeltaAccumulator, stopReason: string): AnthropicSSEEvent[] {
+ return [
+ {
+ event: 'message_delta',
+ data: {
+ type: 'message_delta',
+ delta: {
+ stop_reason: stopReason,
+ },
+ usage: {
+ input_tokens: accumulator.getInputTokens(),
+ output_tokens: accumulator.getOutputTokens(),
+ },
+ },
+ },
+ {
+ event: 'message_stop',
+ data: {
+ type: 'message_stop',
+ },
+ },
+ ];
+ }
+
+ /**
+ * Generate thinking signature for Claude Code UI
+ */
+ generateThinkingSignature(thinking: string): ThinkingSignature {
+ // Generate signature hash
+ const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16);
+
+ return {
+ type: 'thinking_signature',
+ hash: hash,
+ length: thinking.length,
+ timestamp: Date.now(),
+ };
+ }
+
+ /**
+ * Map OpenAI stop reason to Anthropic stop reason
+ */
+ mapStopReason(openaiReason: string): string {
+ const mapping: Record = {
+ stop: 'end_turn',
+ length: 'max_tokens',
+ tool_calls: 'tool_use',
+ content_filter: 'stop_sequence',
+ };
+ return mapping[openaiReason] || 'end_turn';
+ }
+}
diff --git a/src/glmt/pipeline/stream-parser.ts b/src/glmt/pipeline/stream-parser.ts
new file mode 100644
index 00000000..c0b6fc71
--- /dev/null
+++ b/src/glmt/pipeline/stream-parser.ts
@@ -0,0 +1,308 @@
+/**
+ * StreamParser - Transform OpenAI streaming deltas to Anthropic SSE events
+ *
+ * Responsibilities:
+ * - Process streaming deltas (reasoning_content, content, tool_calls)
+ * - Coordinate with accumulator for state tracking
+ * - Detect and handle planning loops
+ * - Generate appropriate Anthropic SSE events
+ */
+
+import type { DeltaAccumulator } from '../delta-accumulator';
+import type { SSEEvent, AnthropicSSEEvent, AccumulatorBlock } from './types';
+import { ResponseBuilder } from './response-builder';
+import { ToolCallHandler } from './tool-call-handler';
+
+export interface StreamParserConfig {
+ verbose?: boolean;
+ debugMode?: boolean;
+ debugLog?: boolean;
+ writeDebugLog?: (type: string, data: unknown) => void;
+}
+
+export class StreamParser {
+ private verbose: boolean;
+ private debugMode: boolean;
+ private debugLog: boolean;
+ private responseBuilder: ResponseBuilder;
+ private toolCallHandler: ToolCallHandler;
+ private writeDebugLog: (type: string, data: unknown) => void;
+
+ constructor(config: StreamParserConfig = {}) {
+ this.verbose = config.verbose || false;
+ this.debugMode = config.debugMode || false;
+ this.debugLog = config.debugLog || false;
+ this.responseBuilder = new ResponseBuilder(this.verbose);
+ this.toolCallHandler = new ToolCallHandler();
+ this.writeDebugLog = config.writeDebugLog || (() => {});
+ }
+
+ /**
+ * Transform OpenAI streaming delta to Anthropic events
+ */
+ transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+
+ // Debug logging for streaming deltas
+ if (this.debugLog && openaiEvent.data) {
+ this.writeDebugLog('delta-openai', openaiEvent.data);
+ }
+
+ // Handle [DONE] marker
+ if (openaiEvent.event === 'done') {
+ if (!accumulator.isFinalized()) {
+ return this.finalizeDelta(accumulator);
+ }
+ return []; // Already finalized
+ }
+
+ // Usage update (appears in final chunk, may be before choice data)
+ if (openaiEvent.data?.usage) {
+ accumulator.updateUsage(openaiEvent.data.usage);
+
+ // If we have both usage AND finish_reason, finalize immediately
+ if (accumulator.getFinishReason()) {
+ events.push(...this.finalizeDelta(accumulator));
+ return events;
+ }
+ }
+
+ const choice = openaiEvent.data?.choices?.[0];
+ if (!choice) return events;
+
+ const delta = choice.delta;
+ if (!delta) return events;
+
+ // Message start
+ if (!accumulator.isMessageStarted()) {
+ if (openaiEvent.data?.model) {
+ accumulator.setModel(openaiEvent.data.model);
+ }
+ events.push(this.responseBuilder.createMessageStartEvent(accumulator));
+ accumulator.setMessageStarted(true);
+ }
+
+ // Role
+ if (delta.role) {
+ accumulator.setRole(delta.role);
+ }
+
+ // Reasoning content delta
+ if (delta.reasoning_content) {
+ events.push(...this.handleReasoningDelta(delta.reasoning_content, accumulator));
+ }
+
+ // Text content delta
+ if (delta.content) {
+ events.push(...this.handleContentDelta(delta.content, accumulator));
+ }
+
+ // Check for planning loop
+ if (accumulator.checkForLoop()) {
+ this.log(
+ 'WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls'
+ );
+ this.log('Forcing early finalization to prevent unbounded planning');
+ events.push(...this.forceFinalization(accumulator));
+ return events;
+ }
+
+ // Tool calls deltas
+ if (delta.tool_calls && delta.tool_calls.length > 0) {
+ events.push(...this.handleToolCallDeltas(delta.tool_calls, accumulator));
+ }
+
+ // Finish reason
+ if (choice.finish_reason) {
+ accumulator.setFinishReason(choice.finish_reason);
+
+ // If we have both finish_reason AND usage, finalize immediately
+ if (accumulator.hasUsageReceived()) {
+ events.push(...this.finalizeDelta(accumulator));
+ }
+ }
+
+ // Debug logging for generated events
+ if (this.debugLog && events.length > 0) {
+ this.writeDebugLog('delta-anthropic-events', {
+ events,
+ accumulator: accumulator.getSummary(),
+ });
+ }
+
+ return events;
+ }
+
+ /**
+ * Handle reasoning content delta
+ */
+ private handleReasoningDelta(
+ reasoningContent: string,
+ accumulator: DeltaAccumulator
+ ): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+ const currentBlock = accumulator.getCurrentBlock();
+
+ if (this.debugMode) {
+ console.error(`[StreamParser] Reasoning delta: ${reasoningContent.length} chars`);
+ console.error(
+ `[StreamParser] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`
+ );
+ }
+
+ if (!currentBlock || currentBlock.type !== 'thinking') {
+ // Start thinking block
+ const block = accumulator.startBlock('thinking');
+ events.push(this.responseBuilder.createContentBlockStartEvent(block));
+
+ if (this.debugMode) {
+ console.error(`[StreamParser] Started new thinking block ${block.index}`);
+ }
+ }
+
+ accumulator.addDelta(reasoningContent);
+ const currentThinkingBlock = accumulator.getCurrentBlock();
+ if (currentThinkingBlock) {
+ events.push(
+ this.responseBuilder.createThinkingDeltaEvent(currentThinkingBlock, reasoningContent)
+ );
+ }
+
+ return events;
+ }
+
+ /**
+ * Handle content delta
+ */
+ private handleContentDelta(content: string, accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+ const currentBlock = accumulator.getCurrentBlock();
+
+ // Close thinking block if transitioning from thinking to text
+ if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
+ events.push(...this.closeThinkingBlock(currentBlock, accumulator));
+ }
+
+ if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock()?.type !== 'text') {
+ // Start text block
+ const block = accumulator.startBlock('text');
+ events.push(this.responseBuilder.createContentBlockStartEvent(block));
+ }
+
+ accumulator.addDelta(content);
+ const currentTextBlock = accumulator.getCurrentBlock();
+ if (currentTextBlock) {
+ events.push(this.responseBuilder.createTextDeltaEvent(currentTextBlock, content));
+ }
+
+ return events;
+ }
+
+ /**
+ * Handle tool call deltas
+ */
+ private handleToolCallDeltas(
+ toolCallDeltas: import('./types').OpenAIToolCallDelta[],
+ accumulator: DeltaAccumulator
+ ): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+
+ if (!toolCallDeltas) return events;
+
+ // Close current content block ONCE before processing any tool calls
+ const currentBlock = accumulator.getCurrentBlock();
+ if (currentBlock && !currentBlock.stopped) {
+ if (currentBlock.type === 'thinking') {
+ events.push(...this.closeThinkingBlock(currentBlock, accumulator));
+ } else {
+ events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
+ accumulator.stopCurrentBlock();
+ }
+ }
+
+ // Process tool call deltas
+ events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
+
+ return events;
+ }
+
+ /**
+ * Close thinking block with signature
+ */
+ private closeThinkingBlock(
+ block: AccumulatorBlock,
+ accumulator: DeltaAccumulator
+ ): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+
+ const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
+ if (signatureEvent) {
+ events.push(signatureEvent);
+ }
+ events.push(this.responseBuilder.createContentBlockStopEvent(block));
+ accumulator.stopCurrentBlock();
+
+ return events;
+ }
+
+ /**
+ * Force finalization due to loop detection
+ */
+ private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+
+ // Close current block if any
+ const currentBlock = accumulator.getCurrentBlock();
+ if (currentBlock && !currentBlock.stopped) {
+ if (currentBlock.type === 'thinking') {
+ events.push(...this.closeThinkingBlock(currentBlock, accumulator));
+ } else {
+ events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
+ accumulator.stopCurrentBlock();
+ }
+ }
+
+ // Force finalization
+ events.push(...this.finalizeDelta(accumulator));
+ return events;
+ }
+
+ /**
+ * Finalize streaming and generate closing events
+ */
+ finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
+ if (accumulator.isFinalized()) {
+ return []; // Already finalized
+ }
+
+ const events: AnthropicSSEEvent[] = [];
+
+ // Close current content block if any
+ const currentBlock = accumulator.getCurrentBlock();
+ if (currentBlock && !currentBlock.stopped) {
+ if (currentBlock.type === 'thinking') {
+ events.push(...this.closeThinkingBlock(currentBlock, accumulator));
+ } else {
+ events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
+ accumulator.stopCurrentBlock();
+ }
+ }
+
+ // Message delta (stop reason + usage) and message stop
+ const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
+ events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
+
+ accumulator.setFinalized(true);
+ return events;
+ }
+
+ /**
+ * Log message if verbose
+ */
+ private log(message: string): void {
+ if (this.verbose) {
+ const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
+ console.error(`[StreamParser] [${timestamp}] ${message}`);
+ }
+ }
+}
diff --git a/src/glmt/pipeline/tool-call-handler.ts b/src/glmt/pipeline/tool-call-handler.ts
new file mode 100644
index 00000000..ffe0d670
--- /dev/null
+++ b/src/glmt/pipeline/tool-call-handler.ts
@@ -0,0 +1,97 @@
+/**
+ * ToolCallHandler - Handle tool call processing for streaming responses
+ *
+ * Responsibilities:
+ * - Process tool call deltas from OpenAI
+ * - Generate tool_use content blocks for Anthropic format
+ * - Handle input_json_delta events
+ */
+
+import type { DeltaAccumulator } from '../delta-accumulator';
+import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
+
+export class ToolCallHandler {
+ /**
+ * Process tool calls from non-streaming response
+ */
+ processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
+ const content: ContentBlock[] = [];
+
+ for (const toolCall of toolCalls) {
+ let parsedInput: Record;
+ try {
+ parsedInput = JSON.parse(toolCall.function.arguments || '{}');
+ } catch (parseError) {
+ const err = parseError as Error;
+ console.error(`[ToolCallHandler] Invalid JSON in tool arguments: ${err.message}`);
+ parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments };
+ }
+
+ content.push({
+ type: 'tool_use',
+ id: toolCall.id,
+ name: toolCall.function.name,
+ input: parsedInput,
+ });
+ }
+
+ return content;
+ }
+
+ /**
+ * Process tool call deltas during streaming
+ * Returns events for tool use blocks and input_json deltas
+ */
+ processToolCallDeltas(
+ toolCallDeltas: OpenAIToolCallDelta[],
+ accumulator: DeltaAccumulator
+ ): AnthropicSSEEvent[] {
+ const events: AnthropicSSEEvent[] = [];
+
+ for (const toolCallDelta of toolCallDeltas) {
+ // Track tool call state
+ const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
+ accumulator.addToolCallDelta(toolCallDelta);
+
+ // Emit tool use events (start + input_json deltas)
+ if (isNewToolCall) {
+ // Start new tool_use block in accumulator
+ const block = accumulator.startBlock('tool_use');
+ const toolCall = accumulator.getToolCall(toolCallDelta.index);
+
+ events.push({
+ event: 'content_block_start',
+ data: {
+ type: 'content_block_start',
+ index: block.index,
+ content_block: {
+ type: 'tool_use',
+ id: toolCall?.id || `tool_${toolCallDelta.index}`,
+ name: toolCall?.function?.name || '',
+ },
+ },
+ });
+ }
+
+ // Emit input_json delta if arguments present
+ if (toolCallDelta.function?.arguments) {
+ const currentToolBlock = accumulator.getCurrentBlock();
+ if (currentToolBlock && currentToolBlock.type === 'tool_use') {
+ events.push({
+ event: 'content_block_delta',
+ data: {
+ type: 'content_block_delta',
+ index: currentToolBlock.index,
+ delta: {
+ type: 'input_json_delta',
+ partial_json: toolCallDelta.function.arguments,
+ },
+ },
+ });
+ }
+ }
+ }
+
+ return events;
+ }
+}
diff --git a/src/glmt/pipeline/types.ts b/src/glmt/pipeline/types.ts
new file mode 100644
index 00000000..27f68749
--- /dev/null
+++ b/src/glmt/pipeline/types.ts
@@ -0,0 +1,185 @@
+/**
+ * Pipeline Types - Shared types for GLMT transformation pipeline
+ */
+
+// Content block types
+export interface ContentBlock {
+ type: string;
+ text?: string;
+ thinking?: string;
+ signature?: ThinkingSignature;
+ id?: string;
+ name?: string;
+ input?: Record;
+ tool_use_id?: string;
+ content?: string | unknown;
+}
+
+export interface Message {
+ role: string;
+ content: string | ContentBlock[];
+}
+
+// Tool types
+export interface AnthropicTool {
+ name: string;
+ description: string;
+ input_schema: Record;
+}
+
+export interface OpenAITool {
+ type: 'function';
+ function: {
+ name: string;
+ description: string;
+ parameters: Record;
+ };
+}
+
+export interface OpenAIToolCall {
+ id: string;
+ type: string;
+ function: {
+ name: string;
+ arguments: string;
+ };
+}
+
+export interface OpenAIToolCallDelta {
+ index: number;
+ id?: string;
+ type?: string;
+ function?: {
+ name?: string;
+ arguments?: string;
+ };
+}
+
+// Request/Response types
+export interface AnthropicRequest {
+ model: string;
+ messages: Message[];
+ max_tokens?: number;
+ temperature?: number;
+ top_p?: number;
+ tools?: AnthropicTool[];
+ stream?: boolean;
+ thinking?: {
+ type: 'enabled' | 'disabled';
+ budget_tokens?: number;
+ };
+}
+
+export interface OpenAIRequest {
+ model: string;
+ messages: Message[];
+ max_tokens?: number;
+ temperature?: number;
+ top_p?: number;
+ tools?: OpenAITool[];
+ tool_choice?: string;
+ stream?: boolean;
+ do_sample?: boolean;
+ reasoning?: boolean;
+ reasoning_effort?: string;
+}
+
+export interface ThinkingConfig {
+ thinking: boolean;
+ effort: string;
+}
+
+export interface TransformResult {
+ openaiRequest: OpenAIRequest | AnthropicRequest;
+ thinkingConfig: ThinkingConfig;
+ error?: string;
+}
+
+export interface ThinkingSignature {
+ type: string;
+ hash: string;
+ length: number;
+ timestamp: number;
+}
+
+// OpenAI response types
+export interface OpenAIChoice {
+ message: {
+ role?: string;
+ content?: string | null;
+ reasoning_content?: string;
+ tool_calls?: OpenAIToolCall[];
+ };
+ delta?: {
+ role?: string;
+ content?: string;
+ reasoning_content?: string;
+ tool_calls?: OpenAIToolCallDelta[];
+ };
+ finish_reason?: string;
+}
+
+export interface OpenAIResponse {
+ id?: string;
+ model?: string;
+ choices?: OpenAIChoice[];
+ usage?: {
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ };
+}
+
+export interface AnthropicResponse {
+ id: string;
+ type: string;
+ role: string;
+ content: ContentBlock[];
+ model: string;
+ stop_reason: string;
+ usage: {
+ input_tokens: number;
+ output_tokens: number;
+ };
+}
+
+// SSE event types
+export interface SSEEvent {
+ event?: string;
+ data?: OpenAIResponse & {
+ usage?: {
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ };
+ };
+}
+
+export interface AnthropicSSEEvent {
+ event: string;
+ data: Record;
+}
+
+// Accumulator block type
+export interface AccumulatorBlock {
+ index: number;
+ type: string;
+ content: string;
+ stopped: boolean;
+}
+
+// Validation result
+export interface ValidationResult {
+ checks: Record;
+ passed: number;
+ total: number;
+ valid: boolean;
+}
+
+// Config types
+export interface GlmtTransformerConfig {
+ defaultThinking?: boolean;
+ verbose?: boolean;
+ debugLog?: boolean;
+ debugMode?: boolean;
+ debugLogDir?: string;
+ explicitReasoning?: boolean;
+}
diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts
new file mode 100644
index 00000000..2ad57d1d
--- /dev/null
+++ b/src/management/checks/cliproxy-check.ts
@@ -0,0 +1,199 @@
+/**
+ * CLIProxy Health Checks - Binary, config, auth, and port status
+ */
+
+import * as fs from 'fs';
+import { ok, warn, info } from '../../utils/ui';
+import {
+ isCLIProxyInstalled,
+ getCLIProxyPath,
+ getAllAuthStatus,
+ getConfigPath,
+ getInstalledCliproxyVersion,
+ CLIPROXY_DEFAULT_PORT,
+ configNeedsRegeneration,
+ regenerateConfig,
+ CLIPROXY_CONFIG_VERSION,
+} from '../../cliproxy';
+import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check CLIProxy binary installation
+ */
+export class CLIProxyBinaryChecker implements IHealthChecker {
+ name = 'CLIProxy Binary';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking CLIProxy binary').start();
+
+ if (isCLIProxyInstalled()) {
+ const binaryPath = getCLIProxyPath();
+ const installedVersion = getInstalledCliproxyVersion();
+ spinner.succeed();
+ console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`);
+ results.addCheck('CLIProxy Binary', 'success', undefined, undefined, {
+ status: 'OK',
+ info: `v${installedVersion} (${binaryPath})`,
+ });
+ } else {
+ spinner.info();
+ console.log(
+ ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)`
+ );
+ results.addCheck(
+ 'CLIProxy Binary',
+ 'success',
+ 'Not installed yet',
+ 'Run: ccs gemini "test" (will download automatically)',
+ { status: 'OK', info: 'Not installed (downloads on first use)' }
+ );
+ }
+ }
+}
+
+/**
+ * Check CLIProxy config file
+ */
+export class CLIProxyConfigChecker implements IHealthChecker {
+ name = 'CLIProxy Config';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking CLIProxy config').start();
+ const configPath = getConfigPath();
+
+ if (fs.existsSync(configPath)) {
+ // Check if config needs regeneration (version mismatch or missing features)
+ if (configNeedsRegeneration()) {
+ spinner.warn();
+ console.log(
+ ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...`
+ );
+
+ // Regenerate config with new features
+ regenerateConfig();
+
+ console.log(
+ ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}`
+ );
+ results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
+ status: 'OK',
+ info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`,
+ });
+ } else {
+ spinner.succeed();
+ console.log(
+ ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`
+ );
+ results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
+ status: 'OK',
+ info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`,
+ });
+ }
+ } else {
+ spinner.info();
+ console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
+ results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, {
+ status: 'OK',
+ info: 'Generated on first use',
+ });
+ }
+ }
+}
+
+/**
+ * Check OAuth status for all providers
+ */
+export class CLIProxyAuthChecker implements IHealthChecker {
+ name = 'CLIProxy Auth';
+
+ run(results: HealthCheck): void {
+ const authStatuses = getAllAuthStatus();
+ for (const status of authStatuses) {
+ const spinner = ora(`Checking ${status.provider} auth`).start();
+ const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
+
+ if (status.authenticated) {
+ const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : '';
+ spinner.succeed();
+ console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`);
+ results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, {
+ status: 'OK',
+ info: `Authenticated${lastAuth}`,
+ });
+ } else {
+ spinner.info();
+ console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`);
+ results.addCheck(
+ `${providerName} Auth`,
+ 'success',
+ 'Not authenticated',
+ `Run: ccs ${status.provider} --auth`,
+ { status: 'OK', info: 'Not authenticated (run ccs to login)' }
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Check CLIProxy port status
+ */
+export class CLIProxyPortChecker implements IHealthChecker {
+ name = 'CLIProxy Port';
+
+ async run(results: HealthCheck): Promise {
+ const spinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start();
+ const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
+
+ if (!portProcess) {
+ // Port is free
+ spinner.info();
+ console.log(
+ ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)`
+ );
+ results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
+ status: 'OK',
+ info: `Port ${CLIPROXY_DEFAULT_PORT} free`,
+ });
+ } else if (isCLIProxyProcess(portProcess)) {
+ // CLIProxy is running (expected)
+ spinner.succeed();
+ console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`);
+ results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
+ status: 'OK',
+ info: `CLIProxy running (PID ${portProcess.pid})`,
+ });
+ } else {
+ // Port conflict - different process
+ spinner.warn();
+ console.log(
+ ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}`
+ );
+ results.addCheck(
+ 'CLIProxy Port',
+ 'warning',
+ `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`,
+ `Kill process: kill ${portProcess.pid} (or restart conflicting application)`,
+ { status: 'WARN', info: `Occupied by ${portProcess.processName}` }
+ );
+ }
+ }
+}
+
+/**
+ * Run all CLIProxy checks
+ */
+export async function runCLIProxyChecks(results: HealthCheck): Promise {
+ const binaryChecker = new CLIProxyBinaryChecker();
+ const configChecker = new CLIProxyConfigChecker();
+ const authChecker = new CLIProxyAuthChecker();
+ const portChecker = new CLIProxyPortChecker();
+
+ binaryChecker.run(results);
+ configChecker.run(results);
+ authChecker.run(results);
+ await portChecker.run(results);
+}
diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts
new file mode 100644
index 00000000..4a643814
--- /dev/null
+++ b/src/management/checks/config-check.ts
@@ -0,0 +1,168 @@
+/**
+ * Configuration Health Checks - Config files and Claude settings
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ok, fail, warn } from '../../utils/ui';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check CCS config files exist and are valid JSON
+ */
+export class ConfigFilesChecker implements IHealthChecker {
+ name = 'Config Files';
+ private readonly ccsDir: string;
+
+ constructor() {
+ this.ccsDir = path.join(os.homedir(), '.ccs');
+ }
+
+ run(results: HealthCheck): void {
+ const files = [
+ { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' },
+ {
+ path: path.join(this.ccsDir, 'glm.settings.json'),
+ name: 'glm.settings.json',
+ key: 'GLM Settings',
+ profile: 'glm',
+ },
+ {
+ path: path.join(this.ccsDir, 'kimi.settings.json'),
+ name: 'kimi.settings.json',
+ key: 'Kimi Settings',
+ profile: 'kimi',
+ },
+ ];
+
+ const { DelegationValidator } = require('../../utils/delegation-validator');
+
+ for (const file of files) {
+ const spinner = ora(`Checking ${file.name}`).start();
+
+ if (!fs.existsSync(file.path)) {
+ spinner.fail();
+ console.log(` ${fail(file.name.padEnd(22))} Not found`);
+ results.addCheck(
+ file.name,
+ 'error',
+ `${file.name} not found`,
+ 'Run: npm install -g @kaitranntt/ccs --force',
+ { status: 'ERROR', info: 'Not found' }
+ );
+ continue;
+ }
+
+ // Validate JSON
+ try {
+ const content = fs.readFileSync(file.path, 'utf8');
+ JSON.parse(content);
+
+ // Extract useful info based on file type
+ let fileInfo = 'Valid';
+ let status: 'OK' | 'WARN' = 'OK';
+
+ if (file.profile) {
+ // For settings files, check if API key is configured
+ const validation = DelegationValidator.validate(file.profile);
+
+ if (validation.valid) {
+ fileInfo = 'Key configured';
+ status = 'OK';
+ } else if (validation.error && validation.error.includes('placeholder')) {
+ fileInfo = 'Placeholder key';
+ status = 'WARN';
+ } else {
+ fileInfo = 'Valid JSON';
+ status = 'OK';
+ }
+ }
+
+ if (status === 'WARN') {
+ spinner.warn();
+ console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`);
+ } else {
+ spinner.succeed();
+ console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`);
+ }
+
+ results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', undefined, undefined, {
+ status: status,
+ info: fileInfo,
+ });
+ } catch (e) {
+ spinner.fail();
+ console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`);
+ results.addCheck(
+ file.name,
+ 'error',
+ `Invalid JSON: ${(e as Error).message}`,
+ `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`,
+ { status: 'ERROR', info: 'Invalid JSON' }
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Check Claude settings.json
+ */
+export class ClaudeSettingsChecker implements IHealthChecker {
+ name = 'Claude Settings';
+ private readonly claudeDir: string;
+
+ constructor() {
+ this.claudeDir = path.join(os.homedir(), '.claude');
+ }
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking ~/.claude/settings.json').start();
+ const settingsPath = path.join(this.claudeDir, 'settings.json');
+ const settingsName = '~/.claude/settings.json';
+
+ if (!fs.existsSync(settingsPath)) {
+ spinner.warn();
+ console.log(` ${warn(settingsName.padEnd(22))} Not found`);
+ results.addCheck(
+ 'Claude Settings',
+ 'warning',
+ '~/.claude/settings.json not found',
+ 'Run: claude /login'
+ );
+ return;
+ }
+
+ // Validate JSON
+ try {
+ const content = fs.readFileSync(settingsPath, 'utf8');
+ JSON.parse(content);
+ spinner.succeed();
+ console.log(` ${ok(settingsName.padEnd(22))} Valid`);
+ results.addCheck('Claude Settings', 'success');
+ } catch (e) {
+ spinner.warn();
+ console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`);
+ results.addCheck(
+ 'Claude Settings',
+ 'warning',
+ `Invalid JSON: ${(e as Error).message}`,
+ 'Run: claude /login'
+ );
+ }
+ }
+}
+
+/**
+ * Run all config checks
+ */
+export function runConfigChecks(results: HealthCheck): void {
+ const configChecker = new ConfigFilesChecker();
+ const claudeChecker = new ClaudeSettingsChecker();
+
+ configChecker.run(results);
+ claudeChecker.run(results);
+}
diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts
new file mode 100644
index 00000000..c586b07a
--- /dev/null
+++ b/src/management/checks/env-check.ts
@@ -0,0 +1,76 @@
+/**
+ * Environment Health Check - OAuth readiness diagnostics
+ */
+
+import { getEnvironmentDiagnostics } from '../environment-diagnostics';
+import { ok, warn } from '../../utils/ui';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check environment for OAuth readiness
+ * Helps diagnose Windows headless false positives
+ */
+export class EnvironmentChecker implements IHealthChecker {
+ name = 'Environment';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking environment').start();
+ const diag = getEnvironmentDiagnostics();
+
+ // Determine overall environment health
+ let envStatus: 'OK' | 'WARN' = 'OK';
+ let envMessage = 'Browser available';
+
+ // Check for potential issues
+ if (diag.detectedHeadless) {
+ if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') {
+ // Windows false positive - this is actually a warning
+ envStatus = 'WARN';
+ envMessage = 'Headless detected (may be false positive on Windows)';
+ } else if (diag.sshSession) {
+ envMessage = 'SSH session (headless mode)';
+ } else {
+ envMessage = 'Headless environment';
+ }
+ }
+
+ if (envStatus === 'WARN') {
+ spinner.warn();
+ console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`);
+ } else {
+ spinner.succeed();
+ console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`);
+ }
+
+ // Show key environment details
+ console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`);
+ if (diag.sshSession) {
+ console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`);
+ }
+ if (diag.ttyStatus === 'undefined') {
+ console.log(` ${''.padEnd(24)} TTY: undefined [!]`);
+ }
+ console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`);
+
+ results.addCheck(
+ 'Environment',
+ envStatus === 'OK' ? 'success' : 'warning',
+ envMessage,
+ envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined,
+ {
+ status: envStatus,
+ info: envMessage,
+ }
+ );
+ }
+}
+
+/**
+ * Run environment check
+ */
+export function runEnvironmentCheck(results: HealthCheck): void {
+ const checker = new EnvironmentChecker();
+ checker.run(results);
+}
diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts
new file mode 100644
index 00000000..1ea17e5f
--- /dev/null
+++ b/src/management/checks/index.ts
@@ -0,0 +1,51 @@
+/**
+ * Health Check Registry - Central export for all checks
+ */
+
+// Types and utilities
+export {
+ HealthCheck,
+ HealthCheckDetails,
+ HealthCheckItem,
+ HealthIssue,
+ IHealthChecker,
+ Spinner,
+ createSpinner,
+} from './types';
+
+// System checks
+export { ClaudeCliChecker, CcsDirectoryChecker, runSystemChecks } from './system-check';
+
+// Environment checks
+export { EnvironmentChecker, runEnvironmentCheck } from './env-check';
+
+// Config checks
+export { ConfigFilesChecker, ClaudeSettingsChecker, runConfigChecks } from './config-check';
+
+// Profile checks
+export {
+ ProfilesChecker,
+ InstancesChecker,
+ DelegationChecker,
+ runProfileChecks,
+} from './profile-check';
+
+// Symlink checks
+export {
+ PermissionsChecker,
+ CcsSymlinksChecker,
+ SettingsSymlinksChecker,
+ runSymlinkChecks,
+} from './symlink-check';
+
+// CLIProxy checks
+export {
+ CLIProxyBinaryChecker,
+ CLIProxyConfigChecker,
+ CLIProxyAuthChecker,
+ CLIProxyPortChecker,
+ runCLIProxyChecks,
+} from './cliproxy-check';
+
+// OAuth checks
+export { OAuthPortsChecker, runOAuthChecks } from './oauth-check';
diff --git a/src/management/checks/oauth-check.ts b/src/management/checks/oauth-check.ts
new file mode 100644
index 00000000..05ad54bf
--- /dev/null
+++ b/src/management/checks/oauth-check.ts
@@ -0,0 +1,76 @@
+/**
+ * OAuth Port Health Checks - Pre-flight check for OAuth authentication
+ */
+
+import { ok, warn, info } from '../../utils/ui';
+import { checkAuthCodePorts } from '../oauth-port-diagnostics';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check OAuth callback ports availability
+ */
+export class OAuthPortsChecker implements IHealthChecker {
+ name = 'OAuth Ports';
+
+ async run(results: HealthCheck): Promise {
+ const spinner = ora('Checking OAuth callback ports').start();
+ const portDiagnostics = await checkAuthCodePorts();
+
+ // Count issues
+ const conflicts = portDiagnostics.filter((d) => d.status === 'occupied');
+
+ if (conflicts.length === 0) {
+ spinner.succeed();
+ console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`);
+ results.addCheck('OAuth Ports', 'success', undefined, undefined, {
+ status: 'OK',
+ info: 'All callback ports available',
+ });
+ } else {
+ spinner.warn();
+ console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`);
+ results.addCheck(
+ 'OAuth Ports',
+ 'warning',
+ `${conflicts.length} port conflict(s)`,
+ 'Close conflicting applications before OAuth',
+ { status: 'WARN', info: `${conflicts.length} conflict(s)` }
+ );
+ }
+
+ // Show individual port status
+ for (const diag of portDiagnostics) {
+ const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
+ const portStr = diag.port !== null ? `(${diag.port})` : '';
+
+ let statusIcon: string;
+ switch (diag.status) {
+ case 'free':
+ case 'cliproxy':
+ statusIcon = ok(`${providerName} ${portStr}`.padEnd(20));
+ break;
+ case 'occupied':
+ statusIcon = warn(`${providerName} ${portStr}`.padEnd(20));
+ break;
+ default:
+ statusIcon = info(`${providerName} ${portStr}`.padEnd(20));
+ }
+
+ console.log(` ${statusIcon} ${diag.message}`);
+
+ if (diag.recommendation && diag.status === 'occupied') {
+ console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`);
+ }
+ }
+ }
+}
+
+/**
+ * Run OAuth port checks
+ */
+export async function runOAuthChecks(results: HealthCheck): Promise {
+ const checker = new OAuthPortsChecker();
+ await checker.run(results);
+}
diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts
new file mode 100644
index 00000000..cc449042
--- /dev/null
+++ b/src/management/checks/profile-check.ts
@@ -0,0 +1,190 @@
+/**
+ * Profile and Delegation Health Checks
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ok, fail, warn, info } from '../../utils/ui';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check profile configurations in config.json
+ */
+export class ProfilesChecker implements IHealthChecker {
+ name = 'Profiles';
+ private readonly ccsDir: string;
+
+ constructor() {
+ this.ccsDir = path.join(os.homedir(), '.ccs');
+ }
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking profiles').start();
+ const configPath = path.join(this.ccsDir, 'config.json');
+
+ if (!fs.existsSync(configPath)) {
+ spinner.info();
+ console.log(` ${info('Profiles'.padEnd(22))} config.json not found`);
+ return;
+ }
+
+ try {
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+
+ if (!config.profiles || typeof config.profiles !== 'object') {
+ spinner.fail();
+ console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`);
+ results.addCheck(
+ 'Profiles',
+ 'error',
+ 'config.json missing profiles object',
+ 'Run: npm install -g @kaitranntt/ccs --force',
+ { status: 'ERROR', info: 'Missing profiles object' }
+ );
+ return;
+ }
+
+ const profileCount = Object.keys(config.profiles).length;
+ const profileNames = Object.keys(config.profiles).join(', ');
+
+ spinner.succeed();
+ console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
+ results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, {
+ status: 'OK',
+ info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
+ });
+ } catch (e) {
+ spinner.fail();
+ console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`);
+ results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
+ status: 'ERROR',
+ info: (e as Error).message,
+ });
+ }
+ }
+}
+
+/**
+ * Check instance directories (account-based profiles)
+ */
+export class InstancesChecker implements IHealthChecker {
+ name = 'Instances';
+ private readonly ccsDir: string;
+
+ constructor() {
+ this.ccsDir = path.join(os.homedir(), '.ccs');
+ }
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking instances').start();
+ const instancesDir = path.join(this.ccsDir, 'instances');
+
+ if (!fs.existsSync(instancesDir)) {
+ spinner.info();
+ console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
+ results.addCheck('Instances', 'success', 'No account profiles configured');
+ return;
+ }
+
+ const instances = fs.readdirSync(instancesDir).filter((name) => {
+ return fs.statSync(path.join(instancesDir, name)).isDirectory();
+ });
+
+ if (instances.length === 0) {
+ spinner.info();
+ console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
+ results.addCheck('Instances', 'success', 'No account profiles');
+ return;
+ }
+
+ spinner.succeed();
+ console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`);
+ results.addCheck('Instances', 'success', `${instances.length} account profiles`);
+ }
+}
+
+/**
+ * Check delegation system (commands and ready profiles)
+ */
+export class DelegationChecker implements IHealthChecker {
+ name = 'Delegation';
+ private readonly ccsDir: string;
+
+ constructor() {
+ this.ccsDir = path.join(os.homedir(), '.ccs');
+ }
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking delegation').start();
+
+ // Check if delegation commands exist in ~/.ccs/.claude/commands/
+ const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands');
+ const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md'));
+ const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md'));
+
+ if (!hasCcsCommand || !hasContinueCommand) {
+ spinner.warn();
+ console.log(` ${warn('Delegation'.padEnd(22))} Not installed`);
+ results.addCheck(
+ 'Delegation',
+ 'warning',
+ 'Delegation commands not found',
+ 'Install with: npm install -g @kaitranntt/ccs --force',
+ { status: 'WARN', info: 'Not installed' }
+ );
+ return;
+ }
+
+ // Check profile validity using DelegationValidator
+ const { DelegationValidator } = require('../../utils/delegation-validator');
+ const readyProfiles: string[] = [];
+
+ for (const profile of ['glm', 'kimi']) {
+ const validation = DelegationValidator.validate(profile);
+ if (validation.valid) {
+ readyProfiles.push(profile);
+ }
+ }
+
+ if (readyProfiles.length === 0) {
+ spinner.warn();
+ console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`);
+ results.addCheck(
+ 'Delegation',
+ 'warning',
+ 'Delegation installed but no profiles configured',
+ 'Configure profiles with valid API keys (not placeholders)',
+ { status: 'WARN', info: 'No profiles ready' }
+ );
+ return;
+ }
+
+ spinner.succeed();
+ console.log(
+ ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`
+ );
+ results.addCheck(
+ 'Delegation',
+ 'success',
+ `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`,
+ undefined,
+ { status: 'OK', info: `${readyProfiles.length} profiles ready` }
+ );
+ }
+}
+
+/**
+ * Run all profile checks
+ */
+export function runProfileChecks(results: HealthCheck): void {
+ const profilesChecker = new ProfilesChecker();
+ const instancesChecker = new InstancesChecker();
+ const delegationChecker = new DelegationChecker();
+
+ profilesChecker.run(results);
+ instancesChecker.run(results);
+ delegationChecker.run(results);
+}
diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts
new file mode 100644
index 00000000..0b943295
--- /dev/null
+++ b/src/management/checks/symlink-check.ts
@@ -0,0 +1,218 @@
+/**
+ * Symlink and Permission Health Checks
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ok, fail, warn } from '../../utils/ui';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+const homedir = os.homedir();
+const ccsDir = path.join(homedir, '.ccs');
+const claudeDir = path.join(homedir, '.claude');
+
+/**
+ * Check file permissions on ~/.ccs/
+ */
+export class PermissionsChecker implements IHealthChecker {
+ name = 'Permissions';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking permissions').start();
+ const testFile = path.join(ccsDir, '.permission-test');
+
+ try {
+ fs.writeFileSync(testFile, 'test', 'utf8');
+ fs.unlinkSync(testFile);
+ spinner.succeed();
+ console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`);
+ results.addCheck('Permissions', 'success', undefined, undefined, {
+ status: 'OK',
+ info: 'Write access verified',
+ });
+ } catch (_e) {
+ spinner.fail();
+ console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`);
+ results.addCheck(
+ 'Permissions',
+ 'error',
+ 'Cannot write to ~/.ccs/',
+ 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
+ { status: 'ERROR', info: 'Cannot write to ~/.ccs/' }
+ );
+ }
+ }
+}
+
+/**
+ * Check CCS symlinks to ~/.claude/
+ */
+export class CcsSymlinksChecker implements IHealthChecker {
+ name = 'CCS Symlinks';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking CCS symlinks').start();
+
+ try {
+ const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager');
+ const manager = new ClaudeSymlinkManager();
+ const health = manager.checkHealth();
+
+ if (health.healthy) {
+ const cnt = manager.ccsItems.length;
+ spinner.succeed();
+ console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${cnt}/${cnt} items linked`);
+ results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', undefined, {
+ status: 'OK',
+ info: `${cnt}/${cnt} items synced`,
+ });
+ } else {
+ spinner.warn();
+ console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`);
+ results.addCheck('CCS Symlinks', 'warning', health.issues.join(', '), 'Run: ccs sync', {
+ status: 'WARN',
+ info: `${health.issues.length} issues`,
+ });
+ }
+ } catch (e) {
+ spinner.warn();
+ console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`);
+ results.addCheck(
+ 'CCS Symlinks',
+ 'warning',
+ 'Could not check CCS symlinks: ' + (e as Error).message,
+ 'Run: ccs sync',
+ { status: 'WARN', info: 'Could not check' }
+ );
+ }
+ }
+}
+
+/** Helper: Check if symlink points to expected target */
+function isValidSymlink(symlinkPath: string, expectedTarget: string): boolean {
+ if (!fs.existsSync(symlinkPath)) return false;
+ try {
+ const stats = fs.lstatSync(symlinkPath);
+ if (!stats.isSymbolicLink()) return false;
+ const target = fs.readlinkSync(symlinkPath);
+ const resolved = path.resolve(path.dirname(symlinkPath), target);
+ return resolved === expectedTarget;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check settings.json symlinks for instances
+ */
+export class SettingsSymlinksChecker implements IHealthChecker {
+ name = 'Settings Symlinks';
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking settings.json symlinks').start();
+ const label = 'settings.json';
+ const sharedSettings = path.join(ccsDir, 'shared', 'settings.json');
+ const claudeSettings = path.join(claudeDir, 'settings.json');
+
+ try {
+ // Check shared settings symlink
+ if (!fs.existsSync(sharedSettings)) {
+ spinner.warn();
+ console.log(` ${warn(label.padEnd(22))} Not found (shared)`);
+ results.addCheck(
+ 'Settings Symlinks',
+ 'warning',
+ 'Shared settings.json not found',
+ 'Run: ccs sync'
+ );
+ return;
+ }
+
+ const sharedStats = fs.lstatSync(sharedSettings);
+ if (!sharedStats.isSymbolicLink()) {
+ spinner.warn();
+ console.log(` ${warn(label.padEnd(22))} Not a symlink (shared)`);
+ results.addCheck(
+ 'Settings Symlinks',
+ 'warning',
+ 'Shared settings.json is not a symlink',
+ 'Run: ccs sync'
+ );
+ return;
+ }
+
+ if (!isValidSymlink(sharedSettings, claudeSettings)) {
+ spinner.warn();
+ console.log(` ${warn(label.padEnd(22))} Wrong target (shared)`);
+ results.addCheck(
+ 'Settings Symlinks',
+ 'warning',
+ 'Shared symlink points to wrong target',
+ 'Run: ccs sync'
+ );
+ return;
+ }
+
+ // Check instances
+ const instancesDir = path.join(ccsDir, 'instances');
+ if (!fs.existsSync(instancesDir)) {
+ spinner.succeed();
+ console.log(` ${ok(label.padEnd(22))} Shared symlink valid`);
+ results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, {
+ status: 'OK',
+ info: 'Shared symlink valid',
+ });
+ return;
+ }
+
+ const instances = fs
+ .readdirSync(instancesDir)
+ .filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory());
+
+ const broken = instances.filter((inst) => {
+ const instSettings = path.join(instancesDir, inst, 'settings.json');
+ return !isValidSymlink(instSettings, sharedSettings);
+ }).length;
+
+ if (broken > 0) {
+ spinner.warn();
+ console.log(` ${warn(label.padEnd(22))} ${broken} broken instance(s)`);
+ results.addCheck(
+ 'Settings Symlinks',
+ 'warning',
+ `${broken} instance(s) have broken symlinks`,
+ 'Run: ccs sync',
+ { status: 'WARN', info: `${broken} broken instance(s)` }
+ );
+ } else {
+ spinner.succeed();
+ console.log(` ${ok(label.padEnd(22))} ${instances.length} instance(s) valid`);
+ results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', undefined, {
+ status: 'OK',
+ info: `${instances.length} instance(s) valid`,
+ });
+ }
+ } catch (err) {
+ spinner.warn();
+ console.log(` ${warn(label.padEnd(22))} Check failed`);
+ results.addCheck(
+ 'Settings Symlinks',
+ 'warning',
+ `Failed to check: ${(err as Error).message}`,
+ 'Run: ccs sync',
+ { status: 'WARN', info: 'Check failed' }
+ );
+ }
+ }
+}
+
+/**
+ * Run all symlink checks
+ */
+export function runSymlinkChecks(results: HealthCheck): void {
+ new PermissionsChecker().run(results);
+ new CcsSymlinksChecker().run(results);
+ new SettingsSymlinksChecker().run(results);
+}
diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts
new file mode 100644
index 00000000..65b1b39e
--- /dev/null
+++ b/src/management/checks/system-check.ts
@@ -0,0 +1,138 @@
+/**
+ * System Health Checks - Claude CLI and CCS Directory
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { spawn } from 'child_process';
+import { getClaudeCliInfo } from '../../utils/claude-detector';
+import { escapeShellArg } from '../../utils/shell-executor';
+import { ok, fail } from '../../utils/ui';
+import { HealthCheck, IHealthChecker, createSpinner } from './types';
+
+const ora = createSpinner();
+
+/**
+ * Check Claude CLI availability and version
+ */
+export class ClaudeCliChecker implements IHealthChecker {
+ name = 'Claude CLI';
+
+ async run(results: HealthCheck): Promise {
+ const spinner = ora('Checking Claude CLI').start();
+
+ const cliInfo = getClaudeCliInfo();
+
+ if (!cliInfo) {
+ spinner.fail();
+ console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`);
+ results.addCheck(
+ 'Claude CLI',
+ 'error',
+ 'Claude CLI not found in PATH',
+ 'Install from: https://docs.claude.com/en/docs/claude-code/installation',
+ { status: 'ERROR', info: 'Not installed' }
+ );
+ return;
+ }
+
+ const { path: claudeCli, needsShell } = cliInfo;
+
+ // Try to execute claude --version
+ try {
+ const result = await new Promise((resolve, reject) => {
+ // When shell is needed (Windows .cmd/.bat files), concatenate into string
+ // to avoid DEP0190 warning about passing args with shell: true
+ const child = needsShell
+ ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), {
+ stdio: 'pipe',
+ timeout: 5000,
+ shell: true,
+ })
+ : spawn(claudeCli, ['--version'], {
+ stdio: 'pipe',
+ timeout: 5000,
+ });
+
+ let output = '';
+ child.stdout?.on('data', (data: Buffer) => (output += data));
+ child.stderr?.on('data', (data: Buffer) => (output += data));
+
+ child.on('close', (code: number | null) => {
+ if (code === 0) resolve(output);
+ else reject(new Error('Exit code ' + code));
+ });
+
+ child.on('error', reject);
+ });
+
+ // Extract version from output
+ const versionMatch = result.match(/(\d+\.\d+\.\d+)/);
+ const version = versionMatch ? versionMatch[1] : 'unknown';
+
+ spinner.succeed();
+ console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`);
+ results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, {
+ status: 'OK',
+ info: `v${version} (${claudeCli})`,
+ });
+ } catch (_err) {
+ spinner.fail();
+ console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`);
+ results.addCheck(
+ 'Claude CLI',
+ 'error',
+ 'Claude CLI not found or not working',
+ 'Install from: https://docs.claude.com/en/docs/claude-code/installation',
+ { status: 'ERROR', info: 'Not installed' }
+ );
+ }
+ }
+}
+
+/**
+ * Check ~/.ccs/ directory exists
+ */
+export class CcsDirectoryChecker implements IHealthChecker {
+ name = 'CCS Directory';
+ private readonly ccsDir: string;
+
+ constructor() {
+ this.ccsDir = path.join(os.homedir(), '.ccs');
+ }
+
+ run(results: HealthCheck): void {
+ const spinner = ora('Checking ~/.ccs/ directory').start();
+
+ if (fs.existsSync(this.ccsDir)) {
+ spinner.succeed();
+ console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`);
+ results.addCheck('CCS Directory', 'success', undefined, undefined, {
+ status: 'OK',
+ info: '~/.ccs/',
+ });
+ } else {
+ spinner.fail();
+ console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`);
+ results.addCheck(
+ 'CCS Directory',
+ 'error',
+ '~/.ccs/ directory not found',
+ 'Run: npm install -g @kaitranntt/ccs --force',
+ { status: 'ERROR', info: 'Not found' }
+ );
+ }
+ }
+}
+
+/**
+ * Run all system checks
+ */
+export async function runSystemChecks(results: HealthCheck): Promise {
+ const cliChecker = new ClaudeCliChecker();
+ const dirChecker = new CcsDirectoryChecker();
+
+ await cliChecker.run(results);
+ dirChecker.run(results);
+}
diff --git a/src/management/checks/types.ts b/src/management/checks/types.ts
new file mode 100644
index 00000000..4485575a
--- /dev/null
+++ b/src/management/checks/types.ts
@@ -0,0 +1,114 @@
+/**
+ * Health Check Types and Interfaces
+ */
+
+/**
+ * Spinner interface for ora or fallback
+ */
+export interface Spinner {
+ start(): {
+ succeed(msg?: string): void;
+ fail(msg?: string): void;
+ warn(msg?: string): void;
+ info(msg?: string): void;
+ text: string;
+ };
+}
+
+/**
+ * Details for individual health check
+ */
+export interface HealthCheckDetails {
+ status: 'OK' | 'ERROR' | 'WARN';
+ info: string;
+}
+
+/**
+ * Individual health check item
+ */
+export interface HealthCheckItem {
+ name: string;
+ status: 'success' | 'error' | 'warning';
+ message?: string;
+ fix?: string;
+}
+
+/**
+ * Health issue (error or warning)
+ */
+export interface HealthIssue {
+ name: string;
+ message: string;
+ fix?: string;
+}
+
+/**
+ * Health check results container
+ */
+export class HealthCheck {
+ public checks: HealthCheckItem[] = [];
+ public warnings: HealthIssue[] = [];
+ public errors: HealthIssue[] = [];
+ public details: Record = {};
+
+ addCheck(
+ name: string,
+ status: 'success' | 'error' | 'warning',
+ message = '',
+ fix: string | undefined = undefined,
+ details: HealthCheckDetails | undefined = undefined
+ ): void {
+ this.checks.push({ name, status, message, fix });
+
+ if (status === 'error') this.errors.push({ name, message, fix });
+ if (status === 'warning') this.warnings.push({ name, message, fix });
+
+ // Store details for summary table
+ if (details) {
+ this.details[name] = details;
+ }
+ }
+
+ hasErrors(): boolean {
+ return this.errors.length > 0;
+ }
+
+ hasWarnings(): boolean {
+ return this.warnings.length > 0;
+ }
+
+ isHealthy(): boolean {
+ return !this.hasErrors();
+ }
+}
+
+/**
+ * Base interface for all health checks
+ */
+export interface IHealthChecker {
+ name: string;
+ run(results: HealthCheck): Promise | void;
+}
+
+/**
+ * Create ora spinner with fallback for environments where ora is unavailable
+ */
+export function createSpinner(): (text: string) => Spinner {
+ try {
+ const oraModule = require('ora');
+ return oraModule.default || oraModule;
+ } catch (_e) {
+ // ora not available, create fallback spinner that uses console.log
+ return function (text: string): Spinner {
+ return {
+ start: () => ({
+ succeed: (msg?: string) => console.log(msg || `[OK] ${text}`),
+ fail: (msg?: string) => console.log(msg || `[X] ${text}`),
+ warn: (msg?: string) => console.log(msg || `[!] ${text}`),
+ info: (msg?: string) => console.log(msg || `[i] ${text}`),
+ text: '',
+ }),
+ };
+ };
+ }
+}
diff --git a/src/management/doctor.ts b/src/management/doctor.ts
index c26c7073..8667e405 100644
--- a/src/management/doctor.ts
+++ b/src/management/doctor.ts
@@ -1,134 +1,29 @@
/**
- * CCS Health Check and Diagnostics
+ * CCS Health Check and Diagnostics - Main Orchestrator
*/
-import * as fs from 'fs';
-import * as path from 'path';
-import * as os from 'os';
-import { spawn } from 'child_process';
-import { getClaudeCliInfo } from '../utils/claude-detector';
-import { escapeShellArg } from '../utils/shell-executor';
-import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui';
+import { initUI, header, box, table, color, ok, fail, warn } from '../utils/ui';
import packageJson from '../../package.json';
import {
- isCLIProxyInstalled,
- getCLIProxyPath,
- getAllAuthStatus,
- getConfigPath,
- getInstalledCliproxyVersion,
- CLIPROXY_DEFAULT_PORT,
- configNeedsRegeneration,
- regenerateConfig,
- CLIPROXY_CONFIG_VERSION,
-} from '../cliproxy';
-import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
-import { getEnvironmentDiagnostics } from './environment-diagnostics';
-import { checkAuthCodePorts } from './oauth-port-diagnostics';
-import { killProcessOnPort, getPlatformName } from '../utils/platform-commands';
-
-// Make ora optional (might not be available during npm install postinstall)
-// ora v9+ is an ES module, need to use .default for CommonJS
-interface Spinner {
- start(): {
- succeed(msg?: string): void;
- fail(msg?: string): void;
- warn(msg?: string): void;
- info(msg?: string): void;
- text: string;
- };
-}
-
-let ora: (text: string) => Spinner;
-try {
- const oraModule = require('ora');
- ora = oraModule.default || oraModule;
-} catch (_e) {
- // ora not available, create fallback spinner that uses console.log
- ora = function (text: string): Spinner {
- return {
- start: () => ({
- succeed: (msg?: string) => console.log(msg || `[OK] ${text}`),
- fail: (msg?: string) => console.log(msg || `[X] ${text}`),
- warn: (msg?: string) => console.log(msg || `[!] ${text}`),
- info: (msg?: string) => console.log(msg || `[i] ${text}`),
- text: '',
- }),
- };
- };
-}
-
-interface HealthCheckDetails {
- status: 'OK' | 'ERROR' | 'WARN';
- info: string;
-}
-
-interface HealthCheckItem {
- name: string;
- status: 'success' | 'error' | 'warning';
- message?: string;
- fix?: string;
-}
-
-interface HealthIssue {
- name: string;
- message: string;
- fix?: string;
-}
+ HealthCheck,
+ runSystemChecks,
+ runEnvironmentCheck,
+ runConfigChecks,
+ runProfileChecks,
+ runSymlinkChecks,
+ runCLIProxyChecks,
+ runOAuthChecks,
+} from './checks';
+import { runAutoRepair } from './repair';
/**
- * Health check results
- */
-class HealthCheck {
- public checks: HealthCheckItem[] = [];
- public warnings: HealthIssue[] = [];
- public errors: HealthIssue[] = [];
- public details: Record = {};
-
- addCheck(
- name: string,
- status: 'success' | 'error' | 'warning',
- message = '',
- fix: string | undefined = undefined,
- details: HealthCheckDetails | undefined = undefined
- ): void {
- this.checks.push({ name, status, message, fix });
-
- if (status === 'error') this.errors.push({ name, message, fix });
- if (status === 'warning') this.warnings.push({ name, message, fix });
-
- // Store details for summary table
- if (details) {
- this.details[name] = details;
- }
- }
-
- hasErrors(): boolean {
- return this.errors.length > 0;
- }
-
- hasWarnings(): boolean {
- return this.warnings.length > 0;
- }
-
- isHealthy(): boolean {
- return !this.hasErrors();
- }
-}
-
-/**
- * Doctor Class
+ * Doctor Class - Orchestrates health checks
*/
class Doctor {
- private readonly homedir: string;
- private readonly ccsDir: string;
- private readonly claudeDir: string;
private readonly results: HealthCheck;
private readonly ccsVersion: string;
constructor() {
- this.homedir = os.homedir();
- this.ccsDir = path.join(this.homedir, '.ccs');
- this.claudeDir = path.join(this.homedir, '.claude');
this.results = new HealthCheck();
this.ccsVersion = packageJson.version;
}
@@ -148,887 +43,43 @@ class Doctor {
// Group 1: System
console.log(header('SYSTEM'));
- await this.checkClaudeCli();
- this.checkCcsDirectory();
+ await runSystemChecks(this.results);
console.log('');
- // Group 2: Environment (NEW - OAuth readiness diagnostics)
+ // Group 2: Environment (OAuth readiness diagnostics)
console.log(header('ENVIRONMENT'));
- this.checkEnvironment();
+ runEnvironmentCheck(this.results);
console.log('');
// Group 3: Configuration
console.log(header('CONFIGURATION'));
- this.checkConfigFiles();
- this.checkClaudeSettings();
+ runConfigChecks(this.results);
console.log('');
// Group 4: Profiles & Delegation
console.log(header('PROFILES & DELEGATION'));
- this.checkProfiles();
- this.checkInstances();
- this.checkDelegation();
+ runProfileChecks(this.results);
console.log('');
// Group 5: System Health
console.log(header('SYSTEM HEALTH'));
- this.checkPermissions();
- this.checkCcsSymlinks();
- this.checkSettingsSymlinks();
+ runSymlinkChecks(this.results);
console.log('');
// Group 6: CLIProxy (OAuth profiles)
console.log(header('CLIPROXY (OAUTH PROFILES)'));
- await this.checkCLIProxy();
+ await runCLIProxyChecks(this.results);
console.log('');
- // Group 7: OAuth Readiness (NEW - port availability)
+ // Group 7: OAuth Readiness (port availability)
console.log(header('OAUTH READINESS'));
- await this.checkOAuthPorts();
+ await runOAuthChecks(this.results);
console.log('');
this.showReport();
return this.results;
}
- /**
- * Check 1: Claude CLI availability
- */
- private async checkClaudeCli(): Promise {
- const spinner = ora('Checking Claude CLI').start();
-
- const cliInfo = getClaudeCliInfo();
-
- if (!cliInfo) {
- spinner.fail();
- console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`);
- this.results.addCheck(
- 'Claude CLI',
- 'error',
- 'Claude CLI not found in PATH',
- 'Install from: https://docs.claude.com/en/docs/claude-code/installation',
- { status: 'ERROR', info: 'Not installed' }
- );
- return;
- }
-
- const { path: claudeCli, needsShell } = cliInfo;
-
- // Try to execute claude --version
- try {
- const result = await new Promise((resolve, reject) => {
- // When shell is needed (Windows .cmd/.bat files), concatenate into string
- // to avoid DEP0190 warning about passing args with shell: true
- const child = needsShell
- ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), {
- stdio: 'pipe',
- timeout: 5000,
- shell: true,
- })
- : spawn(claudeCli, ['--version'], {
- stdio: 'pipe',
- timeout: 5000,
- });
-
- let output = '';
- child.stdout?.on('data', (data: Buffer) => (output += data));
- child.stderr?.on('data', (data: Buffer) => (output += data));
-
- child.on('close', (code: number | null) => {
- if (code === 0) resolve(output);
- else reject(new Error('Exit code ' + code));
- });
-
- child.on('error', reject);
- });
-
- // Extract version from output
- const versionMatch = result.match(/(\d+\.\d+\.\d+)/);
- const version = versionMatch ? versionMatch[1] : 'unknown';
-
- spinner.succeed();
- console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`);
- this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, {
- status: 'OK',
- info: `v${version} (${claudeCli})`,
- });
- } catch (_err) {
- spinner.fail();
- console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`);
- this.results.addCheck(
- 'Claude CLI',
- 'error',
- 'Claude CLI not found or not working',
- 'Install from: https://docs.claude.com/en/docs/claude-code/installation',
- { status: 'ERROR', info: 'Not installed' }
- );
- }
- }
-
- /**
- * Check 2: ~/.ccs/ directory
- */
- private checkCcsDirectory(): void {
- const spinner = ora('Checking ~/.ccs/ directory').start();
-
- if (fs.existsSync(this.ccsDir)) {
- spinner.succeed();
- console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`);
- this.results.addCheck('CCS Directory', 'success', undefined, undefined, {
- status: 'OK',
- info: '~/.ccs/',
- });
- } else {
- spinner.fail();
- console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`);
- this.results.addCheck(
- 'CCS Directory',
- 'error',
- '~/.ccs/ directory not found',
- 'Run: npm install -g @kaitranntt/ccs --force',
- { status: 'ERROR', info: 'Not found' }
- );
- }
- }
-
- /**
- * Check 3: Config files
- */
- private checkConfigFiles(): void {
- const files = [
- { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' },
- {
- path: path.join(this.ccsDir, 'glm.settings.json'),
- name: 'glm.settings.json',
- key: 'GLM Settings',
- profile: 'glm',
- },
- {
- path: path.join(this.ccsDir, 'kimi.settings.json'),
- name: 'kimi.settings.json',
- key: 'Kimi Settings',
- profile: 'kimi',
- },
- ];
-
- const { DelegationValidator } = require('../utils/delegation-validator');
-
- for (const file of files) {
- const spinner = ora(`Checking ${file.name}`).start();
-
- if (!fs.existsSync(file.path)) {
- spinner.fail();
- console.log(` ${fail(file.name.padEnd(22))} Not found`);
- this.results.addCheck(
- file.name,
- 'error',
- `${file.name} not found`,
- 'Run: npm install -g @kaitranntt/ccs --force',
- { status: 'ERROR', info: 'Not found' }
- );
- continue;
- }
-
- // Validate JSON
- try {
- const content = fs.readFileSync(file.path, 'utf8');
- JSON.parse(content);
-
- // Extract useful info based on file type
- let fileInfo = 'Valid';
- let status: 'OK' | 'WARN' = 'OK';
-
- if (file.profile) {
- // For settings files, check if API key is configured
- const validation = DelegationValidator.validate(file.profile);
-
- if (validation.valid) {
- fileInfo = 'Key configured';
- status = 'OK';
- } else if (validation.error && validation.error.includes('placeholder')) {
- fileInfo = 'Placeholder key';
- status = 'WARN';
- } else {
- fileInfo = 'Valid JSON';
- status = 'OK';
- }
- }
-
- if (status === 'WARN') {
- spinner.warn();
- console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`);
- } else {
- spinner.succeed();
- console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`);
- }
-
- this.results.addCheck(
- file.name,
- status === 'OK' ? 'success' : 'warning',
- undefined,
- undefined,
- {
- status: status,
- info: fileInfo,
- }
- );
- } catch (e) {
- spinner.fail();
- console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`);
- this.results.addCheck(
- file.name,
- 'error',
- `Invalid JSON: ${(e as Error).message}`,
- `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`,
- { status: 'ERROR', info: 'Invalid JSON' }
- );
- }
- }
- }
-
- /**
- * Check 4: Claude settings
- */
- private checkClaudeSettings(): void {
- const spinner = ora('Checking ~/.claude/settings.json').start();
- const settingsPath = path.join(this.claudeDir, 'settings.json');
- const settingsName = '~/.claude/settings.json';
-
- if (!fs.existsSync(settingsPath)) {
- spinner.warn();
- console.log(` ${warn(settingsName.padEnd(22))} Not found`);
- this.results.addCheck(
- 'Claude Settings',
- 'warning',
- '~/.claude/settings.json not found',
- 'Run: claude /login'
- );
- return;
- }
-
- // Validate JSON
- try {
- const content = fs.readFileSync(settingsPath, 'utf8');
- JSON.parse(content);
- spinner.succeed();
- console.log(` ${ok(settingsName.padEnd(22))} Valid`);
- this.results.addCheck('Claude Settings', 'success');
- } catch (e) {
- spinner.warn();
- console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`);
- this.results.addCheck(
- 'Claude Settings',
- 'warning',
- `Invalid JSON: ${(e as Error).message}`,
- 'Run: claude /login'
- );
- }
- }
-
- /**
- * Check 5: Profile configurations
- */
- private checkProfiles(): void {
- const spinner = ora('Checking profiles').start();
- const configPath = path.join(this.ccsDir, 'config.json');
-
- if (!fs.existsSync(configPath)) {
- spinner.info();
- console.log(` ${info('Profiles'.padEnd(22))} config.json not found`);
- return;
- }
-
- try {
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
-
- if (!config.profiles || typeof config.profiles !== 'object') {
- spinner.fail();
- console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`);
- this.results.addCheck(
- 'Profiles',
- 'error',
- 'config.json missing profiles object',
- 'Run: npm install -g @kaitranntt/ccs --force',
- { status: 'ERROR', info: 'Missing profiles object' }
- );
- return;
- }
-
- const profileCount = Object.keys(config.profiles).length;
- const profileNames = Object.keys(config.profiles).join(', ');
-
- spinner.succeed();
- console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
- this.results.addCheck(
- 'Profiles',
- 'success',
- `${profileCount} profiles configured`,
- undefined,
- {
- status: 'OK',
- info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
- }
- );
- } catch (e) {
- spinner.fail();
- console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`);
- this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
- status: 'ERROR',
- info: (e as Error).message,
- });
- }
- }
-
- /**
- * Check 6: Instance directories (account-based profiles)
- */
- private checkInstances(): void {
- const spinner = ora('Checking instances').start();
- const instancesDir = path.join(this.ccsDir, 'instances');
-
- if (!fs.existsSync(instancesDir)) {
- spinner.info();
- console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
- this.results.addCheck('Instances', 'success', 'No account profiles configured');
- return;
- }
-
- const instances = fs.readdirSync(instancesDir).filter((name) => {
- return fs.statSync(path.join(instancesDir, name)).isDirectory();
- });
-
- if (instances.length === 0) {
- spinner.info();
- console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
- this.results.addCheck('Instances', 'success', 'No account profiles');
- return;
- }
-
- spinner.succeed();
- console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`);
- this.results.addCheck('Instances', 'success', `${instances.length} account profiles`);
- }
-
- /**
- * Check 7: Delegation system
- */
- private checkDelegation(): void {
- const spinner = ora('Checking delegation').start();
-
- // Check if delegation commands exist in ~/.ccs/.claude/commands/
- const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands');
- const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md'));
- const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md'));
-
- if (!hasCcsCommand || !hasContinueCommand) {
- spinner.warn();
- console.log(` ${warn('Delegation'.padEnd(22))} Not installed`);
- this.results.addCheck(
- 'Delegation',
- 'warning',
- 'Delegation commands not found',
- 'Install with: npm install -g @kaitranntt/ccs --force',
- { status: 'WARN', info: 'Not installed' }
- );
- return;
- }
-
- // Check profile validity using DelegationValidator
- const { DelegationValidator } = require('../utils/delegation-validator');
- const readyProfiles: string[] = [];
-
- for (const profile of ['glm', 'kimi']) {
- const validation = DelegationValidator.validate(profile);
- if (validation.valid) {
- readyProfiles.push(profile);
- }
- }
-
- if (readyProfiles.length === 0) {
- spinner.warn();
- console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`);
- this.results.addCheck(
- 'Delegation',
- 'warning',
- 'Delegation installed but no profiles configured',
- 'Configure profiles with valid API keys (not placeholders)',
- { status: 'WARN', info: 'No profiles ready' }
- );
- return;
- }
-
- spinner.succeed();
- console.log(
- ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`
- );
- this.results.addCheck(
- 'Delegation',
- 'success',
- `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`,
- undefined,
- { status: 'OK', info: `${readyProfiles.length} profiles ready` }
- );
- }
-
- /**
- * Check 8: File permissions
- */
- private checkPermissions(): void {
- const spinner = ora('Checking permissions').start();
- const testFile = path.join(this.ccsDir, '.permission-test');
-
- try {
- fs.writeFileSync(testFile, 'test', 'utf8');
- fs.unlinkSync(testFile);
- spinner.succeed();
- console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`);
- this.results.addCheck('Permissions', 'success', undefined, undefined, {
- status: 'OK',
- info: 'Write access verified',
- });
- } catch (_e) {
- spinner.fail();
- console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`);
- this.results.addCheck(
- 'Permissions',
- 'error',
- 'Cannot write to ~/.ccs/',
- 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
- { status: 'ERROR', info: 'Cannot write to ~/.ccs/' }
- );
- }
- }
-
- /**
- * Check 9: CCS symlinks to ~/.claude/
- */
- private checkCcsSymlinks(): void {
- const spinner = ora('Checking CCS symlinks').start();
-
- try {
- const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager');
- const manager = new ClaudeSymlinkManager();
- const health = manager.checkHealth();
-
- if (health.healthy) {
- const itemCount = manager.ccsItems.length;
- spinner.succeed();
- console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${itemCount}/${itemCount} items linked`);
- this.results.addCheck(
- 'CCS Symlinks',
- 'success',
- 'All CCS items properly symlinked',
- undefined,
- {
- status: 'OK',
- info: `${itemCount}/${itemCount} items synced`,
- }
- );
- } else {
- spinner.warn();
- console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`);
- this.results.addCheck(
- 'CCS Symlinks',
- 'warning',
- health.issues.join(', '),
- 'Run: ccs sync',
- { status: 'WARN', info: `${health.issues.length} issues` }
- );
- }
- } catch (e) {
- spinner.warn();
- console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`);
- this.results.addCheck(
- 'CCS Symlinks',
- 'warning',
- 'Could not check CCS symlinks: ' + (e as Error).message,
- 'Run: ccs sync',
- { status: 'WARN', info: 'Could not check' }
- );
- }
- }
-
- /**
- * Check 10: settings.json symlinks
- */
- private checkSettingsSymlinks(): void {
- const spinner = ora('Checking settings.json symlinks').start();
- const settingsLabel = 'settings.json';
-
- try {
- const sharedDir = path.join(this.homedir, '.ccs', 'shared');
- const sharedSettings = path.join(sharedDir, 'settings.json');
- const claudeSettings = path.join(this.claudeDir, 'settings.json');
-
- // Check shared settings exists and points to ~/.claude/
- if (!fs.existsSync(sharedSettings)) {
- spinner.warn();
- console.log(` ${warn(settingsLabel.padEnd(22))} Not found (shared)`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'warning',
- 'Shared settings.json not found',
- 'Run: ccs sync'
- );
- return;
- }
-
- const sharedStats = fs.lstatSync(sharedSettings);
- if (!sharedStats.isSymbolicLink()) {
- spinner.warn();
- console.log(` ${warn(settingsLabel.padEnd(22))} Not a symlink (shared)`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'warning',
- 'Shared settings.json is not a symlink',
- 'Run: ccs sync'
- );
- return;
- }
-
- const sharedTarget = fs.readlinkSync(sharedSettings);
- const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
-
- if (resolvedShared !== claudeSettings) {
- spinner.warn();
- console.log(` ${warn(settingsLabel.padEnd(22))} Wrong target (shared)`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'warning',
- `Points to ${resolvedShared} instead of ${claudeSettings}`,
- 'Run: ccs sync'
- );
- return;
- }
-
- // Check each instance
- const instancesDir = path.join(this.ccsDir, 'instances');
- if (!fs.existsSync(instancesDir)) {
- spinner.succeed();
- console.log(` ${ok(settingsLabel.padEnd(22))} Shared symlink valid`);
- this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, {
- status: 'OK',
- info: 'Shared symlink valid',
- });
- return;
- }
-
- const instances = fs.readdirSync(instancesDir).filter((name) => {
- return fs.statSync(path.join(instancesDir, name)).isDirectory();
- });
-
- let broken = 0;
- for (const instance of instances) {
- const instancePath = path.join(instancesDir, instance);
- const instanceSettings = path.join(instancePath, 'settings.json');
-
- if (!fs.existsSync(instanceSettings)) {
- broken++;
- continue;
- }
-
- try {
- const stats = fs.lstatSync(instanceSettings);
- if (!stats.isSymbolicLink()) {
- broken++;
- continue;
- }
-
- const target = fs.readlinkSync(instanceSettings);
- const resolved = path.resolve(path.dirname(instanceSettings), target);
-
- if (resolved !== sharedSettings) {
- broken++;
- }
- } catch (_err) {
- broken++;
- }
- }
-
- if (broken > 0) {
- spinner.warn();
- console.log(` ${warn(settingsLabel.padEnd(22))} ${broken} broken instance(s)`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'warning',
- `${broken} instance(s) have broken symlinks`,
- 'Run: ccs sync',
- { status: 'WARN', info: `${broken} broken instance(s)` }
- );
- } else {
- spinner.succeed();
- console.log(` ${ok(settingsLabel.padEnd(22))} ${instances.length} instance(s) valid`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'success',
- 'All instance symlinks valid',
- undefined,
- {
- status: 'OK',
- info: `${instances.length} instance(s) valid`,
- }
- );
- }
- } catch (err) {
- spinner.warn();
- console.log(` ${warn(settingsLabel.padEnd(22))} Check failed`);
- this.results.addCheck(
- 'Settings Symlinks',
- 'warning',
- `Failed to check: ${(err as Error).message}`,
- 'Run: ccs sync',
- { status: 'WARN', info: 'Check failed' }
- );
- }
- }
-
- /**
- * Check 10.1: Environment detection (OAuth readiness)
- * Helps diagnose Windows headless false positives
- */
- private checkEnvironment(): void {
- const spinner = ora('Checking environment').start();
- const diag = getEnvironmentDiagnostics();
-
- // Determine overall environment health
- let envStatus: 'OK' | 'WARN' = 'OK';
- let envMessage = 'Browser available';
-
- // Check for potential issues
- if (diag.detectedHeadless) {
- if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') {
- // Windows false positive - this is actually a warning
- envStatus = 'WARN';
- envMessage = 'Headless detected (may be false positive on Windows)';
- } else if (diag.sshSession) {
- envMessage = 'SSH session (headless mode)';
- } else {
- envMessage = 'Headless environment';
- }
- }
-
- if (envStatus === 'WARN') {
- spinner.warn();
- console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`);
- } else {
- spinner.succeed();
- console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`);
- }
-
- // Show key environment details
- console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`);
- if (diag.sshSession) {
- console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`);
- }
- if (diag.ttyStatus === 'undefined') {
- console.log(` ${''.padEnd(24)} TTY: undefined [!]`);
- }
- console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`);
-
- this.results.addCheck(
- 'Environment',
- envStatus === 'OK' ? 'success' : 'warning',
- envMessage,
- envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined,
- {
- status: envStatus,
- info: envMessage,
- }
- );
- }
-
- /**
- * Check 10.2: OAuth callback ports availability
- * Pre-flight check for OAuth authentication
- */
- private async checkOAuthPorts(): Promise {
- const spinner = ora('Checking OAuth callback ports').start();
- const portDiagnostics = await checkAuthCodePorts();
-
- // Count issues
- const conflicts = portDiagnostics.filter((d) => d.status === 'occupied');
-
- if (conflicts.length === 0) {
- spinner.succeed();
- console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`);
- this.results.addCheck('OAuth Ports', 'success', undefined, undefined, {
- status: 'OK',
- info: 'All callback ports available',
- });
- } else {
- spinner.warn();
- console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`);
- this.results.addCheck(
- 'OAuth Ports',
- 'warning',
- `${conflicts.length} port conflict(s)`,
- 'Close conflicting applications before OAuth',
- { status: 'WARN', info: `${conflicts.length} conflict(s)` }
- );
- }
-
- // Show individual port status
- for (const diag of portDiagnostics) {
- const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
- const portStr = diag.port !== null ? `(${diag.port})` : '';
-
- let statusIcon: string;
- switch (diag.status) {
- case 'free':
- case 'cliproxy':
- statusIcon = ok(`${providerName} ${portStr}`.padEnd(20));
- break;
- case 'occupied':
- statusIcon = warn(`${providerName} ${portStr}`.padEnd(20));
- break;
- default:
- statusIcon = info(`${providerName} ${portStr}`.padEnd(20));
- }
-
- console.log(` ${statusIcon} ${diag.message}`);
-
- if (diag.recommendation && diag.status === 'occupied') {
- console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`);
- }
- }
- }
-
- /**
- * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen)
- */
- private async checkCLIProxy(): Promise {
- // 1. Binary installed?
- const binarySpinner = ora('Checking CLIProxy binary').start();
-
- if (isCLIProxyInstalled()) {
- const binaryPath = getCLIProxyPath();
- const installedVersion = getInstalledCliproxyVersion();
- binarySpinner.succeed();
- console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`);
- this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, {
- status: 'OK',
- info: `v${installedVersion} (${binaryPath})`,
- });
- } else {
- binarySpinner.info();
- console.log(
- ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)`
- );
- this.results.addCheck(
- 'CLIProxy Binary',
- 'success',
- 'Not installed yet',
- 'Run: ccs gemini "test" (will download automatically)',
- { status: 'OK', info: 'Not installed (downloads on first use)' }
- );
- }
-
- // 2. Config file exists and is up-to-date?
- const configSpinner = ora('Checking CLIProxy config').start();
- const configPath = getConfigPath();
-
- if (fs.existsSync(configPath)) {
- // Check if config needs regeneration (version mismatch or missing features)
- if (configNeedsRegeneration()) {
- configSpinner.warn();
- console.log(
- ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...`
- );
-
- // Regenerate config with new features
- regenerateConfig();
-
- console.log(
- ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}`
- );
- this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
- status: 'OK',
- info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`,
- });
- } else {
- configSpinner.succeed();
- console.log(
- ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`
- );
- this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
- status: 'OK',
- info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`,
- });
- }
- } else {
- configSpinner.info();
- console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
- this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, {
- status: 'OK',
- info: 'Generated on first use',
- });
- }
-
- // 3. OAuth status for each provider
- const authStatuses = getAllAuthStatus();
- for (const status of authStatuses) {
- const authSpinner = ora(`Checking ${status.provider} auth`).start();
- const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
-
- if (status.authenticated) {
- const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : '';
- authSpinner.succeed();
- console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`);
- this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, {
- status: 'OK',
- info: `Authenticated${lastAuth}`,
- });
- } else {
- authSpinner.info();
- console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`);
- this.results.addCheck(
- `${providerName} Auth`,
- 'success',
- 'Not authenticated',
- `Run: ccs ${status.provider} --auth`,
- { status: 'OK', info: 'Not authenticated (run ccs to login)' }
- );
- }
- }
-
- // 4. Port status (check if CLIProxy or other process)
- const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start();
- const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
-
- if (!portProcess) {
- // Port is free
- portSpinner.info();
- console.log(
- ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)`
- );
- this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
- status: 'OK',
- info: `Port ${CLIPROXY_DEFAULT_PORT} free`,
- });
- } else if (isCLIProxyProcess(portProcess)) {
- // CLIProxy is running (expected)
- portSpinner.succeed();
- console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`);
- this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
- status: 'OK',
- info: `CLIProxy running (PID ${portProcess.pid})`,
- });
- } else {
- // Port conflict - different process
- portSpinner.warn();
- console.log(
- ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}`
- );
- this.results.addCheck(
- 'CLIProxy Port',
- 'warning',
- `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`,
- `Kill process: kill ${portProcess.pid} (or restart conflicting application)`,
- { status: 'WARN', info: `Occupied by ${portProcess.processName}` }
- );
- }
- }
-
/**
* Show health check report
*/
@@ -1119,134 +170,9 @@ class Doctor {
/**
* Fix detected issues (--fix flag)
- * Fixes:
- * 1. Zombie CLIProxy processes blocking ports
- * 2. Outdated CLIProxy config files
- * 3. Shared symlinks broken by Claude CLI's atomic writes
- * 4. OAuth callback ports blocked by CLIProxy
*/
async fixIssues(): Promise {
- console.log('');
- console.log(header('AUTO-FIX MODE'));
- console.log('');
- console.log(info(`Platform: ${getPlatformName()}`));
- console.log('');
-
- let fixed = 0;
-
- // Fix 1: Kill zombie CLIProxy processes
- const zombieSpinner = ora('Checking for zombie CLIProxy processes').start();
- try {
- // Check main CLIProxy port
- const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
- if (portProcess && isCLIProxyProcess(portProcess)) {
- zombieSpinner.text = 'Killing zombie CLIProxy process...';
- const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true);
- if (killed) {
- zombieSpinner.succeed(
- `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}`
- );
- fixed++;
- } else {
- zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`);
- }
- } else if (portProcess) {
- zombieSpinner.info(
- `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)`
- );
- } else {
- zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`);
- }
- } catch (err) {
- zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`);
- }
-
- // Fix 2: Kill CLIProxy processes on OAuth callback ports
- const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy
- for (const port of oauthPorts) {
- const oauthSpinner = ora(`Checking OAuth port ${port}`).start();
- try {
- const portProcess = await getPortProcess(port);
- if (portProcess && isCLIProxyProcess(portProcess)) {
- oauthSpinner.text = `Freeing OAuth port ${port}...`;
- const killed = killProcessOnPort(port, true);
- if (killed) {
- oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`);
- fixed++;
- } else {
- oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`);
- }
- } else if (portProcess) {
- oauthSpinner.info(
- `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually`
- );
- } else {
- oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`);
- }
- } catch (_err) {
- oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`);
- }
- }
-
- // Fix 3: Regenerate outdated CLIProxy config
- const configSpinner = ora('Checking CLIProxy config version').start();
- try {
- if (configNeedsRegeneration()) {
- configSpinner.text = 'Upgrading CLIProxy config...';
- regenerateConfig();
- configSpinner.succeed(
- `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}`
- );
- fixed++;
- } else {
- configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`);
- }
- } catch (err) {
- configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`);
- }
-
- // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.)
- const symlinkSpinner = ora('Checking shared settings.json symlink').start();
- const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json');
- try {
- if (fs.existsSync(sharedSettings)) {
- const stats = fs.lstatSync(sharedSettings);
- if (!stats.isSymbolicLink()) {
- symlinkSpinner.text = 'Restoring shared settings.json symlink...';
- const SharedManagerModule = await import('./shared-manager');
- const SharedManager = SharedManagerModule.default;
- const sharedManager = new SharedManager();
- sharedManager.ensureSharedDirectories();
- symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`);
- fixed++;
- } else {
- symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`);
- }
- } else {
- symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`);
- }
- } catch (err) {
- symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`);
- }
-
- // Summary
- console.log('');
- if (fixed > 0) {
- console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`));
- console.log('');
- console.log(info('Try your command again. If issues persist, run:'));
- console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`);
- } else {
- console.log(ok('No issues found that needed fixing'));
- console.log('');
- console.log(info('If you still have issues:'));
- console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`);
- console.log(
- ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs`
- );
- console.log(` 3. Restart your terminal/computer`);
- }
- console.log('');
+ await runAutoRepair();
}
/**
diff --git a/src/management/index.ts b/src/management/index.ts
new file mode 100644
index 00000000..7fe5a372
--- /dev/null
+++ b/src/management/index.ts
@@ -0,0 +1,43 @@
+/**
+ * Management Module Barrel Export
+ *
+ * Provides system management functionality including health checks,
+ * diagnostics, instance management, and repair utilities.
+ */
+
+// Manager classes (default exports)
+export { default as Doctor } from './doctor';
+export { default as InstanceManager } from './instance-manager';
+export { default as RecoveryManager } from './recovery-manager';
+export { default as SharedManager } from './shared-manager';
+
+// Environment diagnostics
+export {
+ type EnvironmentDiagnostics,
+ getEnvironmentDiagnostics,
+ shouldUseHeadlessAuth,
+ formatEnvironmentDiagnostics,
+} from './environment-diagnostics';
+
+// OAuth port diagnostics
+export {
+ OAUTH_CALLBACK_PORTS,
+ OAUTH_FLOW_TYPES,
+ type OAuthFlowType,
+ type OAuthPortDiagnostic,
+ type EnhancedPreflightResult,
+ type PreflightCheck,
+ checkOAuthPort,
+ checkAllOAuthPorts,
+ checkAuthCodePorts,
+ getPortConflicts,
+ formatOAuthPortDiagnostics,
+ preflightOAuthCheck,
+ enhancedPreflightOAuthCheck,
+} from './oauth-port-diagnostics';
+
+// Health checks
+export * from './checks';
+
+// Repair utilities
+export * from './repair';
diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts
new file mode 100644
index 00000000..882bb3b1
--- /dev/null
+++ b/src/management/repair/auto-repair.ts
@@ -0,0 +1,153 @@
+/**
+ * Auto-Repair Module - Fix common issues automatically
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ok, warn, fail, info, header, color } from '../../utils/ui';
+import {
+ CLIPROXY_DEFAULT_PORT,
+ configNeedsRegeneration,
+ regenerateConfig,
+ CLIPROXY_CONFIG_VERSION,
+} from '../../cliproxy';
+import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
+import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands';
+import { createSpinner } from '../checks/types';
+
+const ora = createSpinner();
+
+/**
+ * Fix detected issues automatically
+ * Fixes:
+ * 1. Zombie CLIProxy processes blocking ports
+ * 2. Outdated CLIProxy config files
+ * 3. Shared symlinks broken by Claude CLI's atomic writes
+ * 4. OAuth callback ports blocked by CLIProxy
+ */
+export async function runAutoRepair(): Promise {
+ const homedir = os.homedir();
+
+ console.log('');
+ console.log(header('AUTO-FIX MODE'));
+ console.log('');
+ console.log(info(`Platform: ${getPlatformName()}`));
+ console.log('');
+
+ let fixed = 0;
+
+ // Fix 1: Kill zombie CLIProxy processes
+ const zombieSpinner = ora('Checking for zombie CLIProxy processes').start();
+ try {
+ // Check main CLIProxy port
+ const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
+ if (portProcess && isCLIProxyProcess(portProcess)) {
+ zombieSpinner.text = 'Killing zombie CLIProxy process...';
+ const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true);
+ if (killed) {
+ zombieSpinner.succeed(
+ `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}`
+ );
+ fixed++;
+ } else {
+ zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`);
+ }
+ } else if (portProcess) {
+ zombieSpinner.info(
+ `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)`
+ );
+ } else {
+ zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`);
+ }
+ } catch (err) {
+ zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`);
+ }
+
+ // Fix 2: Kill CLIProxy processes on OAuth callback ports
+ const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy
+ for (const port of oauthPorts) {
+ const oauthSpinner = ora(`Checking OAuth port ${port}`).start();
+ try {
+ const portProcess = await getPortProcess(port);
+ if (portProcess && isCLIProxyProcess(portProcess)) {
+ oauthSpinner.text = `Freeing OAuth port ${port}...`;
+ const killed = killProcessOnPort(port, true);
+ if (killed) {
+ oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`);
+ fixed++;
+ } else {
+ oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`);
+ }
+ } else if (portProcess) {
+ oauthSpinner.info(
+ `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually`
+ );
+ } else {
+ oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`);
+ }
+ } catch (_err) {
+ oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`);
+ }
+ }
+
+ // Fix 3: Regenerate outdated CLIProxy config
+ const configSpinner = ora('Checking CLIProxy config version').start();
+ try {
+ if (configNeedsRegeneration()) {
+ configSpinner.text = 'Upgrading CLIProxy config...';
+ regenerateConfig();
+ configSpinner.succeed(
+ `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}`
+ );
+ fixed++;
+ } else {
+ configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`);
+ }
+ } catch (err) {
+ configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`);
+ }
+
+ // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.)
+ const symlinkSpinner = ora('Checking shared settings.json symlink').start();
+ const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json');
+ try {
+ if (fs.existsSync(sharedSettings)) {
+ const stats = fs.lstatSync(sharedSettings);
+ if (!stats.isSymbolicLink()) {
+ symlinkSpinner.text = 'Restoring shared settings.json symlink...';
+ const SharedManagerModule = await import('../shared-manager');
+ const SharedManager = SharedManagerModule.default;
+ const sharedManager = new SharedManager();
+ sharedManager.ensureSharedDirectories();
+ symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`);
+ fixed++;
+ } else {
+ symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`);
+ }
+ } else {
+ symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`);
+ }
+ } catch (err) {
+ symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`);
+ }
+
+ // Summary
+ console.log('');
+ if (fixed > 0) {
+ console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`));
+ console.log('');
+ console.log(info('Try your command again. If issues persist, run:'));
+ console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`);
+ } else {
+ console.log(ok('No issues found that needed fixing'));
+ console.log('');
+ console.log(info('If you still have issues:'));
+ console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`);
+ console.log(
+ ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs`
+ );
+ console.log(` 3. Restart your terminal/computer`);
+ }
+ console.log('');
+}
diff --git a/src/management/repair/index.ts b/src/management/repair/index.ts
new file mode 100644
index 00000000..d495a41d
--- /dev/null
+++ b/src/management/repair/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Repair Module Registry
+ */
+
+export { runAutoRepair } from './auto-repair';
diff --git a/src/types/delegation.ts b/src/types/delegation.ts
index 25efaa79..257a2998 100644
--- a/src/types/delegation.ts
+++ b/src/types/delegation.ts
@@ -34,19 +34,8 @@ export interface DelegationSessionsRegistry {
lastSessionId?: string; // Most recent session ID
}
-/**
- * Execution result
- */
-export interface ExecutionResult {
- exitCode: number;
- duration: number; // Seconds
- workingDir: string;
- sessionId: string;
- profile: string;
- model?: string; // Model name from settings
- cost?: number; // Estimated cost (if available)
- turns: number;
-}
+// Re-export ExecutionResult from canonical location for backward compatibility
+export type { ExecutionResult } from '../delegation/executor/types';
/**
* Real-time output event
diff --git a/src/types/utils.ts b/src/types/utils.ts
index d24651ac..a9320d53 100644
--- a/src/types/utils.ts
+++ b/src/types/utils.ts
@@ -42,6 +42,36 @@ export interface TerminalInfo {
*/
export type Result = { ok: true; value: T } | { ok: false; error: E };
+/**
+ * Generic operation result for success/failure patterns
+ * Use this instead of defining new *Result interfaces
+ */
+export interface OperationResult {
+ success: boolean;
+ message?: string;
+ error?: string;
+ data?: T;
+}
+
+/**
+ * Generic component/tool status
+ * Used for CLI tools, services, etc.
+ */
+export interface ComponentStatus {
+ installed: boolean;
+ path?: string;
+ version?: string;
+}
+
+/**
+ * Generic validation result
+ * Standardized on 'valid' property name
+ */
+export interface ValidationResult {
+ valid: boolean;
+ errors?: string[];
+}
+
// =============================================================================
// UI TYPES (Phase 1: CLI UI/UX Enhancement)
// =============================================================================
diff --git a/src/utils/claude-spawner.ts b/src/utils/claude-spawner.ts
new file mode 100644
index 00000000..e6e169bd
--- /dev/null
+++ b/src/utils/claude-spawner.ts
@@ -0,0 +1,139 @@
+/**
+ * Claude Spawner Utilities
+ *
+ * Cross-platform Claude CLI spawn utilities for CCS.
+ * Handles Windows .cmd/.bat/.ps1 files properly.
+ */
+
+import { spawn, ChildProcess, SpawnOptions } from 'child_process';
+import { escapeShellArg } from './shell-executor';
+import { getClaudeCliInfo } from './claude-detector';
+import { ErrorManager } from './error-manager';
+
+export interface SpawnClaudeOptions {
+ /** Arguments to pass to Claude CLI */
+ args?: string[];
+ /** Environment variables to merge with process.env */
+ env?: NodeJS.ProcessEnv;
+ /** Working directory */
+ cwd?: string;
+ /** Stdio configuration (default: 'inherit') */
+ stdio?: SpawnOptions['stdio'];
+}
+
+export interface SpawnClaudeResult {
+ /** The spawned child process */
+ child: ChildProcess;
+ /** Path to the Claude CLI executable */
+ claudePath: string;
+}
+
+/**
+ * Spawn Claude CLI with cross-platform support.
+ *
+ * Handles Windows .cmd/.bat/.ps1 wrappers automatically.
+ * Returns the ChildProcess for custom event handling.
+ *
+ * @throws Error if Claude CLI is not found
+ */
+export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult {
+ const claudeInfo = getClaudeCliInfo();
+ if (!claudeInfo) {
+ throw new Error('Claude CLI not found');
+ }
+
+ const { path: claudeCli, needsShell } = claudeInfo;
+ const { args = [], env, cwd, stdio = 'inherit' } = options;
+
+ // Merge environment
+ const mergedEnv = env ? { ...process.env, ...env } : process.env;
+
+ let child: ChildProcess;
+ if (needsShell) {
+ // Windows .cmd/.bat/.ps1: concatenate into string to avoid DEP0190 warning
+ const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
+ child = spawn(cmdString, {
+ stdio,
+ windowsHide: true,
+ shell: true,
+ env: mergedEnv,
+ cwd,
+ });
+ } else {
+ // Unix or Windows native: use array form (faster, no shell overhead)
+ child = spawn(claudeCli, args, {
+ stdio,
+ windowsHide: true,
+ env: mergedEnv,
+ cwd,
+ });
+ }
+
+ return { child, claudePath: claudeCli };
+}
+
+/**
+ * Spawn Claude CLI and wait for exit.
+ *
+ * Convenience function that returns a promise resolving to the exit code.
+ */
+export function spawnClaudeSync(options: SpawnClaudeOptions = {}): Promise