Merge pull request #160 from kaitranntt/dev
refactor: complete codebase modularization (CLI + UI)
@@ -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
|
||||
|
||||
@@ -62,17 +62,23 @@ The dashboard provides visual management for all account types:
|
||||
- **API Profiles**: Configure GLM, Kimi with your keys
|
||||
- **Health Monitor**: Real-time status across all profiles
|
||||
|
||||
**Analytics (Light/Dark Theme)**
|
||||
**Analytics Dashboard**
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
**Live Auth Monitor**
|
||||
|
||||
**API Profiles & OAuth Providers**
|
||||

|
||||
|
||||

|
||||
**CLI Proxy API & Copilot Integration**
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
**WebSearch Fallback**
|
||||
|
||||

|
||||
|
||||
<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
|
||||
|
||||
[](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)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 294 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API Module Barrel Export
|
||||
*
|
||||
* Barrel export for API-related functionality including profile services.
|
||||
*/
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '',
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Repair Module Registry
|
||||
*/
|
||||
|
||||
export { runAutoRepair } from './auto-repair';
|
||||
@@ -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
|
||||
|
||||