Merge pull request #160 from kaitranntt/dev

refactor: complete codebase modularization (CLI + UI)
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-20 16:31:42 -05:00
committed by GitHub
292 changed files with 24154 additions and 17649 deletions
+49
View File
@@ -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
+45 -23
View File
@@ -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**
![Analytics Light](assets/screenshots/analytics-light.png)
![Analytics](assets/screenshots/analytics.webp)
![Analytics Dark](assets/screenshots/analytics.png)
**Live Auth Monitor**
**API Profiles & OAuth Providers**
![Live Auth Monitor](assets/screenshots/live-auth-monitor.webp)
![API Profiles](assets/screenshots/api_profiles.png)
**CLI Proxy API & Copilot Integration**
![CLIProxy](assets/screenshots/cliproxy.png)
![CLIProxy API](assets/screenshots/cliproxyapi.webp)
![Copilot API](assets/screenshots/copilot-api.webp)
**WebSearch Fallback**
![WebSearch](assets/screenshots/websearch.webp)
<br>
@@ -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).
<br>
<div align="center">
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

+503
View File
@@ -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 (
<div>
<ModelMappingForm values={config.mapping} onChange={...} />
</div>
);
}
// 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:
```
<type>(<scope>): <description>
```
### 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
+410
View File
@@ -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
+225
View File
@@ -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 <profile>` 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
+279
View File
@@ -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
+581
View File
@@ -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 <profile> [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
|
+---> <profile>.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/<profile>.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/<profile>.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
+1 -1
View File
@@ -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",
+8
View File
@@ -0,0 +1,8 @@
/**
* API Module Barrel Export
*
* Barrel export for API-related functionality including profile services.
*/
// Services
export * from './services';
+30
View File
@@ -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';
+122
View File
@@ -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();
}
+47
View File
@@ -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;
}
+189
View File
@@ -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<string, string>; cliproxy?: Record<string, unknown> };
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 };
}
}
+76
View File
@@ -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;
}
+38 -534
View File
@@ -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<void> {
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 <profile> [--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<void> {
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<string, ProfileMetadata> = { ...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 <profile>', '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<void> {
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 <profile> [--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<void> {
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 <profile> [--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<void> {
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 <profile>', '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<void> {
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());
}
/**
+148
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+71
View File
@@ -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<void> {
await initUI();
const { profileName } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth default <profile>', '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<void> {
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);
}
}
+22
View File
@@ -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';
+138
View File
@@ -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<void> {
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<string, ProfileMetadata> = { ...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 <profile>', '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);
}
}
+86
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+84
View File
@@ -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<void> {
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 <profile> [--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);
}
}
+66
View File
@@ -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'),
};
}
+8
View File
@@ -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';
+2 -11
View File
@@ -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();
}
+4 -12
View File
@@ -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<string, { created: string; last_used: string | null }> {
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;
}
+27 -4
View File
@@ -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<void> {
}
}
// 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);
+42 -909
View File
@@ -10,895 +10,51 @@
*
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
* 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<Record<CLIProxyProvider, number>> = {
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<CLIProxyProvider, ProviderOAuthConfig> = {
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<CLIProxyProvider, string[]> = {
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<CLIProxyProvider, string[]> = {
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<AccountInfo | null> {
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<boolean>((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} <USER>@<HOST>`, 'command')}`
);
console.log('');
}
return new Promise<AccountInfo | null>((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<boolean> {
// 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 <provider> --auth');
console.log('To logout: ccs <provider> --logout');
}
+152
View File
@@ -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<Record<CLIProxyProvider, number>> = {
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<CLIProxyProvider, ProviderOAuthConfig> = {
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<CLIProxyProvider, string[]> = {
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<CLIProxyProvider, string[]> = {
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;
}
+132
View File
@@ -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}`);
}
+38
View File
@@ -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';
+230
View File
@@ -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<boolean> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<boolean>((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<boolean> {
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<AccountInfo | null> {
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} <USER>@<HOST>`, '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<boolean> {
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;
}
+290
View File
@@ -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<void> {
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<void> {
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<AccountInfo | null> {
const {
provider,
binaryPath,
args,
tokenDir,
oauthConfig,
callbackPort,
headless,
verbose,
nickname,
} = options;
const log = (msg: string) => {
if (verbose) console.error(`[auth] ${msg}`);
};
return new Promise<AccountInfo | null>((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);
});
});
}
+260
View File
@@ -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 <provider> --auth');
console.log('To logout: ccs <provider> --logout');
}
File diff suppressed because it is too large Load Diff
+230
View File
@@ -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<void> {
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<DownloaderConfig> = {}
): Promise<DownloadResult> {
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<string> {
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<Record<string, unknown>> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+28
View File
@@ -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<void> {
if (extension === 'tar.gz') {
await extractTarGz(archivePath, destDir, verbose);
} else {
await extractZip(archivePath, destDir, verbose);
}
}
+44
View File
@@ -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';
+119
View File
@@ -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<void> {
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<typeof detectPlatform>;
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 };
}
+86
View File
@@ -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<void> {
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<string> {
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;
}
+98
View File
@@ -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<void> {
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);
});
}
+29
View File
@@ -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';
+16
View File
@@ -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';
+66
View File
@@ -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<string> {
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<ChecksumResult> {
// 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,
};
}
+142
View File
@@ -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;
}
+82
View File
@@ -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<string> {
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<UpdateCheckResult> {
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,
};
}
+95
View File
@@ -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<void> {
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}`));
});
}
+176
View File
@@ -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<LatestVersionResult> {
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<InstallResult> {
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<InstallResult> {
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();
}
+40
View File
@@ -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';
@@ -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<StopProxyResult> {
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;
}
@@ -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<string, VariantConfig> {
try {
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
const variants = unifiedConfig.cliproxy?.variants || {};
const result: Record<string, VariantConfig> = {};
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<string, VariantConfig> = {};
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<string, string>; cliproxy?: Record<string, unknown> };
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<string, string>; cliproxy?: Record<string, unknown> };
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;
}
+139
View File
@@ -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<string, VariantConfig> {
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 };
}
}
+136
View File
@@ -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;
}
+100 -482
View File
@@ -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<string, string>; cliproxy?: Record<string, unknown> };
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<void> {
await initUI();
const parsedArgs = parseArgs(args);
@@ -316,7 +93,7 @@ async function handleCreate(args: string[]): Promise<void> {
}
// 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<void> {
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<void> {
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<void> {
}
}
// 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<void> {
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<void> {
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<void> {
}
}
// Build model mapping
const models: ModelMapping = {
default: model,
opus: opusModel,
@@ -428,234 +199,102 @@ async function handleCreate(args: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
await initUI();
@@ -774,9 +394,7 @@ async function showHelp(): Promise<void> {
console.log('');
}
/**
* Main api command router
*/
/** Main api command router */
export async function handleApiCommand(args: string[]): Promise<void> {
const command = args[0];
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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';
+10
View File
@@ -449,6 +449,16 @@ export function updateUnifiedConfig(updates: Partial<UnifiedConfig>): 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.
*/
+7
View File
@@ -0,0 +1,7 @@
/**
* Barrel export for executor module
*/
export * from './types';
export { StreamBuffer, formatToolVerbose } from './stream-parser';
export { buildExecutionResult, extractSessionInfo } from './result-aggregator';
@@ -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,
};
}
+151
View File
@@ -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;
}
+124
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
}>;
};
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;
}
+102 -431
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
}>;
};
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<ExecutionResult> {
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<void>
* @private
*/
/** Sleep utility for retry backoff */
private static _sleep(ms: number): Promise<void> {
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<boolean> {
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;
}
}
+13
View File
@@ -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';
+3 -33
View File
@@ -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[];
+151
View File
@@ -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
}
}
}
},
};
}
+167
View File
@@ -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<T extends unknown[]>(
fn: (...args: T) => Promise<void>
): (...args: T) => Promise<void> {
return async (...args: T): Promise<void> => {
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);
}
}
+173
View File
@@ -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;
}
+79
View File
@@ -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, string> = {
[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);
}
+63
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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';
+183
View File
@@ -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 (/<Thinking:(On|Off)>/i.test(content) || /<Effort:(Low|Medium|High)>/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 <Thinking:On|Off>
const thinkingMatch = content.match(/<Thinking:(On|Off)>/i);
if (thinkingMatch) {
config.thinking = thinkingMatch[1].toLowerCase() === 'on';
}
// Check for <Effort:Low|Medium|High>
const effortMatch = content.match(/<Effort:(Low|Medium|High)>/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
}
}
+35
View File
@@ -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';
+161
View File
@@ -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<string, number>;
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<typeof this.localeEnforcer.injectInstruction>[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;
}
}
+202
View File
@@ -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<string, string> = {
stop: 'end_turn',
length: 'max_tokens',
tool_calls: 'tool_use',
content_filter: 'stop_sequence',
};
return mapping[openaiReason] || 'end_turn';
}
}
+308
View File
@@ -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}`);
}
}
}
+97
View File
@@ -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<string, unknown>;
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;
}
}
+185
View File
@@ -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<string, unknown>;
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<string, unknown>;
}
export interface OpenAITool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
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<string, unknown>;
}
// Accumulator block type
export interface AccumulatorBlock {
index: number;
type: string;
content: string;
stopped: boolean;
}
// Validation result
export interface ValidationResult {
checks: Record<string, boolean>;
passed: number;
total: number;
valid: boolean;
}
// Config types
export interface GlmtTransformerConfig {
defaultThinking?: boolean;
verbose?: boolean;
debugLog?: boolean;
debugMode?: boolean;
debugLogDir?: string;
explicitReasoning?: boolean;
}
+199
View File
@@ -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 <profile> to login)' }
);
}
}
}
}
/**
* Check CLIProxy port status
*/
export class CLIProxyPortChecker implements IHealthChecker {
name = 'CLIProxy Port';
async run(results: HealthCheck): Promise<void> {
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<void> {
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);
}
+168
View File
@@ -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);
}
+76
View File
@@ -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);
}
+51
View File
@@ -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';
+76
View File
@@ -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<void> {
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<void> {
const checker = new OAuthPortsChecker();
await checker.run(results);
}
+190
View File
@@ -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);
}
+218
View File
@@ -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);
}
+138
View File
@@ -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<void> {
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<string>((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<void> {
const cliChecker = new ClaudeCliChecker();
const dirChecker = new CcsDirectoryChecker();
await cliChecker.run(results);
dirChecker.run(results);
}
+114
View File
@@ -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<string, HealthCheckDetails> = {};
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> | 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: '',
}),
};
};
}
}
+23 -1097
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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';
+153
View File
@@ -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<void> {
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 <provider> --auth --verbose', 'command')} for detailed logs`
);
console.log(` 3. Restart your terminal/computer`);
}
console.log('');
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Repair Module Registry
*/
export { runAutoRepair } from './auto-repair';
+2 -13
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More