diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcfd1b3..05355c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +## [6.7.1-dev.1](https://github.com/kaitranntt/ccs/compare/v6.7.0...v6.7.1-dev.1) (2025-12-20) + +### Documentation + +* add comprehensive documentation suite for modular architecture ([1ffd169](https://github.com/kaitranntt/ccs/commit/1ffd169b98560bf59b03653937ff479e96b47800)) +* **readme:** update providers, websearch, and add star history ([0dc2da6](https://github.com/kaitranntt/ccs/commit/0dc2da6e5ae55c2c99de92037bdc9f1f43a3eeec)) + +### Code Refactoring + +* add barrel exports for commands and utils directories ([50c427d](https://github.com/kaitranntt/ccs/commit/50c427d339f008e628c05b3f150843425174b425)) +* add barrel exports to api/, glmt/, management/ ([6372b3d](https://github.com/kaitranntt/ccs/commit/6372b3d303fbd1eced272526b882984c784f0358)) +* **api:** extract service layer from api-command ([ecb6bb4](https://github.com/kaitranntt/ccs/commit/ecb6bb448a74c1f9bc4220c3674e9b4669093a3e)) +* **auth:** modularize auth-commands into commands/ directory ([0341f4f](https://github.com/kaitranntt/ccs/commit/0341f4f86f5598dc3a86c06d2a05e22b2af3342d)) +* **cliproxy:** extract service layer from cliproxy-command ([b49b7d1](https://github.com/kaitranntt/ccs/commit/b49b7d17b20e6f470e0315ad777f61e0f266b246)) +* **cliproxy:** modularize auth handler and binary ([5c28935](https://github.com/kaitranntt/ccs/commit/5c28935d1e893b643556daf4ff4127d543082fab)) +* **cliproxy:** modularize binary-manager into binary/ directory ([d3c94fe](https://github.com/kaitranntt/ccs/commit/d3c94fe6a2344aac6215fb12952e42ac17837daa)) +* **delegation:** modularize headless-executor into executor/ directory ([c3baaa8](https://github.com/kaitranntt/ccs/commit/c3baaa8251e2f4f54b10e68a0a5eb886ec271ace)) +* **errors:** centralize error handling infrastructure ([22dbfd9](https://github.com/kaitranntt/ccs/commit/22dbfd91c5862c91988cba6cd07eef22e6bf97bf)) +* **glmt:** modularize transformer pipeline ([cd107e3](https://github.com/kaitranntt/ccs/commit/cd107e354c0faff7582d55fadccee0135ea685fe)) +* **management:** modularize doctor health checks ([0eb2030](https://github.com/kaitranntt/ccs/commit/0eb2030dc2af6e351a88801dc42ce739208bfc2e)) +* remove unused deprecated code ([4a5b832](https://github.com/kaitranntt/ccs/commit/4a5b832a6ed5990d4621e79f17f5f81e8a0c87d1)) +* **types:** add generic types and consolidate ExecutionResult ([6c78b63](https://github.com/kaitranntt/ccs/commit/6c78b63908dd258770beb74abc25b62b56f6fcd5)) +* **ui:** add barrel exports for analytics and components root ([b911db8](https://github.com/kaitranntt/ccs/commit/b911db8b5fd66e4b4a9e1a9261dfcfa4d74bb1ba)) +* **ui:** organize health components into health/ directory ([a106aa2](https://github.com/kaitranntt/ccs/commit/a106aa2ee63178e1635df09be5bc2cf2a9da04d7)) +* **ui:** organize layout components into layout/ directory ([bef9955](https://github.com/kaitranntt/ccs/commit/bef99551230a04831e9a45c5f765ef341ee48b0d)) +* **ui:** organize shared components into shared/ directory ([3c7b0e7](https://github.com/kaitranntt/ccs/commit/3c7b0e7a651cd81fc38a907c9f480f173df5c785)) +* **ui:** remove old flat component files after reorganization ([e1fd394](https://github.com/kaitranntt/ccs/commit/e1fd3945fc146997691aea2dcac1b03e3005dd69)) +* **ui:** split account-flow-viz into account/flow-viz/ directory ([8fd35c8](https://github.com/kaitranntt/ccs/commit/8fd35c8dd619c56a7562c34172a8de3e736be4c8)) +* **ui:** split copilot-config-form into copilot/config-form/ directory ([1b1015c](https://github.com/kaitranntt/ccs/commit/1b1015cf506a55393e6a48c99711c3ffa7dac37c)) +* **ui:** split error-logs-monitor into monitoring/error-logs/ directory ([946030c](https://github.com/kaitranntt/ccs/commit/946030c8363ed5e4ac6ebe8b6860d0f8fb006c41)) +* **ui:** split profile-editor into profiles/editor/ directory ([6778c4d](https://github.com/kaitranntt/ccs/commit/6778c4d637ca936110107672ff65c02b1366a607)) +* **ui:** split provider-editor into cliproxy/provider-editor/ directory ([4bea5a3](https://github.com/kaitranntt/ccs/commit/4bea5a33468813d9382d6e7cc4270ca97fc965f8)) +* **ui:** split quick-setup-wizard into setup/wizard/ directory ([81196b0](https://github.com/kaitranntt/ccs/commit/81196b0ff14a2b119cdd55d49f8a7eeb63abc8f6)) +* **ui:** update imports to use new domain directories ([c70ba89](https://github.com/kaitranntt/ccs/commit/c70ba89b43a177907592d6dd62ef35cf39e691e0)) +* **utils:** extract formatRelativeTime to utils/time.ts ([e1f135a](https://github.com/kaitranntt/ccs/commit/e1f135a93a77f66947ac95b76017e00a5a750c5f)) +* **utils:** modularize ui.ts into ui/ directory ([c1e5ec7](https://github.com/kaitranntt/ccs/commit/c1e5ec70b5052f19dc6a2d339cd4317b44e10e34)) +* **utils:** modularize websearch-manager into websearch/ directory ([5e4fa20](https://github.com/kaitranntt/ccs/commit/5e4fa200df87861e9b078f1696b95569c570ea84)) +* **utils:** remove deprecated color and error functions from helpers.ts ([99afb3e](https://github.com/kaitranntt/ccs/commit/99afb3e011cae46369753e36b2fe1ef231c2f535)) +* **utils:** use canonical ValidationResult from types/utils ([18424cb](https://github.com/kaitranntt/ccs/commit/18424cba89120c61348d03195d00c00aa8cfcbe4)) +* **web-server:** extract usage aggregator service ([9346ff2](https://github.com/kaitranntt/ccs/commit/9346ff2be96bc5b8660774a814f1113de6b6ee36)) +* **web-server:** extract usage module to usage/ directory ([bae323c](https://github.com/kaitranntt/ccs/commit/bae323c0d35051c75cc0224a3000823c43f5d875)) +* **web-server:** modularize health-service into health/ directory ([c1f30ae](https://github.com/kaitranntt/ccs/commit/c1f30ae80076e12d1ed536d992c3dc8fad8248ca)) +* **web-server:** modularize routes into dedicated files ([a12c3d8](https://github.com/kaitranntt/ccs/commit/a12c3d800aedbfd232651cd69c9fcad7b702087d)) +* **websearch:** unify CLI status types with ComponentStatus ([a8f7dad](https://github.com/kaitranntt/ccs/commit/a8f7dad4e39cadf7453059fb982561170d7efb3b)) + +### Performance Improvements + +* **assets:** convert screenshots to WebP, add new feature images ([a9d21c2](https://github.com/kaitranntt/ccs/commit/a9d21c21f2bb94b83d829286ad0338fc81f27975)) + ## [6.7.0](https://github.com/kaitranntt/ccs/compare/v6.6.1...v6.7.0) (2025-12-19) ### Features diff --git a/README.md b/README.md index 0a6620c7..fecfb7ce 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,23 @@ The dashboard provides visual management for all account types: - **API Profiles**: Configure GLM, Kimi with your keys - **Health Monitor**: Real-time status across all profiles -**Analytics (Light/Dark Theme)** +**Analytics Dashboard** -![Analytics Light](assets/screenshots/analytics-light.png) +![Analytics](assets/screenshots/analytics.webp) -![Analytics Dark](assets/screenshots/analytics.png) +**Live Auth Monitor** -**API Profiles & OAuth Providers** +![Live Auth Monitor](assets/screenshots/live-auth-monitor.webp) -![API Profiles](assets/screenshots/api_profiles.png) +**CLI Proxy API & Copilot Integration** -![CLIProxy](assets/screenshots/cliproxy.png) +![CLIProxy API](assets/screenshots/cliproxyapi.webp) + +![Copilot API](assets/screenshots/copilot-api.webp) + +**WebSearch Fallback** + +![WebSearch](assets/screenshots/websearch.webp)
@@ -83,12 +89,17 @@ The dashboard provides visual management for all account types: | **Claude** | Subscription | `ccs` | Default, strategic planning | | **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration | | **Codex** | OAuth | `ccs codex` | Code generation | +| **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **GLM** | API Key | `ccs glm` | Cost-optimized execution | | **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode | > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. +**Powered by:** +- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - OAuth proxy for Gemini, Codex, Antigravity +- [copilot-api](https://github.com/ericc-ch/copilot-api) - GitHub Copilot API integration + > [!TIP] > **Need more?** CCS supports **any Anthropic-compatible API**. Create custom profiles for self-hosted LLMs, enterprise gateways, or alternative providers. See [API Profiles documentation](https://docs.ccs.kaitran.ca/providers/api-profiles). @@ -197,15 +208,24 @@ Without Developer Mode, CCS falls back to copying directories. ## WebSearch -Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically provides web search via CLI tools with automatic fallback. ### How It Works | Profile Type | WebSearch Method | |--------------|------------------| | Claude (native) | Anthropic WebSearch API | -| OAuth providers | MCP web-search-prime (auto-configured) | -| API profiles | MCP web-search-prime (auto-configured) | +| Third-party profiles | CLI Tool Fallback Chain | + +### CLI Tool Fallback Chain + +CCS intercepts WebSearch requests and routes them through available CLI tools: + +| Priority | Tool | Auth | Install | +|----------|------|------|---------| +| 1st | Gemini CLI | OAuth (free) | `npm install -g @google/gemini-cli` | +| 2nd | OpenCode | OAuth (free) | `curl -fsSL https://opencode.ai/install \| bash` | +| 3rd | Grok CLI | API Key | `npm install -g @vibe-kit/grok-cli` | ### Configuration @@ -213,22 +233,18 @@ Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: ```yaml websearch: - enabled: true # Enable/disable auto-config - provider: auto # auto | web-search-prime | brave | tavily - fallback: true # Enable fallback chain + enabled: true # Enable/disable (default: true) + gemini: + enabled: true # Use Gemini CLI (default: true) + model: gemini-2.5-flash # Model to use + opencode: + enabled: true # Use OpenCode as fallback + grok: + enabled: false # Requires XAI_API_KEY ``` -### Optional API Keys - -For additional search providers, set environment variables: - -```bash -export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month -export TAVILY_API_KEY="your-key" # AI-optimized search (paid) -``` - -> [!NOTE] -> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks. +> [!TIP] +> **Gemini CLI** is recommended - free OAuth authentication with 1000 requests/day. Just run `gemini` once to authenticate via browser. See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. @@ -287,8 +303,14 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). MIT License - see [LICENSE](LICENSE). +
+
+## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://www.star-history.com/#kaitranntt/ccs&type=date&legend=top-left) + --- **[ccs.kaitran.ca](https://ccs.kaitran.ca)** | [Report Issues](https://github.com/kaitranntt/ccs/issues) | [Star on GitHub](https://github.com/kaitranntt/ccs) diff --git a/assets/screenshots/analytics-light.png b/assets/screenshots/analytics-light.png deleted file mode 100644 index 3ab3f969..00000000 Binary files a/assets/screenshots/analytics-light.png and /dev/null differ diff --git a/assets/screenshots/analytics.png b/assets/screenshots/analytics.png deleted file mode 100644 index 34bd1563..00000000 Binary files a/assets/screenshots/analytics.png and /dev/null differ diff --git a/assets/screenshots/analytics.webp b/assets/screenshots/analytics.webp new file mode 100644 index 00000000..bbd9c8f5 Binary files /dev/null and b/assets/screenshots/analytics.webp differ diff --git a/assets/screenshots/api_profiles.png b/assets/screenshots/api_profiles.png deleted file mode 100644 index b6243bc1..00000000 Binary files a/assets/screenshots/api_profiles.png and /dev/null differ diff --git a/assets/screenshots/cliproxy.png b/assets/screenshots/cliproxy.png deleted file mode 100644 index a292e6a0..00000000 Binary files a/assets/screenshots/cliproxy.png and /dev/null differ diff --git a/assets/screenshots/cliproxyapi.webp b/assets/screenshots/cliproxyapi.webp new file mode 100644 index 00000000..6e84533b Binary files /dev/null and b/assets/screenshots/cliproxyapi.webp differ diff --git a/assets/screenshots/copilot-api.webp b/assets/screenshots/copilot-api.webp new file mode 100644 index 00000000..1cf85573 Binary files /dev/null and b/assets/screenshots/copilot-api.webp differ diff --git a/assets/screenshots/live-auth-monitor.webp b/assets/screenshots/live-auth-monitor.webp new file mode 100644 index 00000000..8bd4d02e Binary files /dev/null and b/assets/screenshots/live-auth-monitor.webp differ diff --git a/assets/screenshots/websearch.webp b/assets/screenshots/websearch.webp new file mode 100644 index 00000000..63bf8204 Binary files /dev/null and b/assets/screenshots/websearch.webp differ diff --git a/docs/code-standards.md b/docs/code-standards.md new file mode 100644 index 00000000..14059628 --- /dev/null +++ b/docs/code-standards.md @@ -0,0 +1,503 @@ +# CCS Code Standards + +Last Updated: 2025-12-19 + +Code standards, modularization patterns, and conventions for the CCS codebase. + +--- + +## Core Principles + +### YAGNI (You Aren't Gonna Need It) +- No features "just in case" +- Only implement what is currently needed +- Delete unused code rather than commenting it out + +### KISS (Keep It Simple, Stupid) +- Prefer simple solutions over clever ones +- Reduce complexity at every opportunity +- Use established patterns over custom implementations + +### DRY (Don't Repeat Yourself) +- One source of truth for configuration +- Extract common logic into shared utilities +- Use barrel exports to centralize imports + +--- + +## File Organization + +### Directory Structure Rules + +1. **Domain-based organization**: Group files by business domain, not by file type +2. **Barrel exports required**: Every directory must have an `index.ts` aggregating exports +3. **Flat within depth**: Keep nesting to 3 levels maximum +4. **Co-location**: Keep related files together (component + hooks + utils) + +### File Naming Conventions + +| Convention | Example | When to Use | +|------------|---------|-------------| +| kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files | +| kebab-case | `profile-detector.ts` | Multi-word file names | +| PascalCase | `BinaryManager` | Class exports only | +| camelCase | `detectProfile` | Function exports | + +**File names should be descriptive**: LLMs should understand the file's purpose from its name alone without reading content. + +### Correct Examples + +``` +src/cliproxy/binary-manager.ts # Binary management logic +src/commands/doctor-command.ts # Doctor CLI command handler +ui/src/components/cliproxy/provider-editor/index.tsx +``` + +### Incorrect Examples + +``` +src/utils/helper.ts # Too vague +src/cliproxy/manager.ts # Which manager? +ui/src/components/Editor.tsx # Not kebab-case +``` + +--- + +## File Size Limit: 200 Lines + +**Target**: All code files should be under 200 lines. + +**Exceptions** (with justification): +- Data files (model-pricing.ts, model-catalog.ts) +- Entry points with routing logic (ccs.ts) +- Complex transformation logic that cannot be meaningfully split + +### Why 200 Lines? + +1. **Context efficiency**: LLMs process smaller files faster +2. **Single responsibility**: Forces focused, testable modules +3. **Navigation**: Easier to scan and understand +4. **Maintainability**: Reduces merge conflicts + +### When Files Exceed 200 Lines + +If a file grows beyond 200 lines: + +1. **Identify extraction candidates**: + - Helper functions that could be utilities + - Constants and type definitions + - Subcomponents within React components + - Related logic that forms a cohesive unit + +2. **Create subdirectory structure**: + ``` + # Before + provider-editor.tsx (921 lines) + + # After + provider-editor/ + ├── index.tsx # Main component (200 lines) + ├── model-mapping-form.tsx + ├── endpoint-config.tsx + ├── auth-section.tsx + ├── hooks.ts + ├── types.ts + └── utils.ts + ``` + +3. **Preserve public API**: Main export remains the same through barrel export + +--- + +## Barrel Export Pattern + +### What is a Barrel Export? + +An `index.ts` file that aggregates and re-exports module contents: + +```typescript +// src/cliproxy/index.ts + +// Types (with explicit type keyword) +export type { PlatformInfo, BinaryInfo } from './types'; + +// Functions +export { detectPlatform } from './platform-detector'; +export { BinaryManager } from './binary-manager'; + +// From subdirectories +export * from './auth'; +export * from './services'; +``` + +### Rules for Barrel Exports + +1. **Every domain directory must have `index.ts`** +2. **Export types with `export type`** for tree-shaking +3. **Re-export subdirectories** for deep access +4. **Keep barrel exports flat** - no logic, only exports + +### Import Patterns + +```typescript +// CORRECT: Import from domain barrel +import { execClaudeWithCLIProxy, CLIProxyProvider } from '../cliproxy'; +import { Config, Settings } from '../types'; + +// INCORRECT: Import from specific file (bypasses barrel) +import { execClaudeWithCLIProxy } from '../cliproxy/cliproxy-executor'; +``` + +### Exception: Deep Imports + +Allowed when: +- Importing private utilities not exposed in barrel +- Circular dependency avoidance +- Performance-critical tree-shaking + +--- + +## Monster File Splitting Methodology + +When splitting large files (500+ lines), follow this process: + +### Step 1: Analyze Structure + +Identify logical boundaries: +- Render sections in React components +- Handler groups in route files +- Related utility functions +- Constants and types + +### Step 2: Extract Types First + +```typescript +// types.ts +export interface ProviderEditorProps { + providerId: string; + onSave: (config: ProviderConfig) => void; +} + +export interface ModelMappingValues { + model: string; + endpoint: string; +} +``` + +### Step 3: Extract Utilities + +```typescript +// utils.ts +export function validateEndpoint(url: string): boolean { ... } +export function formatModelName(name: string): string { ... } +``` + +### Step 4: Extract Hooks + +```typescript +// hooks.ts +export function useProviderConfig(providerId: string) { ... } +export function useModelValidation() { ... } +``` + +### Step 5: Extract Subcomponents + +```typescript +// model-mapping-form.tsx +export function ModelMappingForm({ values, onChange }: Props) { ... } +``` + +### Step 6: Compose in Index + +```typescript +// index.tsx +import { ModelMappingForm } from './model-mapping-form'; +import { useProviderConfig } from './hooks'; +import type { ProviderEditorProps } from './types'; + +export function ProviderEditor({ providerId, onSave }: ProviderEditorProps) { + const config = useProviderConfig(providerId); + return ( +
+ +
+ ); +} + +// Re-export types for consumers +export type { ProviderEditorProps, ModelMappingValues } from './types'; +``` + +--- + +## TypeScript Standards + +### Strict Mode Required + +All projects use TypeScript strict mode: + +```json +{ + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} +``` + +### Type Annotations + +```typescript +// CORRECT: Explicit return types for public functions +export function detectProfile(args: string[]): DetectedProfile { ... } + +// CORRECT: Inferred types for internal functions +const formatName = (name: string) => name.trim().toLowerCase(); + +// INCORRECT: any type +function processData(data: any) { ... } // Use unknown or proper type +``` + +### Type Exports + +```typescript +// CORRECT: Use type keyword for type-only exports +export type { Config, Settings } from './config'; + +// CORRECT: Group type exports in barrel +export type { + PlatformInfo, + BinaryInfo, + DownloadProgress, +} from './types'; +``` + +--- + +## ESLint Rules (Enforced) + +| Rule | Level | Notes | +|------|-------|-------| +| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix | +| `@typescript-eslint/no-explicit-any` | error | Use proper types | +| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions | +| `prefer-const` | error | Immutable by default | +| `no-var` | error | Use const/let | +| `eqeqeq` | error | Strict equality | +| `react-hooks/*` | recommended | (UI only) | + +--- + +## Terminal Output Standards + +### ASCII Only + +```typescript +// CORRECT +console.log('[OK] Operation successful'); +console.log('[!] Warning message'); +console.log('[X] Error occurred'); +console.log('[i] Information'); + +// INCORRECT - NO EMOJIS +console.log('Operation successful'); // NO +console.log('Warning message'); // NO +``` + +### Color Handling + +```typescript +import { colors } from '../utils/ui'; + +// Colors are TTY-aware and respect NO_COLOR +console.log(colors.green('[OK]') + ' Operation successful'); +``` + +### Box Borders + +Use ASCII box drawing for error displays: + +``` ++=====================================+ +| [X] ERROR: Configuration failed | +| | +| Details: Unable to parse config | ++=====================================+ +``` + +--- + +## React Component Standards (UI) + +### Component Structure + +```typescript +// component-name.tsx + +// 1. Imports (grouped: react, external, internal, relative) +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useProfiles } from '@/hooks'; +import { formatName } from './utils'; +import type { ComponentProps } from './types'; + +// 2. Types (if not in separate file) +interface Props { + id: string; + onSave: () => void; +} + +// 3. Component +export function ComponentName({ id, onSave }: Props) { + // Hooks first + const profiles = useProfiles(); + const [state, setState] = useState(null); + + // Handlers + const handleClick = () => { ... }; + + // Render + return ( ... ); +} +``` + +### Naming Conventions + +| Item | Convention | Example | +|------|------------|---------| +| Component files | kebab-case.tsx | `provider-editor.tsx` | +| Component exports | PascalCase | `ProviderEditor` | +| Hook files | use-*.ts | `use-profiles.ts` | +| Hook exports | useCamelCase | `useProfiles` | +| Utility files | kebab-case.ts | `path-utils.ts` | +| Utility exports | camelCase | `formatPath` | + +--- + +## Quality Gates + +### Pre-Commit Sequence + +```bash +# Main project +bun run format +bun run lint:fix +bun run validate + +# UI project (if changed) +cd ui +bun run format +bun run lint:fix +bun run validate +``` + +### Validate Runs + +| Project | Command | Checks | +|---------|---------|--------| +| Main | `bun run validate` | typecheck + lint + format:check + test | +| UI | `bun run validate` | typecheck + lint + format:check | + +--- + +## Conventional Commits + +All commits must follow conventional commit format: + +``` +(): +``` + +### Types + +| Type | When to Use | Version Bump | +|------|-------------|--------------| +| `feat` | New feature | MINOR | +| `fix` | Bug fix | PATCH | +| `perf` | Performance | PATCH | +| `docs` | Documentation | None | +| `style` | Formatting | None | +| `refactor` | Code restructure | None | +| `test` | Tests | None | +| `chore` | Maintenance | None | + +### Examples + +```bash +# Correct +git commit -m "feat(cliproxy): add OAuth token refresh" +git commit -m "fix(doctor): handle missing config gracefully" +git commit -m "refactor(ui): split provider-editor into modules" + +# Incorrect - REJECTED +git commit -m "added new feature" +git commit -m "Fixed bug" +``` + +--- + +## Anti-Patterns to Avoid + +### 1. God Files + +```typescript +// BAD: One file doing everything +// src/utils.ts (2000 lines with mixed concerns) + +// GOOD: Split by domain +// src/utils/ui/colors.ts +// src/utils/ui/boxes.ts +// src/utils/shell-executor.ts +// src/utils/config-manager.ts +``` + +### 2. Barrel Import Bypass + +```typescript +// BAD: Direct import bypassing barrel +import { detectPlatform } from '../cliproxy/platform-detector'; + +// GOOD: Import from domain barrel +import { detectPlatform } from '../cliproxy'; +``` + +### 3. Inline Everything + +```typescript +// BAD: Huge inline functions in components +function Component() { + const handleComplexOperation = () => { + // 100 lines of logic... + }; +} + +// GOOD: Extract to hooks or utilities +function Component() { + const { handleComplexOperation } = useComplexOperation(); +} +``` + +### 4. Type Duplication + +```typescript +// BAD: Same types defined in multiple files +// file1.ts +interface Config { ... } +// file2.ts +interface Config { ... } + +// GOOD: Single source of truth +// types/config.ts +export interface Config { ... } +``` + +--- + +## Related Documentation + +- [Codebase Summary](./codebase-summary.md) - Full directory structure +- [System Architecture](./system-architecture.md) - Architecture diagrams +- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md new file mode 100644 index 00000000..8f418863 --- /dev/null +++ b/docs/codebase-summary.md @@ -0,0 +1,410 @@ +# CCS Codebase Summary + +Last Updated: 2025-12-19 + +Comprehensive overview of the modularized CCS codebase structure following the Phase 5 modularization effort. + +## Repository Structure + +``` +ccs/ +├── src/ # CLI TypeScript source +├── dist/ # Compiled JavaScript (npm package) +├── lib/ # Native shell scripts (bash, PowerShell) +├── ui/ # React dashboard application +│ ├── src/ # UI source code +│ └── dist/ # Built UI bundle +├── tests/ # Test suites +├── docs/ # Documentation +└── assets/ # Static assets (logos, screenshots) +``` + +--- + +## CLI Source (`src/`) + +The main CLI is organized into domain-specific modules with barrel exports. + +### Directory Structure + +``` +src/ +├── ccs.ts # Main entry point & CLI router +├── types/ # TypeScript type definitions +│ ├── index.ts # Barrel export (aggregates all types) +│ ├── cli.ts # CLI types (ParsedArgs, ExitCode) +│ ├── config.ts # Config types (Settings, EnvVars) +│ ├── delegation.ts # Delegation types (sessions, events) +│ ├── glmt.ts # GLMT types (messages, transforms) +│ └── utils.ts # Utility types (ErrorCode, LogLevel) +│ +├── commands/ # CLI command handlers +│ ├── cliproxy-command.ts # CLIProxy subcommand handling +│ ├── doctor-command.ts # Health diagnostics +│ ├── help-command.ts # Help text generation +│ ├── install-command.ts # Install/uninstall logic +│ ├── shell-completion-command.ts +│ ├── sync-command.ts # Symlink synchronization +│ ├── update-command.ts # Self-update logic +│ └── version-command.ts # Version display +│ +├── auth/ # Authentication module +│ ├── index.ts # Barrel export +│ ├── commands/ # Auth-specific CLI commands +│ │ └── index.ts +│ ├── account-switcher.ts # Account switching logic +│ └── profile-detector.ts # Profile detection (474 lines) +│ +├── config/ # Configuration management +│ ├── index.ts # Barrel export +│ ├── unified-config-loader.ts # Central config loader (546 lines) +│ └── migration-manager.ts # Config migration logic +│ +├── cliproxy/ # CLIProxyAPI integration (heavily modularized) +│ ├── index.ts # Barrel export (137 lines, extensive) +│ ├── auth/ # OAuth handlers, token management +│ │ └── index.ts +│ ├── binary/ # Binary management +│ │ └── index.ts +│ ├── services/ # Service layer +│ │ └── index.ts +│ ├── cliproxy-executor.ts # Main executor (666 lines) +│ ├── config-generator.ts # Config file generation (531 lines) +│ ├── account-manager.ts # Account management (509 lines) +│ ├── platform-detector.ts # OS/arch detection +│ ├── binary-manager.ts # Binary download/update +│ ├── auth-handler.ts # Authentication handling +│ ├── model-catalog.ts # Provider model definitions +│ ├── model-config.ts # Model configuration +│ ├── service-manager.ts # Background service +│ ├── proxy-detector.ts # Running proxy detection +│ ├── startup-lock.ts # Race condition prevention +│ └── [more files...] +│ +├── copilot/ # GitHub Copilot integration +│ ├── index.ts # Barrel export +│ └── copilot-package-manager.ts # Package management (515 lines) +│ +├── glmt/ # GLM/GLMT integration +│ ├── index.ts # Barrel export +│ ├── pipeline/ # Processing pipeline +│ │ └── index.ts +│ ├── glmt-proxy.ts # Main proxy (675 lines) +│ └── delta-accumulator.ts # Delta processing (484 lines) +│ +├── delegation/ # Task delegation & headless execution +│ ├── index.ts # Barrel export +│ ├── executor/ # Execution engine +│ └── [delegation files...] +│ +├── errors/ # Centralized error handling +│ ├── index.ts # Barrel export +│ ├── error-handler.ts # Main error handler +│ ├── exit-codes.ts # Exit code definitions +│ └── cleanup.ts # Cleanup logic +│ +├── management/ # Doctor diagnostics +│ ├── index.ts # Barrel export +│ ├── checks/ # Diagnostic checks +│ │ └── index.ts +│ └── repair/ # Auto-repair logic +│ └── index.ts +│ +├── api/ # API utilities & services +│ ├── index.ts # Barrel export +│ └── services/ # API services +│ ├── index.ts +│ ├── profile-reader.ts +│ └── profile-writer.ts +│ +├── utils/ # Utilities (modularized into subdirs) +│ ├── index.ts # Barrel export +│ ├── ui/ # Terminal UI utilities +│ │ ├── index.ts +│ │ ├── boxes.ts # Box drawing +│ │ ├── colors.ts # Terminal colors +│ │ └── spinners.ts # Progress spinners +│ ├── websearch/ # Search tool integrations +│ │ └── index.ts +│ └── [utility files...] +│ +└── web-server/ # Express web server (heavily modularized) + ├── index.ts # Server entry & barrel export + ├── routes/ # 15+ route handlers + │ ├── index.ts + │ ├── accounts-route.ts + │ ├── auth-route.ts + │ ├── cliproxy-route.ts + │ ├── copilot-route.ts + │ ├── doctor-route.ts + │ ├── glmt-route.ts + │ ├── health-route.ts + │ ├── profiles-route.ts + │ └── [more routes...] + ├── health/ # Health check system + │ └── index.ts + ├── usage/ # Usage analytics module + │ ├── index.ts + │ ├── handlers.ts # Request handlers (633 lines) + │ ├── aggregator.ts # Data aggregation (538 lines) + │ └── data-aggregator.ts + ├── services/ # Shared services + │ └── index.ts + └── model-pricing.ts # Model cost definitions (676 lines) +``` + +### Module Categories + +| Category | Directories | Purpose | +|----------|-------------|---------| +| Core | `commands/`, `errors/` | CLI commands, error handling | +| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | +| Config | `config/`, `types/` | Configuration & type definitions | +| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations | +| Services | `web-server/`, `api/` | HTTP server, API services | +| Utilities | `utils/`, `management/` | Helpers, diagnostics | + +--- + +## UI Source (`ui/src/`) + +The React dashboard organized by domain with barrel exports at every level. + +### Directory Structure + +``` +ui/src/ +├── components/ +│ ├── index.ts # Main barrel (aggregates all domains) +│ │ +│ ├── account/ # Account management +│ │ ├── index.ts # Barrel export +│ │ ├── accounts-table.tsx +│ │ ├── add-account-dialog.tsx +│ │ └── flow-viz/ # Flow visualization (split from 1,144-line file) +│ │ ├── index.tsx # Main component (200 lines) +│ │ ├── account-card.tsx +│ │ ├── account-card-stats.tsx +│ │ ├── connection-timeline.tsx +│ │ ├── flow-paths.tsx +│ │ ├── flow-viz-header.tsx +│ │ ├── provider-card.tsx +│ │ ├── hooks.ts +│ │ ├── types.ts +│ │ ├── utils.ts +│ │ ├── path-utils.ts +│ │ └── zone-utils.ts +│ │ +│ ├── analytics/ # Usage charts, stats cards +│ │ ├── index.ts +│ │ ├── cliproxy-stats-card.tsx +│ │ └── usage-trend-chart.tsx +│ │ +│ ├── cliproxy/ # CLIProxy configuration +│ │ ├── index.ts # Barrel export (30 lines) +│ │ ├── provider-editor/ # Split from 921-line file +│ │ │ ├── index.tsx # Main editor (250 lines) +│ │ │ └── [13 focused modules] +│ │ ├── config/ # YAML editor, file tree +│ │ │ ├── config-split-view.tsx +│ │ │ ├── diff-dialog.tsx +│ │ │ ├── file-tree.tsx +│ │ │ └── yaml-editor.tsx +│ │ ├── overview/ # Health lists, preferences +│ │ │ ├── credential-health-list.tsx +│ │ │ ├── model-preferences-grid.tsx +│ │ │ └── quick-stats-row.tsx +│ │ └── [7 top-level component files] +│ │ +│ ├── copilot/ # Copilot settings +│ │ ├── index.ts +│ │ └── config-form/ # Split from 846-line file +│ │ └── [13 focused modules] +│ │ +│ ├── health/ # System health gauges +│ │ └── index.ts +│ │ +│ ├── layout/ # App structure +│ │ ├── index.ts +│ │ ├── sidebar.tsx +│ │ └── footer.tsx +│ │ +│ ├── monitoring/ # Error logs, auth monitor +│ │ ├── index.ts +│ │ ├── auth-monitor.tsx # Auth monitoring (465 lines) +│ │ └── error-logs/ # Split from 617-line file +│ │ └── [6 focused modules] +│ │ +│ ├── profiles/ # Profile management +│ │ ├── index.ts +│ │ ├── profile-dialog.tsx +│ │ ├── profile-create-dialog.tsx +│ │ └── editor/ # Split from 531-line file +│ │ └── [10 focused modules] +│ │ +│ ├── setup/ # Quick setup wizard +│ │ ├── index.ts +│ │ └── wizard/ # Step-based wizard +│ │ ├── index.tsx +│ │ └── steps/ +│ │ +│ ├── shared/ # Reusable components (19 components) +│ │ ├── index.ts +│ │ ├── ccs-logo.tsx +│ │ ├── code-editor.tsx +│ │ ├── confirm-dialog.tsx +│ │ ├── provider-icon.tsx +│ │ ├── settings-dialog.tsx +│ │ ├── stat-card.tsx +│ │ └── [13 more shared components] +│ │ +│ └── ui/ # shadcn/ui primitives +│ ├── button.tsx +│ ├── card.tsx +│ ├── dialog.tsx +│ ├── sidebar.tsx # Custom sidebar (674 lines) +│ └── [UI primitives...] +│ +├── contexts/ # React Contexts +│ ├── privacy-context.tsx +│ ├── theme-context.tsx +│ └── websocket-context.tsx +│ +├── hooks/ # Custom hooks (domain-prefixed) +│ ├── use-accounts.ts +│ ├── use-cliproxy.ts +│ ├── use-health.ts +│ ├── use-profiles.ts +│ ├── use-websocket.ts +│ └── [more hooks...] +│ +├── lib/ # Utilities +│ ├── api.ts # API client +│ ├── model-catalogs.ts # Model definitions +│ └── utils.ts # Helper functions +│ +├── pages/ # Page components (lazy-loaded) +│ ├── analytics.tsx # Analytics dashboard (420 lines) +│ ├── api.tsx # API profiles page (350 lines) +│ ├── cliproxy.tsx # CLIProxy page (405 lines) +│ ├── copilot.tsx # Copilot page (295 lines) +│ ├── health.tsx # Health page (256 lines) +│ └── settings.tsx # Settings page (1,710 lines - TODO: split) +│ +└── providers/ # Context providers + └── websocket-provider.tsx +``` + +### Component Statistics + +| Domain | Components | Subdirs | Split Files | +|--------|------------|---------|-------------| +| account | 3 | flow-viz (12 files) | 1 monster split | +| analytics | 3 | - | - | +| cliproxy | 10 | provider-editor, config, overview | 1 monster split | +| copilot | 2 | config-form (13 files) | 1 monster split | +| health | 2 | - | - | +| layout | 3 | - | - | +| monitoring | 3 | error-logs (6 files) | 1 monster split | +| profiles | 4 | editor (10 files) | 1 monster split | +| setup | 2 | wizard/steps | - | +| shared | 19 | - | - | +| **Total** | **51+** | **8 subdirs** | **5 splits** | + +--- + +## Key File Metrics + +### Largest Files (Targets for Future Splitting) + +**CLI (`src/`):** + +| File | Lines | Status | +|------|-------|--------| +| model-pricing.ts | 676 | Data file - acceptable | +| glmt-proxy.ts | 675 | Complex - monitor | +| cliproxy-executor.ts | 666 | Core logic - acceptable | +| cliproxy-command.ts | 634 | Could split | +| usage/handlers.ts | 633 | Could split | +| ccs.ts | 596 | Entry point - acceptable | +| unified-config-loader.ts | 546 | Complex - acceptable | + +**UI (`ui/src/`):** + +| File | Lines | Status | +|------|-------|--------| +| pages/settings.tsx | 1,710 | **TODO: SPLIT** | +| components/ui/sidebar.tsx | 674 | shadcn - acceptable | +| monitoring/auth-monitor.tsx | 465 | Could split | +| pages/analytics.tsx | 420 | Could split | +| pages/cliproxy.tsx | 405 | Acceptable | + +--- + +## Import Patterns + +### Standard Import Path + +```typescript +// From any file in src/ +import { Config, Settings } from '../types'; +import { execClaudeWithCLIProxy } from '../cliproxy'; +import { handleError } from '../errors'; + +// From any file in ui/src/ +import { AccountsTable, ProviderIcon, StatCard } from '@/components'; +import { useAccounts, useProfiles } from '@/hooks'; +``` + +### Barrel Export Pattern + +Every domain directory has an `index.ts` that aggregates exports: + +```typescript +// ui/src/components/cliproxy/index.ts +export { CategorizedModelSelector } from './categorized-model-selector'; +export { CliproxyDialog } from './cliproxy-dialog'; +// ... + +// From subdirectories +export { ProviderEditor } from './provider-editor'; +export type { ProviderEditorProps } from './provider-editor'; +``` + +--- + +## Test Structure + +``` +tests/ +├── unit/ # Unit tests +│ ├── auth/ +│ ├── cliproxy/ +│ ├── config/ +│ └── utils/ +├── native/ # Native install tests +│ ├── linux/ +│ ├── macos/ +│ └── windows/ +└── npm/ # npm package tests +``` + +--- + +## Build Outputs + +| Output | Source | Purpose | +|--------|--------|---------| +| `dist/` | `src/` | npm package (CLI) | +| `dist/ui/` | `ui/src/` | Built React app (served by Express) | +| `lib/` | N/A | Native shell scripts | + +--- + +## Related Documentation + +- [Code Standards](./code-standards.md) - Modularization patterns, file size rules +- [System Architecture](./system-architecture.md) - High-level architecture diagrams +- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work +- [WebSearch](./websearch.md) - WebSearch feature documentation +- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md new file mode 100644 index 00000000..09549804 --- /dev/null +++ b/docs/project-overview-pdr.md @@ -0,0 +1,225 @@ +# CCS Product Development Requirements (PDR) + +Last Updated: 2025-12-19 + +## Product Overview + +**Product Name**: CCS (Claude Code Switch) + +**Tagline**: The universal AI profile manager for Claude Code + +**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex) with a React-based dashboard for configuration management. + +--- + +## Problem Statement + +Developers using Claude Code face these challenges: + +1. **Single Account Limitation**: Cannot run multiple Claude subscriptions simultaneously +2. **Provider Lock-in**: Stuck with Anthropic's API, cannot use alternatives +3. **No Concurrent Sessions**: Cannot work on different projects with different accounts +4. **Complex Configuration**: Manual env var and config file management + +--- + +## Solution + +CCS provides: + +1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` +2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity integration +3. **API Profiles**: GLM, Kimi, any Anthropic-compatible API +4. **Visual Dashboard**: React SPA for configuration management +5. **Automatic WebSearch**: MCP fallback for third-party providers + +--- + +## Target Users + +| User Type | Use Case | Primary Features | +|-----------|----------|------------------| +| Individual Developer | Work/personal separation | Multi-account Claude | +| Agency/Contractor | Client account isolation | Profile switching | +| Cost-conscious Dev | GLM for bulk operations | API profiles | +| Enterprise | Custom LLM integration | OpenAI-compatible endpoints | + +--- + +## Functional Requirements + +### FR-001: Profile Switching +- Switch between profiles with `ccs ` command +- Support default profile when no argument provided +- Pass through all Claude CLI arguments + +### FR-002: Multi-Account Claude +- Create isolated Claude instances +- Maintain separate sessions, todolists, logs per account +- Share commands, skills, agents across accounts + +### FR-003: OAuth Provider Integration +- Support Gemini, Codex, Antigravity OAuth flows +- Browser-based authentication +- Token caching and refresh + +### FR-004: API Profile Management +- Configure custom API endpoints +- Support Anthropic-compatible APIs +- Model mapping and configuration + +### FR-005: Dashboard UI +- Visual profile management +- Real-time health monitoring +- Usage analytics + +### FR-006: Health Diagnostics +- Verify Claude CLI installation +- Check config file integrity +- Validate symlinks and permissions + +### FR-007: WebSearch Fallback +- Auto-configure MCP web search for third-party profiles +- Support Gemini CLI, Brave, Tavily providers +- Graceful fallback chain + +--- + +## Non-Functional Requirements + +### NFR-001: Performance +- CLI startup < 100ms +- Dashboard load < 2s +- Minimal memory footprint + +### NFR-002: Reliability +- Idempotent operations +- Graceful error handling +- Automatic recovery where possible + +### NFR-003: Security +- Local-only proxy binding (127.0.0.1) +- No credential exposure in logs +- Secure token storage + +### NFR-004: Cross-Platform +- Support Linux, macOS, Windows +- Bash 3.2+, PowerShell 5.1+, Node.js 14+ +- Identical behavior across platforms + +### NFR-005: Maintainability +- Files < 200 lines +- Domain-based organization +- Barrel exports for clean imports + +--- + +## Technical Requirements + +### TR-001: Runtime Dependencies +- Node.js 14+ or Bun 1.0+ +- Claude Code CLI installed +- Internet access for OAuth/API calls + +### TR-002: Optional Dependencies +- CLIProxyAPI binary (auto-managed) +- Gemini CLI for WebSearch +- Additional MCP servers + +### TR-003: Configuration +- YAML-based config (`~/.ccs/config.yaml`) +- JSON settings per profile +- Environment variable overrides + +--- + +## Architecture Constraints + +### AC-001: CLI-First Design +- All features accessible via CLI +- Dashboard is convenience layer, not required +- Scriptable and automatable + +### AC-002: Non-Invasive +- Never modify `~/.claude/settings.json` +- Use environment variables for configuration +- Reversible changes only + +### AC-003: Proxy Pattern +- Use local proxy for provider routing +- Claude CLI communicates with localhost +- Proxy handles upstream API calls + +--- + +## Success Metrics + +| Metric | Target | Current | +|--------|--------|---------| +| Startup time | < 100ms | TBD | +| Dashboard load | < 2s | TBD | +| Error rate | < 1% | TBD | +| Test coverage | > 80% | TBD | +| File size compliance | 100% < 200 lines | 85% | + +--- + +## Release Criteria + +### v1.0 Release (Current) +- [x] Multi-account Claude support +- [x] OAuth provider integration (Gemini, Codex, AGY) +- [x] API profile management +- [x] Dashboard UI +- [x] Health diagnostics +- [x] WebSearch fallback +- [x] Cross-platform support + +### v1.1 Release (Planned) +- [ ] Settings page modularization +- [ ] Enhanced analytics +- [ ] Profile templates +- [ ] Improved error messages + +### v2.0 Release (Future) +- [ ] Team collaboration features +- [ ] Cloud sync for profiles +- [ ] Plugin system +- [ ] CLI extension framework + +--- + +## Dependencies + +### External Services +- Anthropic Claude API +- Google Gemini API +- GitHub Codex API +- Z.AI GLM API + +### Third-Party Libraries +- Express.js (web server) +- React (dashboard) +- Vite (build tool) +- shadcn/ui (UI components) +- CLIProxyAPI (proxy binary) + +--- + +## Risks and Mitigations + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Claude CLI API changes | Medium | High | Version pinning, compatibility layer | +| Provider API deprecation | Low | High | Fallback chain, multiple providers | +| OAuth token expiry | Medium | Medium | Auto-refresh, clear error messages | +| Binary compatibility | Low | Medium | Multi-platform builds, fallback | + +--- + +## Related Documentation + +- [Codebase Summary](./codebase-summary.md) - Technical structure +- [Code Standards](./code-standards.md) - Development conventions +- [System Architecture](./system-architecture.md) - Architecture diagrams +- [Project Roadmap](./project-roadmap.md) - Development phases diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md new file mode 100644 index 00000000..06801f66 --- /dev/null +++ b/docs/project-roadmap.md @@ -0,0 +1,279 @@ +# CCS Project Roadmap + +Last Updated: 2025-12-19 + +Development roadmap documenting completed modularization phases and future work. + +--- + +## Completed Phases + +### Phase 1: Type System Modularization + +**Status**: COMPLETE + +Extracted TypeScript types into dedicated `types/` directory with barrel exports. + +**Changes**: +- Created `src/types/` directory structure +- Extracted types from inline definitions into dedicated files: + - `config.ts` - Config, Settings, EnvVars + - `cli.ts` - ParsedArgs, ExitCode, ClaudeCliInfo + - `delegation.ts` - Session, execution types + - `glmt.ts` - Message transformation types + - `utils.ts` - ErrorCode, LogLevel, Result +- Created `index.ts` barrel export aggregating all types + +**Impact**: +- Centralized type definitions +- Cleaner imports across codebase +- Easier type maintenance + +--- + +### Phase 2: CLI Command Extraction + +**Status**: COMPLETE + +Extracted CLI command handlers from main `ccs.ts` into dedicated `commands/` directory. + +**Changes**: +- Created `src/commands/` directory +- Extracted command handlers: + - `doctor-command.ts` - Health diagnostics + - `help-command.ts` - Help text generation + - `install-command.ts` - Install/uninstall logic + - `shell-completion-command.ts` - Shell completions + - `sync-command.ts` - Symlink synchronization + - `update-command.ts` - Self-update logic + - `version-command.ts` - Version display + - `cliproxy-command.ts` - CLIProxy subcommands (634 lines) + +**Impact**: +- Reduced `ccs.ts` from ~1200 lines to 596 lines +- Isolated command logic for easier testing +- Clear separation of concerns + +--- + +### Phase 3: CLIProxy Modularization + +**Status**: COMPLETE + +Heavily modularized the CLIProxy integration module with internal subdirectories. + +**Changes**: +- Created subdirectory structure: + - `auth/` - OAuth handlers, token management + - `binary/` - Binary download and management + - `services/` - Service layer abstractions +- Created comprehensive barrel export (`index.ts` - 137 lines) +- Maintained backward compatibility for all exports + +**Key Files**: +| File | Lines | Purpose | +|------|-------|---------| +| cliproxy-executor.ts | 666 | Main execution logic | +| config-generator.ts | 531 | Config file generation | +| account-manager.ts | 509 | Account management | +| auth-handler.ts | - | Authentication handling | +| proxy-detector.ts | - | Running proxy detection | + +--- + +### Phase 4: Utility and Error Modularization + +**Status**: COMPLETE + +Extracted utilities and error handling into focused modules. + +**Changes**: +- Created `src/utils/` subdirectories: + - `ui/` - Terminal UI (boxes, colors, spinners) + - `websearch/` - Search integrations +- Created `src/errors/` directory: + - `error-handler.ts` - Main error handling + - `exit-codes.ts` - Exit code definitions + - `cleanup.ts` - Cleanup logic +- Created `src/management/` directory: + - `checks/` - Diagnostic checks + - `repair/` - Auto-repair logic + +--- + +### Phase 5: UI Components Modularization + +**Status**: COMPLETE + +Major UI refactoring splitting monster files into focused modules. + +**Monster Files Split**: + +| Original File | Lines | Split Into | +|---------------|-------|------------| +| account-flow-viz.tsx | 1,144 | 12 modules in `flow-viz/` | +| provider-editor.tsx | 921 | 13 modules in `provider-editor/` | +| copilot-config-form.tsx | 846 | 13 modules in `config-form/` | +| error-logs.tsx | 617 | 6 modules in `error-logs/` | +| profile-editor.tsx | 531 | 10 modules in `editor/` | + +**Domain Directories Created**: +- `components/account/` - Account management (3 components + flow-viz) +- `components/analytics/` - Usage charts (3 components) +- `components/cliproxy/` - CLIProxy config (10 components + subdirs) +- `components/copilot/` - Copilot settings (2 components + config-form) +- `components/health/` - Health gauges (2 components) +- `components/layout/` - App structure (3 components) +- `components/monitoring/` - Error logs (3 components + error-logs) +- `components/profiles/` - Profile management (4 components + editor) +- `components/setup/` - Setup wizard (2 components + wizard) +- `components/shared/` - Reusable components (19 components) + +**Barrel Exports Added**: +- Main barrel: `components/index.ts` +- Domain barrels: One `index.ts` per domain directory +- Subdirectory barrels: For split component directories + +--- + +## Current Status + +### Metrics + +| Metric | Before | After | Change | +|--------|--------|-------|--------| +| Files > 500 lines | 12 | 7 | -42% | +| UI files > 200 lines | 28 | 14 | -50% | +| Barrel exports (CLI) | 5 | 24 | +380% | +| Barrel exports (UI) | 0 | 11 | New | +| Domain directories | 4 | 15 | +275% | + +### Remaining Large Files + +**CLI (Acceptable)**: +- `model-pricing.ts` (676 lines) - Data file +- `glmt-proxy.ts` (675 lines) - Complex proxy logic +- `cliproxy-executor.ts` (666 lines) - Core execution +- `ccs.ts` (596 lines) - Entry point + +**UI (Need Attention)**: +- `pages/settings.tsx` (1,710 lines) - **HIGH PRIORITY SPLIT** +- `components/ui/sidebar.tsx` (674 lines) - shadcn component +- `monitoring/auth-monitor.tsx` (465 lines) - Could split +- `pages/analytics.tsx` (420 lines) - Could split + +--- + +## Future Work + +### Priority 1: Settings Page Split + +**Target**: Split `pages/settings.tsx` (1,710 lines) + +**Proposed Structure**: +``` +pages/settings/ +├── index.tsx # Main layout +├── general-section.tsx # General settings +├── appearance-section.tsx # Theme, colors +├── providers-section.tsx # Provider config +├── websearch-section.tsx # WebSearch config +├── advanced-section.tsx # Advanced options +├── hooks.ts # Shared hooks +└── types.ts # Settings types +``` + +### Priority 2: Analytics Page Split + +**Target**: Split `pages/analytics.tsx` (420 lines) + +**Proposed Structure**: +``` +pages/analytics/ +├── index.tsx # Main layout +├── usage-overview.tsx # Usage summary +├── model-breakdown.tsx # Per-model stats +├── cost-analysis.tsx # Cost tracking +└── hooks.ts # Data hooks +``` + +### Priority 3: Auth Monitor Split + +**Target**: Split `monitoring/auth-monitor.tsx` (465 lines) + +**Proposed Structure**: +``` +monitoring/auth-monitor/ +├── index.tsx # Main component +├── provider-status.tsx # Provider cards +├── token-display.tsx # Token info +├── refresh-controls.tsx # Refresh actions +└── hooks.ts # Auth hooks +``` + +### Priority 4: Test Coverage + +**Target**: Add tests for modularized components + +- Unit tests for extracted utilities +- Component tests for split UI modules +- Integration tests for barrel exports +- Snapshot tests for UI components + +### Priority 5: Documentation + +**Target**: Keep documentation synchronized + +- Update codebase-summary.md on structural changes +- Document new patterns in code-standards.md +- Keep architecture diagrams current +- Add inline JSDoc comments + +--- + +## Development Milestones + +| Milestone | Status | Target Date | +|-----------|--------|-------------| +| Phase 1: Types | COMPLETE | - | +| Phase 2: Commands | COMPLETE | - | +| Phase 3: CLIProxy | COMPLETE | - | +| Phase 4: Utils/Errors | COMPLETE | - | +| Phase 5: UI Components | COMPLETE | - | +| Phase 6: Settings Split | PENDING | Q1 2026 | +| Phase 7: Remaining Splits | PENDING | Q1 2026 | +| Phase 8: Test Coverage | PENDING | Q2 2026 | + +--- + +## Success Criteria + +### Code Quality + +- [ ] All files under 200 lines (except documented exceptions) +- [ ] Every directory has barrel export +- [ ] No circular dependencies +- [ ] TypeScript strict mode passing + +### Maintainability + +- [ ] Clear domain boundaries +- [ ] Consistent naming conventions +- [ ] Comprehensive documentation +- [ ] Easy navigation for new developers + +### Developer Experience + +- [ ] Fast build times +- [ ] Efficient tree-shaking +- [ ] Clear import paths +- [ ] Minimal cognitive load + +--- + +## Related Documentation + +- [Codebase Summary](./codebase-summary.md) - Current structure +- [Code Standards](./code-standards.md) - Patterns and conventions +- [System Architecture](./system-architecture.md) - Architecture diagrams +- [CLAUDE.md](../CLAUDE.md) - AI development guidance diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 00000000..bec06777 --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,581 @@ +# CCS System Architecture + +Last Updated: 2025-12-19 + +High-level architecture documentation for the CCS (Claude Code Switch) system. + +--- + +## System Overview + +CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex). It consists of two main components: + +1. **CLI Application** (`src/`) - Node.js TypeScript CLI +2. **Dashboard UI** (`ui/`) - React web application served by Express + +``` ++===========================================================================+ +| CCS System | ++===========================================================================+ +| | +| +------------------+ +-----------------+ +----------------+ | +| | User Terminal | ---> | CCS CLI | ---> | Claude Code | | +| | (ccs command) | | (src/ccs.ts) | | CLI | | +| +------------------+ +-----------------+ +----------------+ | +| | | | +| v v | +| +------------------+ +-----------------+ +----------------+ | +| | Dashboard UI | <--> | Express | ---> | Provider APIs | | +| | (React SPA) | | Web Server | | (Claude/GLM/ | | +| +------------------+ +-----------------+ | Gemini/etc) | | +| | +----------------+ | +| v | +| +-----------------+ | +| | CLIProxyAPI | | +| | (Binary) | | +| +-----------------+ | +| | ++===========================================================================+ +``` + +--- + +## Component Architecture + +### CLI Layer + +``` ++===========================================================================+ +| CLI Architecture | ++===========================================================================+ + + User Input (ccs [args]) + | + v + +-------------+ + | ccs.ts | Entry point, command routing + +-------------+ + | + +---> [Version/Help/Doctor/etc.] ---> Exit + | + v + +-------------+ + | Profile | Determines execution path + | Detection | + +-------------+ + | + +---> [Native Claude Account] ---> execClaude() + | | + +---> [CLIProxy Provider] ---> execClaudeWithCLIProxy() + | | + +---> [GLMT Profile] ---> execClaudeWithProxy() + | + v + +-------------+ + | Claude CLI | Underlying Anthropic CLI + +-------------+ +``` + +### Profile Mechanisms (Priority Order) + +``` + Profile Resolution + | + v + 1. CLIProxy Hardcoded ----+---> gemini, codex, agy + (OAuth-based) | Zero-config OAuth providers + | + 2. CLIProxy Variants -----+---> config.cliproxy section + (User-defined) | Custom provider configurations + | + 3. Settings-based --------+---> config.profiles section + (API key profiles) | GLM, GLMT, Kimi, custom + | + 4. Account-based ---------+---> profiles.json + (Claude instances) | Isolated via CLAUDE_CONFIG_DIR + | + v + Profile Config +``` + +--- + +## Module Architecture + +### CLI Modules (`src/`) + +``` ++===========================================================================+ +| CLI Module Structure | ++===========================================================================+ + + +------------------+ +------------------+ +------------------+ + | commands/ | | auth/ | | config/ | + |------------------| |------------------| |------------------| + | doctor-command | | account-switcher | | unified-config- | + | help-command | | profile-detector | | loader | + | install-command | | commands/ | | migration-manager| + | sync-command | +------------------+ +------------------+ + | update-command | + +------------------+ + | | | + +-----------------------+------------------------+ + | + v + +------------------+ +------------------+ +------------------+ + | cliproxy/ | | copilot/ | | glmt/ | + |------------------| |------------------| |------------------| + | cliproxy-executor| | copilot-package- | | glmt-proxy | + | config-generator | | manager | | delta-accumulator| + | account-manager | | [copilot logic] | | pipeline/ | + | auth/ | +------------------+ +------------------+ + | binary/ | + | services/ | + +------------------+ + | | | + +-----------------------+------------------------+ + | + v + +------------------+ +------------------+ +------------------+ + | web-server/ | | utils/ | | errors/ | + |------------------| |------------------| |------------------| + | routes/ (15+) | | ui/ (boxes, | | error-handler | + | health/ | | colors, | | exit-codes | + | usage/ | | spinners) | | cleanup | + | services/ | | websearch/ | +------------------+ + | model-pricing | | shell-executor | + +------------------+ +------------------+ + + | + v + +------------------+ +------------------+ + | types/ | | management/ | + |------------------| |------------------| + | config.ts | | checks/ | + | cli.ts | | repair/ | + | delegation.ts | | [diagnostics] | + | glmt.ts | +------------------+ + +------------------+ +``` + +### UI Modules (`ui/src/`) + +``` ++===========================================================================+ +| UI Module Structure | ++===========================================================================+ + + +------------------+ + | pages/ | Route-level components + |------------------| + | analytics.tsx | + | api.tsx | + | cliproxy.tsx | + | copilot.tsx | + | health.tsx | + | settings.tsx | + +------------------+ + | + v + +------------------+ +------------------+ +------------------+ + | components/ | | contexts/ | | hooks/ | + |------------------| |------------------| |------------------| + | account/ | | privacy-context | | use-accounts | + | analytics/ | | theme-context | | use-cliproxy | + | cliproxy/ | | websocket-context| | use-health | + | copilot/ | +------------------+ | use-profiles | + | health/ | | use-websocket | + | layout/ | +------------------+ + | monitoring/ | + | profiles/ | + | setup/ | + | shared/ | + | ui/ (shadcn) | + +------------------+ + | + v + +------------------+ +------------------+ + | lib/ | | providers/ | + |------------------| |------------------| + | api.ts | | websocket- | + | model-catalogs | | provider | + | utils.ts | +------------------+ + +------------------+ +``` + +--- + +## Data Flow Architecture + +### CLI Execution Flow + +``` ++===========================================================================+ +| CLI Execution Flow | ++===========================================================================+ + + 1. Parse Arguments + | + v + 2. Detect Profile Type + | + +---> Native Claude ---> 3a. Load Account Settings + | | + | v + | 4a. Set CLAUDE_CONFIG_DIR + | | + | v + | 5a. Spawn Claude CLI + | + +---> CLIProxy -------> 3b. Ensure Binary Installed + | | + | v + | 4b. Generate Config + | | + | v + | 5b. Start CLIProxyAPI + | | + | v + | 6b. Set Proxy Env Vars + | | + | v + | 7b. Spawn Claude CLI + | + +---> GLMT -----------> 3c. Start Embedded Proxy + | + v + 4c. Spawn Claude CLI +``` + +### Dashboard Data Flow + +``` ++===========================================================================+ +| Dashboard Data Flow | ++===========================================================================+ + + Browser (React SPA) + | + | HTTP Requests + WebSocket + v + Express Server (src/web-server/) + | + +---> /api/accounts ---> auth/account-manager + | + +---> /api/profiles ---> config/unified-config-loader + | + +---> /api/cliproxy ---> cliproxy/ + | + +---> /api/health ----> management/checks/ + | + +---> /api/usage -----> usage/aggregator + | + v + WebSocket (Real-time) + | + +---> Health status updates + +---> Auth state changes + +---> Usage analytics +``` + +--- + +## Provider Integration Architecture + +### CLIProxyAPI Flow + +``` ++===========================================================================+ +| CLIProxyAPI Integration | ++===========================================================================+ + + Claude CLI + | + | ANTHROPIC_BASE_URL = localhost:XXXX + v + +------------------+ + | CLIProxyAPI | Local proxy binary + | (binary) | + +------------------+ + | + +---> OAuth Authentication (Gemini, Codex, AGY) + | | + | v + | +------------------+ + | | OAuth Server | Browser-based auth + | +------------------+ + | + +---> Request Transformation + | | + | v + | Anthropic Format --> Provider Format + | + +---> Provider APIs + | + +---> Google (Gemini) + +---> GitHub (Codex) + +---> Antigravity + +---> OpenAI-compatible endpoints +``` + +### GLMT Proxy Flow + +``` ++===========================================================================+ +| GLMT Proxy Integration | ++===========================================================================+ + + Claude CLI + | + | ANTHROPIC_BASE_URL = localhost:XXXX + v + +------------------+ + | GLMT Proxy | Embedded Node.js proxy (src/glmt/) + | (glmt-proxy.ts)| + +------------------+ + | + v + +------------------+ + | Delta Accumulator| Stream transformation + +------------------+ + | + v + +------------------+ + | Pipeline | Request/Response transformation + +------------------+ + | + v + +------------------+ + | GLM API | Z.AI / Kimi API + +------------------+ +``` + +--- + +## Configuration Architecture + +### Config File Hierarchy + +``` ++===========================================================================+ +| Configuration Hierarchy | ++===========================================================================+ + + ~/.ccs/ + | + +---> config.yaml # Main CCS config (unified) + | + +---> profiles.json # Claude account registry + | + +---> .settings.json # Per-profile settings + | + +---> cliproxy/ + | | + | +---> config.yaml # CLIProxy configuration + | +---> auth/ # OAuth tokens + | +---> bin/ # CLIProxy binary + | + +---> shared/ # Symlinked resources + | + +---> commands/ # Claude Code commands + +---> skills/ # Custom skills + +---> agents/ # Agent configurations +``` + +### Config Loading Order + +``` + 1. Environment Variables (highest priority) + | + v + 2. CLI Arguments + | + v + 3. Profile-specific settings (~/.ccs/.settings.json) + | + v + 4. Main config (~/.ccs/config.yaml) + | + v + 5. Default values (lowest priority) +``` + +--- + +## WebSocket Architecture + +### Real-time Communication + +``` ++===========================================================================+ +| WebSocket Communication | ++===========================================================================+ + + Dashboard (React) Server (Express) + | | + |<------ Connection Established ------>| + | | + |<------ health:update ----------------| Health status + | | + |<------ auth:status ------------------| Auth changes + | | + |<------ usage:update -----------------| Usage stats + | | + |------- action:refresh -------------->| User requests + | | +``` + +--- + +## Security Architecture + +### Authentication Flow + +``` ++===========================================================================+ +| Authentication Flow | ++===========================================================================+ + + OAuth Providers (Gemini, Codex, AGY) + ----------------------------------- + + 1. User runs: ccs gemini + | + v + 2. Check token cache (~/.ccs/cliproxy/auth/) + | + +---> [Valid token] ---> Use cached token + | + +---> [No/Expired token] + | + v + 3. Open browser for OAuth + | + v + 4. Callback with auth code + | + v + 5. Exchange for access token + | + v + 6. Cache token locally + + + API Key Profiles (GLM, Kimi) + ---------------------------- + + 1. User configures API key in settings + | + v + 2. Key stored in ~/.ccs/.settings.json + | + v + 3. Key passed via ANTHROPIC_AUTH_TOKEN env var +``` + +### Security Boundaries + +``` + +------------------+ + | User Terminal | + +------------------+ + | + | Local only (no network exposure) + v + +------------------+ + | CCS CLI | + +------------------+ + | + | Localhost only (127.0.0.1) + v + +------------------+ + | CLIProxy/GLMT | Binds to localhost only + +------------------+ + | + | TLS encrypted + v + +------------------+ + | Provider APIs | External endpoints + +------------------+ +``` + +--- + +## Build and Distribution + +### Build Pipeline + +``` ++===========================================================================+ +| Build Pipeline | ++===========================================================================+ + + src/ (TypeScript) ui/src/ (React TSX) + | | + v v + TypeScript Compiler Vite Build + | | + v v + dist/ (JavaScript) dist/ui/ (Static assets) + | | + +---------------+---------------------+ + | + v + npm package (@kaitranntt/ccs) + | + v + npm registry / GitHub releases +``` + +### Package Contents + +``` + @kaitranntt/ccs + | + +---> dist/ # Compiled CLI + +---> dist/ui/ # Built dashboard + +---> lib/ # Native scripts + | +---> ccs # Bash bootstrap + | +---> ccs.ps1 # PowerShell bootstrap + +---> package.json +``` + +--- + +## Deployment Architecture + +### Local Installation + +``` + npm install -g @kaitranntt/ccs + | + v + Global node_modules + | + +---> Creates symlink: ccs --> dist/ccs.js + | + +---> First run creates: ~/.ccs/ +``` + +### Runtime Dependencies + +``` + +------------------+ +------------------+ + | Node.js 14+ | | Claude CLI | + | (required) | | (required) | + +------------------+ +------------------+ + + +------------------+ +------------------+ + | CLIProxyAPI | | Gemini CLI | + | (auto-managed) | | (optional) | + +------------------+ +------------------+ +``` + +--- + +## Related Documentation + +- [Codebase Summary](./codebase-summary.md) - Detailed directory structure +- [Code Standards](./code-standards.md) - Coding conventions +- [Project Roadmap](./project-roadmap.md) - Development phases +- [WebSearch](./websearch.md) - WebSearch feature details diff --git a/package.json b/package.json index ad1a5b39..a83e5051 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.7.0", + "version": "6.7.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 00000000..126d7063 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,8 @@ +/** + * API Module Barrel Export + * + * Barrel export for API-related functionality including profile services. + */ + +// Services +export * from './services'; diff --git a/src/api/services/index.ts b/src/api/services/index.ts new file mode 100644 index 00000000..db692f30 --- /dev/null +++ b/src/api/services/index.ts @@ -0,0 +1,30 @@ +/** + * API Services + * + * Barrel export for API-related business logic services. + */ + +// Validation services +export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service'; + +// Profile types +export { + type ModelMapping, + type ApiProfileInfo, + type CliproxyVariantInfo, + type ApiListResult, + type CreateApiProfileResult, + type RemoveApiProfileResult, +} from './profile-types'; + +// Profile read operations +export { + apiProfileExists, + isApiProfileConfigured, + listApiProfiles, + getApiProfileNames, + isUsingUnifiedConfig, +} from './profile-reader'; + +// Profile write operations +export { createApiProfile, removeApiProfile } from './profile-writer'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts new file mode 100644 index 00000000..d28b3149 --- /dev/null +++ b/src/api/services/profile-reader.ts @@ -0,0 +1,122 @@ +/** + * API Profile Reader Service + * + * Read operations for API profiles. + * Supports both unified YAML config and legacy JSON config. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadConfig } from '../../utils/config-manager'; +import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import { getProfileSecrets } from '../../config/secrets-manager'; +import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; + +/** + * Check if API profile exists in config + */ +export function apiProfileExists(name: string): boolean { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } + const config = loadConfig(); + return name in config.profiles; + } catch { + return false; + } +} + +/** + * Check if API profile has real API key (not placeholder) + */ +export function isApiProfileConfigured(apiName: string): boolean { + try { + if (isUnifiedMode()) { + const secrets = getProfileSecrets(apiName); + const token = secrets?.ANTHROPIC_AUTH_TOKEN || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); + } + // Legacy: check settings.json file + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); + if (!fs.existsSync(settingsPath)) return false; + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); + } catch { + return false; + } +} + +/** + * List all API profiles + */ +export function listApiProfiles(): ApiListResult { + const profiles: ApiProfileInfo[] = []; + const variants: CliproxyVariantInfo[] = []; + + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + for (const name of Object.keys(unifiedConfig.profiles)) { + profiles.push({ + name, + settingsPath: 'config.yaml', + isConfigured: isApiProfileConfigured(name), + configSource: 'unified', + }); + } + // CLIProxy variants + for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) { + variants.push({ + name, + provider: variant?.provider || 'unknown', + settings: variant?.settings || '-', + }); + } + } else { + const config = loadConfig(); + for (const [name, settingsPath] of Object.entries(config.profiles)) { + profiles.push({ + name, + settingsPath: settingsPath as string, + isConfigured: isApiProfileConfigured(name), + configSource: 'legacy', + }); + } + // CLIProxy variants + if (config.cliproxy) { + for (const [name, v] of Object.entries(config.cliproxy)) { + const variant = v as { provider: string; settings: string }; + variants.push({ + name, + provider: variant.provider, + settings: variant.settings, + }); + } + } + } + + return { profiles, variants }; +} + +/** + * Get list of available API profile names + */ +export function getApiProfileNames(): string[] { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return Object.keys(config.profiles); + } + const config = loadConfig(); + return Object.keys(config.profiles); +} + +/** + * Check if using unified config mode + */ +export function isUsingUnifiedConfig(): boolean { + return isUnifiedMode(); +} diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts new file mode 100644 index 00000000..75a0b76b --- /dev/null +++ b/src/api/services/profile-types.ts @@ -0,0 +1,47 @@ +/** + * API Profile Types + * + * Shared type definitions for API profile services. + */ + +/** Model mapping for API profiles */ +export interface ModelMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +/** API profile info for listing */ +export interface ApiProfileInfo { + name: string; + settingsPath: string; + isConfigured: boolean; + configSource: 'unified' | 'legacy'; +} + +/** CLIProxy variant info */ +export interface CliproxyVariantInfo { + name: string; + provider: string; + settings: string; +} + +/** Result from list operation */ +export interface ApiListResult { + profiles: ApiProfileInfo[]; + variants: CliproxyVariantInfo[]; +} + +/** Result from create operation */ +export interface CreateApiProfileResult { + success: boolean; + settingsFile: string; + error?: string; +} + +/** Result from remove operation */ +export interface RemoveApiProfileResult { + success: boolean; + error?: string; +} diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts new file mode 100644 index 00000000..b316d5d6 --- /dev/null +++ b/src/api/services/profile-writer.ts @@ -0,0 +1,189 @@ +/** + * API Profile Writer Service - Create/remove operations for API profiles. + * Supports both unified YAML config and legacy JSON config. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + isUnifiedMode, +} from '../../config/unified-config-loader'; +import { deleteAllProfileSecrets } from '../../config/secrets-manager'; +import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types'; + +/** Create settings.json file for API profile (legacy format) */ +function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): string { + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${name}.settings.json`); + + const settings = { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + return settingsPath; +} + +/** Update config.json with new API profile (legacy format) */ +function updateLegacyConfig(name: string): void { + const configPath = getConfigPath(); + const ccsDir = getCcsDir(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + const relativePath = `~/.ccs/${name}.settings.json`; + config.profiles[name] = relativePath; + + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + // Write config atomically + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** Create API profile in unified config */ +function createApiProfileUnified( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): void { + const ccsDir = path.join(os.homedir(), '.ccs'); + const settingsFile = `${name}.settings.json`; + const settingsPath = path.join(ccsDir, settingsFile); + + const settings = { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: models.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + }, + }; + + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + + const config = loadOrCreateUnifiedConfig(); + config.profiles[name] = { + type: 'api', + settings: `~/.ccs/${settingsFile}`, + }; + saveUnifiedConfig(config); +} + +/** Create a new API profile */ +export function createApiProfile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping +): CreateApiProfileResult { + try { + const settingsFile = `~/.ccs/${name}.settings.json`; + + if (isUnifiedMode()) { + createApiProfileUnified(name, baseUrl, apiKey, models); + } else { + createSettingsFile(name, baseUrl, apiKey, models); + updateLegacyConfig(name); + } + + return { success: true, settingsFile }; + } catch (error) { + return { + success: false, + settingsFile: '', + error: (error as Error).message, + }; + } +} + +/** Remove API profile from unified config */ +function removeApiProfileUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + const profile = config.profiles[name]; + + if (!profile) { + throw new Error(`API profile not found: ${name}`); + } + + // Delete the settings file if it exists + if (profile.settings) { + const settingsPath = profile.settings.replace(/^~/, os.homedir()); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + } + + delete config.profiles[name]; + + // Clear default if it was the deleted profile + if (config.default === name) { + config.default = undefined; + } + + saveUnifiedConfig(config); + + // Remove any legacy secrets + deleteAllProfileSecrets(name); +} + +/** Remove API profile from legacy config */ +function removeApiProfileLegacy(name: string): void { + const config = loadConfig(); + delete config.profiles[name]; + + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + // Remove settings file if it exists + const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(expandedPath)) { + fs.unlinkSync(expandedPath); + } +} + +/** Remove an API profile */ +export function removeApiProfile(name: string): RemoveApiProfileResult { + try { + if (isUnifiedMode()) { + removeApiProfileUnified(name); + } else { + removeApiProfileLegacy(name); + } + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts new file mode 100644 index 00000000..0973ca13 --- /dev/null +++ b/src/api/services/validation-service.ts @@ -0,0 +1,76 @@ +/** + * API Validation Service + * + * Provides validation functions for API profile names and URLs. + * Extracted from api-command.ts for reuse and testability. + */ + +import { isReservedName } from '../../config/reserved-names'; + +/** + * Validate API profile name + * @returns Error message if invalid, null if valid + */ +export function validateApiName(name: string): string | null { + if (!name) { + return 'API name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'API name must be 32 characters or less'; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Validate URL format + * @returns Error message if invalid, null if valid + */ +export function validateUrl(url: string): string | null { + if (!url) { + return 'Base URL is required'; + } + try { + new URL(url); + return null; + } catch { + return 'Invalid URL format (must include protocol, e.g., https://)'; + } +} + +/** + * Check if URL looks like it includes endpoint path (common mistake) + * @returns Warning message if problematic, null if OK + */ +export function getUrlWarning(url: string): string | null { + const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + const lowerUrl = url.toLowerCase(); + + for (const pathSuffix of problematicPaths) { + if (lowerUrl.endsWith(pathSuffix)) { + return ( + `URL ends with "${pathSuffix}" - Claude appends this automatically.\n` + + ` You likely want: ${url.replace(new RegExp(pathSuffix + '$', 'i'), '')}` + ); + } + } + return null; +} + +/** + * Sanitize URL by removing common endpoint suffixes + */ +export function sanitizeBaseUrl(url: string): string { + const suffixes = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + let sanitized = url; + for (const suffix of suffixes) { + const regex = new RegExp(suffix + '$', 'i'); + sanitized = sanitized.replace(regex, ''); + } + return sanitized; +} diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 69882696..23efc106 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -1,69 +1,37 @@ /** - * Auth Commands (Simplified) + * Auth Commands (Facade) * * CLI interface for CCS multi-account management. - * Commands: create, list, show, remove, default + * Commands: create, list, show, remove, default, reset-default * * Login-per-profile model: Each profile is an isolated Claude instance. * Users login directly in each instance (no credential copying). * - * Supports dual-mode configuration: - * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists - * - Legacy JSON format (profiles.json) as fallback + * Implementation Note: This is a facade that delegates to modular command handlers. + * See ./commands/ for individual command implementations. */ -import { spawn, ChildProcess } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; import ProfileRegistry from './profile-registry'; -import { ProfileMetadata } from '../types'; import { InstanceManager } from '../management/instance-manager'; -import { - initUI, - header, - subheader, - color, - dim, - ok, - fail, - warn, - info, - table, - infoBox, - warnBox, -} from '../utils/ui'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { escapeShellArg } from '../utils/shell-executor'; -import { InteractivePrompt } from '../utils/prompt'; +import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui'; import packageJson from '../../package.json'; -import { hasUnifiedConfig } from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; -interface AuthCommandArgs { - profileName?: string; - force?: boolean; - verbose?: boolean; - json?: boolean; - yes?: boolean; -} - -interface ProfileOutput { - name: string; - type: string; - is_default: boolean; - created: string; - last_used: string | null; - instance_path?: string; - session_count?: number; -} - -interface ListOutput { - version: string; - profiles: ProfileOutput[]; -} +// Import command handlers from modular structure +import { + type CommandContext, + handleCreate, + handleList, + handleShow, + handleRemove, + handleDefault, + handleResetDefault, +} from './commands'; /** - * Auth Commands Class + * Auth Commands Class (Facade) + * + * Maintains class API for backward compatibility while delegating + * to modular command handlers. */ class AuthCommands { private registry: ProfileRegistry; @@ -76,10 +44,14 @@ class AuthCommands { } /** - * Check if unified config mode is active + * Get command context for handlers */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); + private getContext(): CommandContext { + return { + registry: this.registry, + instanceMgr: this.instanceMgr, + version: this.version, + }; } /** @@ -144,513 +116,45 @@ class AuthCommands { } /** - * Parse command arguments - */ - private parseArgs(args: string[]): AuthCommandArgs { - const profileName = args.find((arg) => !arg.startsWith('--')); - return { - profileName, - force: args.includes('--force'), - verbose: args.includes('--verbose'), - json: args.includes('--json'), - yes: args.includes('--yes') || args.includes('-y'), - }; - } - - /** - * Create new profile and prompt for login + * Create new profile - delegates to create-command.ts */ async handleCreate(args: string[]): Promise { - await initUI(); - const { profileName, force } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); - console.log(''); - console.log('Example:'); - console.log(` ${color('ccs auth create work', 'command')}`); - process.exit(1); - } - - // Check if profile already exists (check both legacy and unified) - const existsLegacy = this.registry.hasProfile(profileName); - const existsUnified = this.registry.hasAccountUnified(profileName); - if (!force && (existsLegacy || existsUnified)) { - console.log(fail(`Profile already exists: ${profileName}`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - process.exit(1); - } - - try { - // Create instance directory - console.log(info(`Creating profile: ${profileName}`)); - const instancePath = this.instanceMgr.ensureInstance(profileName); - - // Create/update profile entry based on config mode - if (this.isUnifiedMode()) { - // Use unified config (config.yaml) - if (existsUnified) { - this.registry.touchAccountUnified(profileName); - } else { - this.registry.createAccountUnified(profileName); - } - } else { - // Use legacy profiles.json - if (existsLegacy) { - this.registry.updateProfile(profileName, { - type: 'account', - }); - } else { - this.registry.createProfile(profileName, { - type: 'account', - }); - } - } - - console.log(info(`Instance directory: ${instancePath}`)); - console.log(''); - console.log(warn('Starting Claude in isolated instance...')); - console.log(warn('You will be prompted to login with your account.')); - console.log(''); - - // Detect Claude CLI - const claudeInfo = getClaudeCliInfo(); - if (!claudeInfo) { - console.log(fail('Claude CLI not found')); - console.log(''); - console.log('Please install Claude CLI first:'); - console.log(` ${color('https://claude.ai/download', 'path')}`); - process.exit(1); - } - - const { path: claudeCli, needsShell } = claudeInfo; - - // Execute Claude in isolated instance (will auto-prompt for login if no credentials) - // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly - let child: ChildProcess; - if (needsShell) { - const cmdString = escapeShellArg(claudeCli); - child = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: true, - env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, - }); - } else { - child = spawn(claudeCli, [], { - stdio: 'inherit', - windowsHide: true, - env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, - }); - } - - child.on('exit', (code: number | null) => { - if (code === 0) { - console.log(''); - console.log( - infoBox( - `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, - 'Profile Created' - ) - ); - console.log(''); - console.log(header('Usage')); - console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); - console.log(''); - console.log( - warnBox( - `Running the command below will SWITCH your default\n` + - `CCS account to "${profileName}". After this, running\n` + - `"ccs" without a profile name will use this account.\n\n` + - ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` + - `To restore the original default, run:\n` + - ` ${color('ccs auth reset-default', 'command')}`, - 'Set as Default?' - ) - ); - console.log(''); - process.exit(0); - } else { - console.log(''); - console.log(fail('Login failed or cancelled')); - console.log(''); - console.log('To retry:'); - console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); - console.log(''); - process.exit(1); - } - }); - - child.on('error', (err: Error) => { - console.log(fail(`Failed to execute Claude CLI: ${err.message}`)); - process.exit(1); - }); - } catch (error) { - console.log(fail(`Failed to create profile: ${(error as Error).message}`)); - process.exit(1); - } + return handleCreate(this.getContext(), args); } /** - * Format relative time (e.g., "2h ago", "1d ago") - */ - private formatRelativeTime(date: Date): string { - const now = Date.now(); - const diff = now - date.getTime(); - - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return 'just now'; - } - - /** - * List all saved profiles + * List all profiles - delegates to list-command.ts */ async handleList(args: string[]): Promise { - await initUI(); - const { verbose, json } = this.parseArgs(args); - - try { - // Get profiles from both legacy (profiles.json) and unified config (config.yaml) - const legacyProfiles = this.registry.getAllProfiles(); - const unifiedAccounts = this.registry.getAllAccountsUnified(); - - // Merge profiles: unified config takes precedence - const profiles: Record = { ...legacyProfiles }; - for (const [name, account] of Object.entries(unifiedAccounts)) { - profiles[name] = { - type: 'account', - created: account.created, - last_used: account.last_used, - }; - } - - const defaultProfile = this.registry.getDefaultUnified() ?? this.registry.getDefaultProfile(); - const profileNames = Object.keys(profiles); - - // JSON output mode - if (json) { - const output: ListOutput = { - version: this.version, - profiles: profileNames.map((name) => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(name); - - return { - name: name, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath, - }; - }), - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - if (profileNames.length === 0) { - console.log(warn('No account profiles found')); - console.log(''); - console.log('To create your first profile:'); - console.log(` ${color('ccs auth create ', 'command')}`); - console.log(''); - console.log('Example:'); - console.log(` ${color('ccs auth create work', 'command')}`); - console.log(''); - return; - } - - console.log(header('Saved Account Profiles')); - console.log(''); - - // Sort by last_used (descending), then alphabetically - const sorted = profileNames.sort((a, b) => { - const aProfile = profiles[a]; - const bProfile = profiles[b]; - - // Default first - if (a === defaultProfile) return -1; - if (b === defaultProfile) return 1; - - // Then by last_used - if (aProfile.last_used && bProfile.last_used) { - return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime(); - } - if (aProfile.last_used) return -1; - if (bProfile.last_used) return 1; - - // Then alphabetically - return a.localeCompare(b); - }); - - // Build table rows - const rows: string[][] = sorted.map((name) => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - - // Status column - const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); - - // Last used column - let lastUsed = '-'; - if (profile.last_used) { - lastUsed = this.formatRelativeTime(new Date(profile.last_used)); - } - - const row = [ - color(name, isDefault ? 'primary' : 'info'), - profile.type || 'account', - status, - ]; - - if (verbose) { - row.push(lastUsed); - } - - return row; - }); - - // Headers - const headers = verbose - ? ['Profile', 'Type', 'Status', 'Last Used'] - : ['Profile', 'Type', 'Status']; - - // Print table - console.log( - table(rows, { - head: headers, - colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], - }) - ); - console.log(''); - console.log(dim(`Total: ${profileNames.length} profile(s)`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to list profiles: ${(error as Error).message}`)); - process.exit(1); - } + return handleList(this.getContext(), args); } /** - * Show details for a specific profile + * Show profile details - delegates to show-command.ts */ async handleShow(args: string[]): Promise { - await initUI(); - const { profileName, json } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); - process.exit(1); - } - - try { - const profile = this.registry.getProfile(profileName); - const defaultProfile = this.registry.getDefaultProfile(); - const isDefault = profileName === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(profileName); - - // Count sessions - let sessionCount = 0; - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter((f) => f.endsWith('.json')).length; - } - } catch (_e) { - // Ignore errors counting sessions - } - - // JSON output mode - if (json) { - const output: ProfileOutput = { - name: profileName, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath, - session_count: sessionCount, - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - const defaultBadge = isDefault ? color(' (default)', 'success') : ''; - console.log(header(`Profile: ${profileName}${defaultBadge}`)); - console.log(''); - - // Details table - const details = [ - ['Type', profile.type || 'account'], - ['Instance', instancePath], - ['Created', new Date(profile.created).toLocaleString()], - ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], - ['Sessions', `${sessionCount}`], - ]; - - console.log( - table(details, { - colWidths: [15, 45], - }) - ); - console.log(''); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } + return handleShow(this.getContext(), args); } /** - * Remove a saved profile + * Remove profile - delegates to remove-command.ts */ async handleRemove(args: string[]): Promise { - await initUI(); - const { profileName, yes } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); - process.exit(1); - } - - // Check existence in both legacy and unified - const existsLegacy = this.registry.hasProfile(profileName); - const existsUnified = this.registry.hasAccountUnified(profileName); - - if (!existsLegacy && !existsUnified) { - console.log(fail(`Profile not found: ${profileName}`)); - process.exit(1); - } - - try { - // Get instance path and session count for impact display - const instancePath = this.instanceMgr.getInstancePath(profileName); - let sessionCount = 0; - - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter((f) => f.endsWith('.json')).length; - } - } catch (_e) { - // Ignore errors counting sessions - } - - // Display impact - console.log(''); - console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`); - console.log(` Instance path: ${instancePath}`); - console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); - console.log(''); - - // Interactive confirmation (or --yes flag) - const confirmed = - yes || - (await InteractivePrompt.confirm( - 'Delete this profile?', - { default: false } // Default to NO (safe) - )); - - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - - // Delete instance - this.instanceMgr.deleteInstance(profileName); - - // Delete profile from appropriate config - if (this.isUnifiedMode() && existsUnified) { - this.registry.removeAccountUnified(profileName); - } - if (existsLegacy) { - this.registry.deleteProfile(profileName); - } - - console.log(ok(`Profile removed: ${profileName}`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to remove profile: ${(error as Error).message}`)); - process.exit(1); - } + return handleRemove(this.getContext(), args); } /** - * Set default profile + * Set default profile - delegates to default-command.ts */ async handleDefault(args: string[]): Promise { - await initUI(); - const { profileName } = this.parseArgs(args); - - if (!profileName) { - console.log(fail('Profile name is required')); - console.log(''); - console.log(`Usage: ${color('ccs auth default ', 'command')}`); - process.exit(1); - } - - try { - // Use unified or legacy based on config mode - if (this.isUnifiedMode()) { - this.registry.setDefaultUnified(profileName); - } else { - this.registry.setDefaultProfile(profileName); - } - - console.log(ok(`Default profile set: ${profileName}`)); - console.log(''); - console.log('Now you can use:'); - console.log( - ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}` - ); - console.log(''); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } + return handleDefault(this.getContext(), args); } /** - * Reset default profile (clear the custom default, restore original CCS behavior) + * Reset default profile - delegates to default-command.ts */ async handleResetDefault(): Promise { - await initUI(); - - try { - // Use unified or legacy based on config mode - if (this.isUnifiedMode()) { - this.registry.clearDefaultUnified(); - } else { - this.registry.clearDefaultProfile(); - } - - console.log(ok('Default profile cleared')); - console.log(''); - console.log('CCS will now use the original behavior:'); - console.log(` ${dim('# Uses your primary Claude account')}`); - console.log(` ${color('ccs "your prompt"', 'command')}`); - console.log(''); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } + return handleResetDefault(this.getContext()); } /** diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts new file mode 100644 index 00000000..caeb41ed --- /dev/null +++ b/src/auth/commands/create-command.ts @@ -0,0 +1,148 @@ +/** + * Create Command Handler + * + * Creates a new profile and prompts for login in an isolated Claude instance. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import { escapeShellArg } from '../../utils/shell-executor'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the create command + */ +export async function handleCreate(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, force } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); + console.log(''); + console.log('Example:'); + console.log(` ${color('ccs auth create work', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + // Check if profile already exists (check both legacy and unified) + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + if (!force && (existsLegacy || existsUnified)) { + console.log(fail(`Profile already exists: ${profileName}`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + try { + // Create instance directory + console.log(info(`Creating profile: ${profileName}`)); + const instancePath = ctx.instanceMgr.ensureInstance(profileName); + + // Create/update profile entry based on config mode + if (isUnifiedMode()) { + // Use unified config (config.yaml) + if (existsUnified) { + ctx.registry.touchAccountUnified(profileName); + } else { + ctx.registry.createAccountUnified(profileName); + } + } else { + // Use legacy profiles.json + if (existsLegacy) { + ctx.registry.updateProfile(profileName, { + type: 'account', + }); + } else { + ctx.registry.createProfile(profileName, { + type: 'account', + }); + } + } + + console.log(info(`Instance directory: ${instancePath}`)); + console.log(''); + console.log(warn('Starting Claude in isolated instance...')); + console.log(warn('You will be prompted to login with your account.')); + console.log(''); + + // Detect Claude CLI + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + console.log(fail('Claude CLI not found')); + console.log(''); + console.log('Please install Claude CLI first:'); + console.log(` ${color('https://claude.ai/download', 'path')}`); + exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); + } + + const { path: claudeCli, needsShell } = claudeInfo; + + // Execute Claude in isolated instance (will auto-prompt for login if no credentials) + // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly + let child: ChildProcess; + if (needsShell) { + const cmdString = escapeShellArg(claudeCli); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, + }); + } else { + child = spawn(claudeCli, [], { + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }, + }); + } + + child.on('exit', (code: number | null) => { + if (code === 0) { + console.log(''); + console.log( + infoBox( + `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, + 'Profile Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); + console.log(''); + console.log( + warnBox( + `Running the command below will SWITCH your default\n` + + `CCS account to "${profileName}". After this, running\n` + + `"ccs" without a profile name will use this account.\n\n` + + ` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` + + `To restore the original default, run:\n` + + ` ${color('ccs auth reset-default', 'command')}`, + 'Set as Default?' + ) + ); + console.log(''); + process.exit(0); + } else { + console.log(''); + console.log(fail('Login failed or cancelled')); + console.log(''); + console.log('To retry:'); + console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); + console.log(''); + exitWithError('Login failed or cancelled', ExitCode.AUTH_ERROR); + } + }); + + child.on('error', (err: Error) => { + exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); + }); + } catch (error) { + exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts new file mode 100644 index 00000000..7964a60f --- /dev/null +++ b/src/auth/commands/default-command.ts @@ -0,0 +1,71 @@ +/** + * Default Command Handler + * + * Sets or clears the default profile. + */ + +import { initUI, color, dim, ok, fail } from '../../utils/ui'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the default command (set default profile) + */ +export async function handleDefault(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth default ', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + // Use unified or legacy based on config mode + if (isUnifiedMode()) { + ctx.registry.setDefaultUnified(profileName); + } else { + ctx.registry.setDefaultProfile(profileName); + } + + console.log(ok(`Default profile set: ${profileName}`)); + console.log(''); + console.log('Now you can use:'); + console.log( + ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}` + ); + console.log(''); + } catch (error) { + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); + } +} + +/** + * Handle the reset-default command (clear the custom default) + */ +export async function handleResetDefault(ctx: CommandContext): Promise { + await initUI(); + + try { + // Use unified or legacy based on config mode + if (isUnifiedMode()) { + ctx.registry.clearDefaultUnified(); + } else { + ctx.registry.clearDefaultProfile(); + } + + console.log(ok('Default profile cleared')); + console.log(''); + console.log('CCS will now use the original behavior:'); + console.log(` ${dim('# Uses your primary Claude account')}`); + console.log(` ${color('ccs "your prompt"', 'command')}`); + console.log(''); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } +} diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts new file mode 100644 index 00000000..67a11be5 --- /dev/null +++ b/src/auth/commands/index.ts @@ -0,0 +1,22 @@ +/** + * Auth Commands Barrel Export + * + * Re-exports all command handlers and types from the commands module. + */ + +// Types and utilities +export { + AuthCommandArgs, + ProfileOutput, + ListOutput, + CommandContext, + parseArgs, + formatRelativeTime, +} from './types'; + +// Command handlers +export { handleCreate } from './create-command'; +export { handleList } from './list-command'; +export { handleShow } from './show-command'; +export { handleRemove } from './remove-command'; +export { handleDefault, handleResetDefault } from './default-command'; diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts new file mode 100644 index 00000000..90444c5e --- /dev/null +++ b/src/auth/commands/list-command.ts @@ -0,0 +1,138 @@ +/** + * List Command Handler + * + * Lists all saved profiles from both legacy and unified config. + */ + +import { ProfileMetadata } from '../../types'; +import { initUI, header, color, dim, warn, table } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; + +/** + * Handle the list command + */ +export async function handleList(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { verbose, json } = parseArgs(args); + + try { + // Get profiles from both legacy (profiles.json) and unified config (config.yaml) + const legacyProfiles = ctx.registry.getAllProfiles(); + const unifiedAccounts = ctx.registry.getAllAccountsUnified(); + + // Merge profiles: unified config takes precedence + const profiles: Record = { ...legacyProfiles }; + for (const [name, account] of Object.entries(unifiedAccounts)) { + profiles[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, + }; + } + + const defaultProfile = ctx.registry.getDefaultUnified() ?? ctx.registry.getDefaultProfile(); + const profileNames = Object.keys(profiles); + + // JSON output mode + if (json) { + const output: ListOutput = { + version: ctx.version, + profiles: profileNames.map((name) => { + const profile = profiles[name]; + const isDefault = name === defaultProfile; + const instancePath = ctx.instanceMgr.getInstancePath(name); + + return { + name: name, + type: profile.type || 'account', + is_default: isDefault, + created: profile.created, + last_used: profile.last_used || null, + instance_path: instancePath, + }; + }), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Human-readable output + if (profileNames.length === 0) { + console.log(warn('No account profiles found')); + console.log(''); + console.log('To create your first profile:'); + console.log(` ${color('ccs auth create ', 'command')}`); + console.log(''); + console.log('Example:'); + console.log(` ${color('ccs auth create work', 'command')}`); + console.log(''); + return; + } + + console.log(header('Saved Account Profiles')); + console.log(''); + + // Sort by last_used (descending), then alphabetically + const sorted = profileNames.sort((a, b) => { + const aProfile = profiles[a]; + const bProfile = profiles[b]; + + // Default first + if (a === defaultProfile) return -1; + if (b === defaultProfile) return 1; + + // Then by last_used + if (aProfile.last_used && bProfile.last_used) { + return new Date(bProfile.last_used).getTime() - new Date(aProfile.last_used).getTime(); + } + if (aProfile.last_used) return -1; + if (bProfile.last_used) return 1; + + // Then alphabetically + return a.localeCompare(b); + }); + + // Build table rows + const rows: string[][] = sorted.map((name) => { + const profile = profiles[name]; + const isDefault = name === defaultProfile; + + // Status column + const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); + + // Last used column + let lastUsed = '-'; + if (profile.last_used) { + lastUsed = formatRelativeTime(new Date(profile.last_used)); + } + + const row = [color(name, isDefault ? 'primary' : 'info'), profile.type || 'account', status]; + + if (verbose) { + row.push(lastUsed); + } + + return row; + }); + + // Headers + const headers = verbose + ? ['Profile', 'Type', 'Status', 'Last Used'] + : ['Profile', 'Type', 'Status']; + + // Print table + console.log( + table(rows, { + head: headers, + colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], + }) + ); + console.log(''); + console.log(dim(`Total: ${profileNames.length} profile(s)`)); + console.log(''); + } catch (error) { + exitWithError(`Failed to list profiles: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts new file mode 100644 index 00000000..cfef25cd --- /dev/null +++ b/src/auth/commands/remove-command.ts @@ -0,0 +1,86 @@ +/** + * Remove Command Handler + * + * Removes a saved profile and its instance directory. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, color, ok, fail, info } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, parseArgs } from './types'; + +/** + * Handle the remove command + */ +export async function handleRemove(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, yes } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + // Check existence in both legacy and unified + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + + if (!existsLegacy && !existsUnified) { + console.log(fail(`Profile not found: ${profileName}`)); + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + try { + // Get instance path and session count for impact display + const instancePath = ctx.instanceMgr.getInstancePath(profileName); + let sessionCount = 0; + + try { + const sessionsDir = path.join(instancePath, 'session-env'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir); + sessionCount = files.filter((f) => f.endsWith('.json')).length; + } + } catch (_e) { + // Ignore errors counting sessions + } + + // Display impact + console.log(''); + console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`); + console.log(` Instance path: ${instancePath}`); + console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); + console.log(''); + + // Interactive confirmation (or --yes flag) + const confirmed = + yes || (await InteractivePrompt.confirm('Delete this profile?', { default: false })); // Default to NO (safe) + + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + // Delete instance + ctx.instanceMgr.deleteInstance(profileName); + + // Delete profile from appropriate config + if (isUnifiedMode() && existsUnified) { + ctx.registry.removeAccountUnified(profileName); + } + if (existsLegacy) { + ctx.registry.deleteProfile(profileName); + } + + console.log(ok(`Profile removed: ${profileName}`)); + console.log(''); + } catch (error) { + exitWithError(`Failed to remove profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); + } +} diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts new file mode 100644 index 00000000..7f0c5f1a --- /dev/null +++ b/src/auth/commands/show-command.ts @@ -0,0 +1,84 @@ +/** + * Show Command Handler + * + * Shows details for a specific profile. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, header, color, fail, table } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { CommandContext, ProfileOutput, parseArgs } from './types'; + +/** + * Handle the show command + */ +export async function handleShow(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + const profile = ctx.registry.getProfile(profileName); + const defaultProfile = ctx.registry.getDefaultProfile(); + const isDefault = profileName === defaultProfile; + const instancePath = ctx.instanceMgr.getInstancePath(profileName); + + // Count sessions + let sessionCount = 0; + try { + const sessionsDir = path.join(instancePath, 'session-env'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir); + sessionCount = files.filter((f) => f.endsWith('.json')).length; + } + } catch (_e) { + // Ignore errors counting sessions + } + + // JSON output mode + if (json) { + const output: ProfileOutput = { + name: profileName, + type: profile.type || 'account', + is_default: isDefault, + created: profile.created, + last_used: profile.last_used || null, + instance_path: instancePath, + session_count: sessionCount, + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Human-readable output + const defaultBadge = isDefault ? color(' (default)', 'success') : ''; + console.log(header(`Profile: ${profileName}${defaultBadge}`)); + console.log(''); + + // Details table + const details = [ + ['Type', profile.type || 'account'], + ['Instance', instancePath], + ['Created', new Date(profile.created).toLocaleString()], + ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], + ['Sessions', `${sessionCount}`], + ]; + + console.log( + table(details, { + colWidths: [15, 45], + }) + ); + console.log(''); + } catch (error) { + exitWithError((error as Error).message, ExitCode.PROFILE_ERROR); + } +} diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts new file mode 100644 index 00000000..c1346753 --- /dev/null +++ b/src/auth/commands/types.ts @@ -0,0 +1,66 @@ +/** + * Auth Commands Type Definitions + * + * Shared interfaces for auth command modules. + */ + +import ProfileRegistry from '../profile-registry'; +import { InstanceManager } from '../../management/instance-manager'; + +// Re-export for backward compatibility +export { formatRelativeTime } from '../../utils/time'; + +/** + * Command arguments parsed from CLI + */ +export interface AuthCommandArgs { + profileName?: string; + force?: boolean; + verbose?: boolean; + json?: boolean; + yes?: boolean; +} + +/** + * Profile output for JSON mode + */ +export interface ProfileOutput { + name: string; + type: string; + is_default: boolean; + created: string; + last_used: string | null; + instance_path?: string; + session_count?: number; +} + +/** + * List output for JSON mode + */ +export interface ListOutput { + version: string; + profiles: ProfileOutput[]; +} + +/** + * Shared context passed to command handlers + */ +export interface CommandContext { + registry: ProfileRegistry; + instanceMgr: InstanceManager; + version: string; +} + +/** + * Parse command arguments from raw args array + */ +export function parseArgs(args: string[]): AuthCommandArgs { + const profileName = args.find((arg) => !arg.startsWith('--')); + return { + profileName, + force: args.includes('--force'), + verbose: args.includes('--verbose'), + json: args.includes('--json'), + yes: args.includes('--yes') || args.includes('-y'), + }; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 00000000..c7298e77 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,8 @@ +/** + * Auth module barrel export + */ + +export { default as AuthCommands } from './auth-commands'; +export { default as ProfileRegistry } from './profile-registry'; +export { default as ProfileDetector } from './profile-detector'; +export type { ProfileDetectionResult, ProfileType, AllProfiles } from './profile-detector'; diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index cefa4fbb..ee2ffafd 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -15,9 +15,8 @@ import * as os from 'os'; import { findSimilarStrings } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; -import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader'; +import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getProfileSecrets } from '../config/secrets-manager'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; @@ -79,20 +78,12 @@ class ProfileDetector { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } - /** - * Check if unified config mode is active. - * Returns true if config.yaml exists or CCS_UNIFIED_CONFIG=1. - */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); - } - /** * Load unified config if available. * Returns null if not in unified mode or load fails. */ private readUnifiedConfig(): UnifiedConfig | null { - if (!this.isUnifiedMode()) return null; + if (!isUnifiedMode()) return null; return loadUnifiedConfig(); } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e3571d8e..e0013e9c 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -3,11 +3,10 @@ import * as path from 'path'; import * as os from 'os'; import { ProfileMetadata } from '../types'; import { - hasUnifiedConfig, loadOrCreateUnifiedConfig, saveUnifiedConfig, + isUnifiedMode, } from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; /** * Profile Registry (Simplified) @@ -47,13 +46,6 @@ export class ProfileRegistry { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } - /** - * Check if unified config mode is active - */ - private isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); - } - /** * Read profiles from disk */ @@ -306,7 +298,7 @@ export class ProfileRegistry { * Check if account exists in unified config */ hasAccountUnified(name: string): boolean { - if (!this.isUnifiedMode()) return false; + if (!isUnifiedMode()) return false; const config = loadOrCreateUnifiedConfig(); return !!config.accounts[name]; } @@ -315,7 +307,7 @@ export class ProfileRegistry { * Get all accounts from unified config */ getAllAccountsUnified(): Record { - if (!this.isUnifiedMode()) return {}; + if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); return config.accounts; } @@ -324,7 +316,7 @@ export class ProfileRegistry { * Get default from unified config */ getDefaultUnified(): string | undefined { - if (!this.isUnifiedMode()) return undefined; + if (!isUnifiedMode()) return undefined; const config = loadOrCreateUnifiedConfig(); return config.default; } diff --git a/src/ccs.ts b/src/ccs.ts index 5fbd8faf..8b37d138 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,6 +13,9 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; +// Import centralized error handling +import { handleError, runCleanup } from './errors'; + // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; import { handleHelpCommand } from './commands/help-command'; @@ -566,8 +569,28 @@ async function main(): Promise { } } -// Run main -main().catch((error) => { - console.error('Fatal error:', error.message); - process.exit(1); +// ========== Global Error Handlers ========== + +// Handle uncaught exceptions +process.on('uncaughtException', (error: Error) => { + handleError(error); }); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason: unknown) => { + handleError(reason); +}); + +// Handle process termination signals for cleanup +process.on('SIGTERM', () => { + runCleanup(); + process.exit(0); +}); + +process.on('SIGINT', () => { + runCleanup(); + process.exit(130); // 128 + SIGINT(2) +}); + +// Run main +main().catch(handleError); diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 8ec68e3c..a7b3528f 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -10,895 +10,51 @@ * * Token storage: ~/.ccs/cliproxy/auth// * Each provider has its own directory to avoid conflicts. - */ - -import { execSync, spawn } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { ok, fail, info, warn, color } from '../utils/ui'; -import { ensureCLIProxyBinary } from './binary-manager'; -import { generateConfig, getProviderAuthDir } from './config-generator'; -import { CLIProxyProvider } from './types'; -import { - AccountInfo, - discoverExistingAccounts, - generateNickname, - getDefaultAccount, - getProviderAccounts, - registerAccount, - touchAccount, -} from './account-manager'; -import { - enhancedPreflightOAuthCheck, - OAUTH_CALLBACK_PORTS as OAUTH_PORTS, -} from '../management/oauth-port-diagnostics'; -import { - parseProjectList, - parseDefaultProject, - isProjectSelectionPrompt, - isProjectList, - generateSessionId, - requestProjectSelection, - type GCloudProject, - type ProjectSelectionPrompt, -} from './project-selection-handler'; - -/** - * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) - * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth * - * OAuth flow types per provider: - * - Gemini: Authorization Code Flow with local callback server on port 8085 - * - Codex: Authorization Code Flow with local callback server on port 1455 - * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Qwen: Device Code Flow (polling-based, NO callback port needed) - * - * We auto-kill processes on callback ports before OAuth to avoid conflicts. + * This file is a facade that re-exports from the auth/ submodule. + * The actual implementation is split across: + * - auth/auth-types.ts - Types and OAuth configurations + * - auth/token-manager.ts - Token storage/retrieval/validation + * - auth/oauth-handler.ts - OAuth flow handling + * - auth/environment-detector.ts - Headless detection and port management */ -const OAUTH_CALLBACK_PORTS: Partial> = { - gemini: 8085, - // codex uses 1455 - // agy uses 51121 - // qwen uses Device Code Flow - no callback port needed -}; -/** - * Kill any process using a specific port - * Used to free OAuth callback port before authentication - */ -function killProcessOnPort(port: number, verbose: boolean): boolean { - try { - if (process.platform === 'win32') { - // Windows: use netstat + taskkill - const result = execSync(`netstat -ano | findstr :${port}`, { - encoding: 'utf-8', - stdio: 'pipe', - }); - const lines = result.trim().split('\n'); - for (const line of lines) { - const parts = line.trim().split(/\s+/); - const pid = parts[parts.length - 1]; - if (pid && /^\d+$/.test(pid)) { - execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); - if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`); - } - } - return true; - } else { - // Unix: use lsof + kill - const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' }); - const pids = result - .trim() - .split('\n') - .filter((p) => p); - for (const pid of pids) { - execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); - if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`); - } - return pids.length > 0; - } - } catch { - // No process on port or command failed - that's fine - return false; - } -} - -/** - * Detect if running in a headless environment (no browser available) - * - * IMPROVED: Avoids false positives on Windows desktop environments - * where isTTY may be undefined due to terminal wrapper behavior. - * - * Case study: Vietnamese Windows users reported "command hangs" because - * their terminal (PowerShell via npm) didn't set isTTY correctly. - */ -function isHeadlessEnvironment(): boolean { - // SSH session - always headless - if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { - return true; - } - - // No display on Linux (X11/Wayland) - headless - if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - return true; - } - - // Windows desktop - NEVER headless unless SSH (already checked above) - // This fixes false positive where Windows npm wrappers don't set isTTY correctly - // Windows desktop environments always have browser capability - if (process.platform === 'win32') { - return false; - } - - // macOS - check for proper terminal - if (process.platform === 'darwin') { - // Non-interactive stdin on macOS means likely piped/scripted - if (!process.stdin.isTTY) { - return true; - } - return false; - } - - // Linux with display - check TTY - if (process.platform === 'linux') { - if (!process.stdin.isTTY) { - return true; - } - return false; - } - - // Default fallback for unknown platforms - return !process.stdin.isTTY; -} - -/** - * Auth status for a provider - */ -export interface AuthStatus { - /** Provider name */ - provider: CLIProxyProvider; - /** Whether authentication exists */ - authenticated: boolean; - /** Path to token directory */ - tokenDir: string; - /** Token file paths found */ - tokenFiles: string[]; - /** When last authenticated (if known) */ - lastAuth?: Date; - /** Accounts registered for this provider (multi-account support) */ - accounts: AccountInfo[]; - /** Default account ID */ - defaultAccount?: string; -} - -/** - * OAuth config for each provider - */ -interface ProviderOAuthConfig { - /** Provider identifier */ - provider: CLIProxyProvider; - /** Display name */ - displayName: string; - /** OAuth authorization URL (for manual flow) */ - authUrl: string; - /** Scopes required */ - scopes: string[]; - /** CLI flag for auth */ - authFlag: string; -} - -/** - * OAuth configurations per provider - * Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow - */ -const OAUTH_CONFIGS: Record = { - gemini: { - provider: 'gemini', - displayName: 'Google Gemini', - authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - scopes: ['https://www.googleapis.com/auth/generative-language'], - authFlag: '--login', - }, - codex: { - provider: 'codex', - displayName: 'Codex', - authUrl: 'https://auth.openai.com/authorize', - scopes: ['openid', 'profile'], - authFlag: '--codex-login', - }, - agy: { - provider: 'agy', - displayName: 'Antigravity', - authUrl: 'https://antigravity.ai/oauth/authorize', - scopes: ['api'], - authFlag: '--antigravity-login', - }, - qwen: { - provider: 'qwen', - displayName: 'Qwen Code', - authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code', - scopes: ['openid', 'profile', 'email', 'model.completion'], - authFlag: '--qwen-login', - }, - iflow: { - provider: 'iflow', - displayName: 'iFlow', - authUrl: 'https://iflow.cn/oauth', - scopes: ['phone', 'profile', 'email'], - authFlag: '--iflow-login', - }, -}; - -/** - * Get OAuth config for provider - */ -export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig { - const config = OAUTH_CONFIGS[provider]; - if (!config) { - throw new Error(`Unknown provider: ${provider}`); - } - return config; -} - -/** - * Get token directory for provider - */ -export function getProviderTokenDir(provider: CLIProxyProvider): string { - return getProviderAuthDir(provider); -} - -/** - * Provider-specific auth file prefixes (fallback detection) - * CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json") - * Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format - */ -const PROVIDER_AUTH_PREFIXES: Record = { - gemini: ['gemini-', 'google-'], - codex: ['codex-', 'openai-'], - agy: ['antigravity-', 'agy-'], - qwen: ['qwen-'], - iflow: ['iflow-'], -}; - -/** - * Provider type values inside token JSON files - * CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"}) - */ -const PROVIDER_TYPE_VALUES: Record = { - gemini: ['gemini'], - codex: ['codex'], - agy: ['antigravity'], - qwen: ['qwen'], - iflow: ['iflow'], -}; - -/** - * Check if a JSON file contains a token for the given provider - * Reads the file and checks the "type" field - */ -function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content); - const typeValue = (data.type || '').toLowerCase(); - const validTypes = PROVIDER_TYPE_VALUES[provider] || []; - return validTypes.includes(typeValue); - } catch { - return false; - } -} - -/** - * Check if provider has valid authentication - * CLIProxyAPI stores OAuth tokens as JSON files in the auth directory. - * Detection strategy: - * 1. First check by filename prefix (fast path) - * 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix) - */ -export function isAuthenticated(provider: CLIProxyProvider): boolean { - const tokenDir = getProviderTokenDir(provider); - - if (!fs.existsSync(tokenDir)) { - return false; - } - - const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; - - try { - const files = fs.readdirSync(tokenDir); - const jsonFiles = files.filter( - (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' - ); - - // Strategy 1: Check by filename prefix (fast path for antigravity, codex) - const prefixMatch = jsonFiles.some((f) => { - const lowerFile = f.toLowerCase(); - return validPrefixes.some((prefix) => lowerFile.startsWith(prefix)); - }); - if (prefixMatch) return true; - - // Strategy 2: Check JSON content for "type" field (needed for Gemini) - for (const f of jsonFiles) { - const filePath = path.join(tokenDir, f); - if (isTokenFileForProvider(filePath, provider)) { - return true; - } - } - - return false; - } catch { - return false; - } -} - -/** - * Get detailed auth status for provider - * Uses same detection strategy as isAuthenticated: prefix first, then content - */ -export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { - const tokenDir = getProviderTokenDir(provider); - let tokenFiles: string[] = []; - let lastAuth: Date | undefined; - - const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; - - if (fs.existsSync(tokenDir)) { - const files = fs.readdirSync(tokenDir); - const jsonFiles = files.filter( - (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' - ); - - // Check each file: by prefix OR by content - tokenFiles = jsonFiles.filter((f) => { - const lowerFile = f.toLowerCase(); - // Strategy 1: prefix match - if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) { - return true; - } - // Strategy 2: content match (for Gemini tokens without prefix) - const filePath = path.join(tokenDir, f); - return isTokenFileForProvider(filePath, provider); - }); - - // Get most recent modification time - for (const file of tokenFiles) { - const filePath = path.join(tokenDir, file); - try { - const stats = fs.statSync(filePath); - if (!lastAuth || stats.mtime > lastAuth) { - lastAuth = stats.mtime; - } - } catch { - // Skip if can't stat file - } - } - } - - // Get registered accounts for multi-account support - const accounts = getProviderAccounts(provider); - const defaultAccount = getDefaultAccount(provider); - - return { - provider, - authenticated: tokenFiles.length > 0, - tokenDir, - tokenFiles, - lastAuth, - accounts, - defaultAccount: defaultAccount?.id, - }; -} - -/** - * Get auth status for all providers - */ -export function getAllAuthStatus(): AuthStatus[] { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; - return providers.map(getAuthStatus); -} - -/** - * Clear authentication for provider - * Only removes files belonging to the specified provider (by prefix or content) - * Does NOT remove the shared auth directory or other providers' files - */ -export function clearAuth(provider: CLIProxyProvider): boolean { - const tokenDir = getProviderTokenDir(provider); - - if (!fs.existsSync(tokenDir)) { - return false; - } - - const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; - const files = fs.readdirSync(tokenDir); - let removedCount = 0; - - // Only remove files that belong to this provider - for (const file of files) { - const filePath = path.join(tokenDir, file); - const lowerFile = file.toLowerCase(); - - // Check by prefix first (fast path) - const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix)); - - // If no prefix match, check by content (for Gemini tokens without prefix) - const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider); - - if (matchesByPrefix || matchesByContent) { - try { - fs.unlinkSync(filePath); - removedCount++; - } catch { - // Failed to remove - skip - } - } - } - - // DO NOT remove the shared auth directory - other providers may still have tokens - return removedCount > 0; -} - -/** - * Display a single step status line - */ -function showStep( - step: number, - total: number, - status: 'ok' | 'fail' | 'progress', - message: string -): void { - const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]'; - console.log(`${statusIcon} [${step}/${total}] ${message}`); -} - -/** - * Get platform-specific troubleshooting for OAuth timeout - */ -function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | null): string[] { - const lines: string[] = []; - lines.push(''); - lines.push('TROUBLESHOOTING:'); - lines.push(' 1. Check browser completed auth (should show success page)'); - - if (port) { - lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); - } else { - lines.push(` 2. Try: ccs ${provider} --auth --verbose`); - } - - return lines; -} - -/** - * Trigger OAuth flow for provider - * Auto-detects headless environment and uses --no-browser flag accordingly - * Shows real-time step-by-step progress for better user feedback - * @param provider - The CLIProxy provider to authenticate - * @param options - OAuth options - * @param options.add - If true, skip confirm prompt when adding another account - * @returns Account info if successful, null otherwise - */ -export async function triggerOAuth( - provider: CLIProxyProvider, - options: { - verbose?: boolean; - headless?: boolean; - account?: string; - add?: boolean; - nickname?: string; - /** If true, triggered from Web UI (enables project selection prompt) */ - fromUI?: boolean; - } = {} -): Promise { - const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname, fromUI = false } = options; - const callbackPort = OAUTH_PORTS[provider]; - const isCLI = !fromUI; // CLI mode = auto-select default project - - // Auto-detect headless if not explicitly set - const headless = options.headless ?? isHeadlessEnvironment(); - - const log = (msg: string) => { - if (verbose) { - console.error(`[auth] ${msg}`); - } - }; - - // Check for existing accounts and prompt if --add not specified - const existingAccounts = getProviderAccounts(provider); - if (existingAccounts.length > 0 && !add) { - console.log(''); - console.log( - info( - `${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` - ) - ); - - // Import readline for confirm prompt - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const confirmed = await new Promise((resolve) => { - rl.question('[?] Add another account? (y/N): ', (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); - - if (!confirmed) { - console.log(info('Cancelled')); - return null; - } - } - - // Enhanced pre-flight check with real-time display - console.log(''); - console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`)); - - const preflight = await enhancedPreflightOAuthCheck(provider); - - // Display each check result - for (const check of preflight.checks) { - const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]'; - console.log(` ${icon} ${check.name}: ${check.message}`); - if (check.fixCommand && check.status !== 'ok') { - console.log(` Fix: ${check.fixCommand}`); - } - } - - // Show firewall warning prominently on Windows - if (preflight.firewallWarning) { - console.log(''); - console.log(warn('Windows Firewall may block OAuth callback')); - console.log(' If auth hangs, run as Administrator:'); - console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`); - } - - if (!preflight.ready) { - console.log(''); - console.log(fail('Pre-flight check failed. Resolve issues above and retry.')); - return null; - } - - console.log(''); - - // Step 1: Ensure binary exists - showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); - let binaryPath: string; - try { - binaryPath = await ensureCLIProxyBinary(verbose); - // Clear and rewrite with OK - process.stdout.write('\x1b[1A\x1b[2K'); // Move up and clear line - showStep(1, 4, 'ok', 'CLIProxy binary ready'); - } catch (error) { - process.stdout.write('\x1b[1A\x1b[2K'); - showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary'); - console.error(fail((error as Error).message)); - throw error; - } - - // Ensure auth directory exists - const tokenDir = getProviderTokenDir(provider); - fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 }); - - // Generate config file (CLIProxyAPI requires it even for auth) - const configPath = generateConfig(provider); - log(`Config generated: ${configPath}`); - - // Free OAuth callback port if needed - const localCallbackPort = OAUTH_CALLBACK_PORTS[provider]; - if (localCallbackPort) { - const killed = killProcessOnPort(localCallbackPort, verbose); - if (killed) { - log(`Freed port ${localCallbackPort} for OAuth callback`); - } - } - - // Build args: config + auth flag + optional --no-browser for headless - const args = ['--config', configPath, oauthConfig.authFlag]; - if (headless) { - args.push('--no-browser'); - } - - // Step 2: Starting callback server - showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`); - - // Show headless instructions if needed - if (headless) { - console.log(''); - console.log(warn('PORT FORWARDING REQUIRED')); - console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`); - console.log(' Run this on your LOCAL machine:'); - console.log( - ` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} @`, 'command')}` - ); - console.log(''); - } - - return new Promise((resolve) => { - // Spawn CLIProxyAPI with auth flag - // Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection) - const authProcess = spawn(binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - CLI_PROXY_AUTH_DIR: tokenDir, - }, - }); - - let stderrData = ''; - let urlDisplayed = false; - let browserOpened = false; - let projectPromptHandled = false; - let accumulatedOutput = ''; // Accumulate output to parse project list - let parsedProjects: GCloudProject[] = []; - const sessionId = generateSessionId(); // Unique session ID for this auth flow - const startTime = Date.now(); - - authProcess.stdout?.on('data', async (data: Buffer) => { - const output = data.toString(); - log(`stdout: ${output.trim()}`); - - // Accumulate output for project list parsing - accumulatedOutput += output; - - // Parse project list when available - if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) { - parsedProjects = parseProjectList(accumulatedOutput); - log(`Parsed ${parsedProjects.length} projects`); - } - - // Handle project selection prompt - if (!projectPromptHandled && isProjectSelectionPrompt(output)) { - projectPromptHandled = true; - - const defaultProjectId = parseDefaultProject(output) || ''; - - // If we have projects and this is a UI-triggered flow, request selection - if (parsedProjects.length > 0 && !isCLI) { - log(`Requesting project selection from UI (session: ${sessionId})`); - - const prompt: ProjectSelectionPrompt = { - sessionId, - provider, - projects: parsedProjects, - defaultProjectId, - supportsAll: output.includes('ALL'), - }; - - try { - // Request selection from UI (with timeout fallback to default) - const selectedId = await requestProjectSelection(prompt); - - // Write selection to stdin (empty = default, else project ID or ALL) - const response = selectedId || ''; - log(`User selected: ${response || '(default)'}`); - authProcess.stdin?.write(response + '\n'); - } catch { - // Fallback to default on error - log('Project selection failed, using default'); - authProcess.stdin?.write('\n'); - } - } else { - // CLI mode or no projects: auto-select default - log('CLI mode or no projects, auto-selecting default'); - authProcess.stdin?.write('\n'); - } - } - - // Detect when callback server starts or browser opens - if (!browserOpened && (output.includes('listening') || output.includes('http'))) { - process.stdout.write('\x1b[1A\x1b[2K'); - showStep(2, 4, 'ok', `Callback server listening on port ${callbackPort}`); - showStep(3, 4, 'progress', 'Opening browser...'); - browserOpened = true; - } - - // In headless mode, display OAuth URLs prominently - if (headless) { - const urlMatch = output.match(/https?:\/\/[^\s]+/); - if (urlMatch && !urlDisplayed) { - console.log(''); - console.log(info(`${oauthConfig.displayName} OAuth URL:`)); - console.log(` ${urlMatch[0]}`); - console.log(''); - urlDisplayed = true; - } - } - }); - - authProcess.stderr?.on('data', (data: Buffer) => { - const output = data.toString(); - stderrData += output; - log(`stderr: ${output.trim()}`); - - // Also check stderr for URLs - if (headless && !urlDisplayed) { - const urlMatch = output.match(/https?:\/\/[^\s]+/); - if (urlMatch) { - console.log(''); - console.log(info(`${oauthConfig.displayName} OAuth URL:`)); - console.log(` ${urlMatch[0]}`); - console.log(''); - urlDisplayed = true; - } - } - }); - - // After a short delay, assume browser opened and show waiting - setTimeout(() => { - if (!browserOpened) { - process.stdout.write('\x1b[1A\x1b[2K'); - showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); - showStep(3, 4, 'ok', 'Browser opened'); - browserOpened = true; - } - showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); - console.log(''); - console.log(info('Complete the login in your browser. This page will update automatically.')); - if (!verbose) { - console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose')); - } - }, 2000); - - // Timeout after 5 minutes for headless, 2 minutes for normal - const timeoutMs = headless ? 300000 : 120000; - const timeout = setTimeout(() => { - authProcess.kill(); - console.log(''); - console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); - - // Show platform-specific troubleshooting - const troubleshooting = getTimeoutTroubleshooting(provider, callbackPort); - for (const line of troubleshooting) { - console.log(line); - } - - resolve(null); - }, timeoutMs); - - authProcess.on('exit', (code) => { - clearTimeout(timeout); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - if (code === 0) { - // Verify token was created BEFORE showing success - if (isAuthenticated(provider)) { - console.log(''); - console.log(ok(`Authentication successful (${elapsed}s)`)); - - // Register the account in accounts registry - const account = registerAccountFromToken(provider, tokenDir, nickname); - resolve(account); - } else { - console.log(''); - console.log(fail('Token not found after authentication')); - console.log(''); - console.log('The browser showed success but callback was not received.'); - - // Show platform-specific guidance - if (process.platform === 'win32') { - console.log(''); - console.log('On Windows, this usually means:'); - console.log(' 1. Windows Firewall blocked the callback'); - console.log(' 2. Antivirus software blocked the connection'); - console.log(''); - console.log('Try running as Administrator:'); - console.log( - ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}` - ); - } - - console.log(''); - console.log(`Try: ccs ${provider} --auth --verbose`); - resolve(null); - } - } else { - console.log(''); - console.log(fail(`CLIProxyAPI auth exited with code ${code}`)); - if (stderrData && !urlDisplayed) { - console.log(` ${stderrData.trim().split('\n')[0]}`); - } - if (headless && !urlDisplayed) { - console.log(''); - console.log(info('No OAuth URL was displayed. Try with --verbose for details.')); - } - resolve(null); - } - }); - - authProcess.on('error', (error) => { - clearTimeout(timeout); - console.log(''); - console.log(fail(`Failed to start auth process: ${error.message}`)); - resolve(null); - }); - }); -} - -/** - * Register account from newly created token file - * Scans auth directory for new token and extracts email - * @param provider - The CLIProxy provider - * @param tokenDir - Directory containing token files - * @param nickname - Optional nickname (uses auto-generated from email if not provided) - */ -function registerAccountFromToken( - provider: CLIProxyProvider, - tokenDir: string, - nickname?: string -): AccountInfo | null { - try { - const files = fs.readdirSync(tokenDir); - const jsonFiles = files.filter((f) => f.endsWith('.json')); - - // Find newest token file for this provider - let newestFile: string | null = null; - let newestMtime = 0; - - for (const file of jsonFiles) { - const filePath = path.join(tokenDir, file); - if (!isTokenFileForProvider(filePath, provider)) continue; - - const stats = fs.statSync(filePath); - if (stats.mtimeMs > newestMtime) { - newestMtime = stats.mtimeMs; - newestFile = file; - } - } - - if (!newestFile) { - return null; - } - - // Read token to extract email - const tokenPath = path.join(tokenDir, newestFile); - const content = fs.readFileSync(tokenPath, 'utf-8'); - const data = JSON.parse(content); - const email = data.email || undefined; - - // Register the account (use provided nickname or auto-generate from email) - return registerAccount(provider, newestFile, email, nickname || generateNickname(email)); - } catch { - return null; - } -} - -/** - * Ensure provider is authenticated - * Triggers OAuth flow if not authenticated - * @param provider - The CLIProxy provider - * @param options - Auth options including optional account - * @returns true if authenticated, false otherwise - */ -export async function ensureAuth( - provider: CLIProxyProvider, - options: { verbose?: boolean; headless?: boolean; account?: string } = {} -): Promise { - // Check if already authenticated - if (isAuthenticated(provider)) { - if (options.verbose) { - console.error(`[auth] ${provider} already authenticated`); - } - // Touch the account to update last used time - const defaultAccount = getDefaultAccount(provider); - if (defaultAccount) { - touchAccount(provider, options.account || defaultAccount.id); - } - return true; - } - - // Not authenticated - trigger OAuth - const oauthConfig = getOAuthConfig(provider); - console.log(info(`${oauthConfig.displayName} authentication required`)); - - const account = await triggerOAuth(provider, options); - return account !== null; -} +// Re-export types +export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth'; + +// Re-export configurations +export { + OAUTH_CALLBACK_PORTS, + OAUTH_CONFIGS, + PROVIDER_AUTH_PREFIXES, + PROVIDER_TYPE_VALUES, + getOAuthConfig, +} from './auth'; + +// Re-export token management functions +export { + getProviderTokenDir, + isTokenFileForProvider, + isAuthenticated, + getAuthStatus, + getAllAuthStatus, + clearAuth, + displayAuthStatus, +} from './auth'; + +// Re-export environment detection functions +export { + isHeadlessEnvironment, + killProcessOnPort, + getTimeoutTroubleshooting, + showStep, +} from './auth'; + +// Re-export OAuth handling functions +export { triggerOAuth, ensureAuth } from './auth'; + +// Re-export account management initialization +import { discoverExistingAccounts } from './account-manager'; /** * Initialize accounts registry from existing tokens @@ -907,26 +63,3 @@ export async function ensureAuth( export function initializeAccounts(): void { discoverExistingAccounts(); } - -/** - * Display auth status for all providers - */ -export function displayAuthStatus(): void { - console.log('CLIProxy Authentication Status:'); - console.log(''); - - const statuses = getAllAuthStatus(); - - for (const status of statuses) { - const oauthConfig = getOAuthConfig(status.provider); - const icon = status.authenticated ? '[OK]' : '[!]'; - const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated'; - const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : ''; - - console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`); - } - - console.log(''); - console.log('To authenticate: ccs --auth'); - console.log('To logout: ccs --logout'); -} diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts new file mode 100644 index 00000000..ddabae1c --- /dev/null +++ b/src/cliproxy/auth/auth-types.ts @@ -0,0 +1,152 @@ +/** + * Auth Types and Configurations + * + * Type definitions and OAuth configurations for CLIProxy authentication. + */ + +import { CLIProxyProvider } from '../types'; +import { AccountInfo } from '../account-manager'; + +/** + * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) + * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth + * + * OAuth flow types per provider: + * - Gemini: Authorization Code Flow with local callback server on port 8085 + * - Codex: Authorization Code Flow with local callback server on port 1455 + * - Agy: Authorization Code Flow with local callback server on port 51121 + * - Qwen: Device Code Flow (polling-based, NO callback port needed) + */ +export const OAUTH_CALLBACK_PORTS: Partial> = { + gemini: 8085, + // codex uses 1455 + // agy uses 51121 + // qwen uses Device Code Flow - no callback port needed +}; + +/** + * Auth status for a provider + */ +export interface AuthStatus { + /** Provider name */ + provider: CLIProxyProvider; + /** Whether authentication exists */ + authenticated: boolean; + /** Path to token directory */ + tokenDir: string; + /** Token file paths found */ + tokenFiles: string[]; + /** When last authenticated (if known) */ + lastAuth?: Date; + /** Accounts registered for this provider (multi-account support) */ + accounts: AccountInfo[]; + /** Default account ID */ + defaultAccount?: string; +} + +/** + * OAuth config for each provider + */ +export interface ProviderOAuthConfig { + /** Provider identifier */ + provider: CLIProxyProvider; + /** Display name */ + displayName: string; + /** OAuth authorization URL (for manual flow) */ + authUrl: string; + /** Scopes required */ + scopes: string[]; + /** CLI flag for auth */ + authFlag: string; +} + +/** + * OAuth configurations per provider + * Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow + */ +export const OAUTH_CONFIGS: Record = { + gemini: { + provider: 'gemini', + displayName: 'Google Gemini', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['https://www.googleapis.com/auth/generative-language'], + authFlag: '--login', + }, + codex: { + provider: 'codex', + displayName: 'Codex', + authUrl: 'https://auth.openai.com/authorize', + scopes: ['openid', 'profile'], + authFlag: '--codex-login', + }, + agy: { + provider: 'agy', + displayName: 'Antigravity', + authUrl: 'https://antigravity.ai/oauth/authorize', + scopes: ['api'], + authFlag: '--antigravity-login', + }, + qwen: { + provider: 'qwen', + displayName: 'Qwen Code', + authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code', + scopes: ['openid', 'profile', 'email', 'model.completion'], + authFlag: '--qwen-login', + }, + iflow: { + provider: 'iflow', + displayName: 'iFlow', + authUrl: 'https://iflow.cn/oauth', + scopes: ['phone', 'profile', 'email'], + authFlag: '--iflow-login', + }, +}; + +/** + * Provider-specific auth file prefixes (fallback detection) + * CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json") + * Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format + */ +export const PROVIDER_AUTH_PREFIXES: Record = { + gemini: ['gemini-', 'google-'], + codex: ['codex-', 'openai-'], + agy: ['antigravity-', 'agy-'], + qwen: ['qwen-'], + iflow: ['iflow-'], +}; + +/** + * Provider type values inside token JSON files + * CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"}) + */ +export const PROVIDER_TYPE_VALUES: Record = { + gemini: ['gemini'], + codex: ['codex'], + agy: ['antigravity'], + qwen: ['qwen'], + iflow: ['iflow'], +}; + +/** + * Get OAuth config for provider + */ +export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig { + const config = OAUTH_CONFIGS[provider]; + if (!config) { + throw new Error(`Unknown provider: ${provider}`); + } + return config; +} + +/** + * OAuth options for triggerOAuth + */ +export interface OAuthOptions { + verbose?: boolean; + headless?: boolean; + account?: string; + add?: boolean; + nickname?: string; + /** If true, triggered from Web UI (enables project selection prompt) */ + fromUI?: boolean; +} diff --git a/src/cliproxy/auth/environment-detector.ts b/src/cliproxy/auth/environment-detector.ts new file mode 100644 index 00000000..287f0076 --- /dev/null +++ b/src/cliproxy/auth/environment-detector.ts @@ -0,0 +1,132 @@ +/** + * Environment Detector for CLIProxyAPI + * + * Detects headless environments and manages OAuth callback ports. + */ + +import { execSync } from 'child_process'; +import { CLIProxyProvider } from '../types'; + +/** + * Detect if running in a headless environment (no browser available) + * + * IMPROVED: Avoids false positives on Windows desktop environments + * where isTTY may be undefined due to terminal wrapper behavior. + * + * Case study: Vietnamese Windows users reported "command hangs" because + * their terminal (PowerShell via npm) didn't set isTTY correctly. + */ +export function isHeadlessEnvironment(): boolean { + // SSH session - always headless + if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { + return true; + } + + // No display on Linux (X11/Wayland) - headless + if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + return true; + } + + // Windows desktop - NEVER headless unless SSH (already checked above) + // This fixes false positive where Windows npm wrappers don't set isTTY correctly + // Windows desktop environments always have browser capability + if (process.platform === 'win32') { + return false; + } + + // macOS - check for proper terminal + if (process.platform === 'darwin') { + // Non-interactive stdin on macOS means likely piped/scripted + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Linux with display - check TTY + if (process.platform === 'linux') { + if (!process.stdin.isTTY) { + return true; + } + return false; + } + + // Default fallback for unknown platforms + return !process.stdin.isTTY; +} + +/** + * Kill any process using a specific port + * Used to free OAuth callback port before authentication + */ +export function killProcessOnPort(port: number, verbose: boolean): boolean { + try { + if (process.platform === 'win32') { + // Windows: use netstat + taskkill + const result = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf-8', + stdio: 'pipe', + }); + const lines = result.trim().split('\n'); + for (const line of lines) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && /^\d+$/.test(pid)) { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); + if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`); + } + } + return true; + } else { + // Unix: use lsof + kill + const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: 'pipe' }); + const pids = result + .trim() + .split('\n') + .filter((p) => p); + for (const pid of pids) { + execSync(`kill -9 ${pid}`, { stdio: 'pipe' }); + if (verbose) console.error(`[auth] Killed process ${pid} on port ${port}`); + } + return pids.length > 0; + } + } catch { + // No process on port or command failed - that's fine + return false; + } +} + +/** + * Get platform-specific troubleshooting for OAuth timeout + */ +export function getTimeoutTroubleshooting( + provider: CLIProxyProvider, + port: number | null +): string[] { + const lines: string[] = []; + lines.push(''); + lines.push('TROUBLESHOOTING:'); + lines.push(' 1. Check browser completed auth (should show success page)'); + + if (port) { + lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); + lines.push(` 3. Try: ccs ${provider} --auth --verbose`); + } else { + lines.push(` 2. Try: ccs ${provider} --auth --verbose`); + } + + return lines; +} + +/** + * Display a single step status line + */ +export function showStep( + step: number, + total: number, + status: 'ok' | 'fail' | 'progress', + message: string +): void { + const statusIcon = status === 'ok' ? '[OK]' : status === 'fail' ? '[X]' : '[..]'; + console.log(`${statusIcon} [${step}/${total}] ${message}`); +} diff --git a/src/cliproxy/auth/index.ts b/src/cliproxy/auth/index.ts new file mode 100644 index 00000000..fd6d0214 --- /dev/null +++ b/src/cliproxy/auth/index.ts @@ -0,0 +1,38 @@ +/** + * Auth Module - Barrel Export + * + * Re-exports all auth-related functionality from submodules. + */ + +// Types and configurations +export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth-types'; +export { + OAUTH_CALLBACK_PORTS, + OAUTH_CONFIGS, + PROVIDER_AUTH_PREFIXES, + PROVIDER_TYPE_VALUES, + getOAuthConfig, +} from './auth-types'; + +// Token management +export { + getProviderTokenDir, + isTokenFileForProvider, + isAuthenticated, + getAuthStatus, + getAllAuthStatus, + clearAuth, + registerAccountFromToken, + displayAuthStatus, +} from './token-manager'; + +// Environment detection +export { + isHeadlessEnvironment, + killProcessOnPort, + getTimeoutTroubleshooting, + showStep, +} from './environment-detector'; + +// OAuth handling +export { triggerOAuth, ensureAuth } from './oauth-handler'; diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts new file mode 100644 index 00000000..b27201d6 --- /dev/null +++ b/src/cliproxy/auth/oauth-handler.ts @@ -0,0 +1,230 @@ +/** + * OAuth Handler for CLIProxyAPI + * + * Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity). + * CLIProxyAPI handles OAuth internally - we just need to: + * 1. Check if auth exists (token files in CCS auth directory) + * 2. Trigger OAuth flow by spawning binary with auth flag + * 3. Auto-detect headless environments (SSH, no DISPLAY) + * 4. Use --no-browser flag for headless, display OAuth URL for manual auth + */ + +import * as fs from 'fs'; +import { fail, info, warn, color } from '../../utils/ui'; +import { ensureCLIProxyBinary } from '../binary-manager'; +import { generateConfig } from '../config-generator'; +import { CLIProxyProvider } from '../types'; +import { + AccountInfo, + getProviderAccounts, + getDefaultAccount, + touchAccount, +} from '../account-manager'; +import { + enhancedPreflightOAuthCheck, + OAUTH_CALLBACK_PORTS as OAUTH_PORTS, +} from '../../management/oauth-port-diagnostics'; +import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types'; +import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; +import { getProviderTokenDir, isAuthenticated } from './token-manager'; +import { executeOAuthProcess } from './oauth-process'; + +/** + * Prompt user to add another account + */ +async function promptAddAccount(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question('[?] Add another account? (y/N): ', (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +/** + * Run pre-flight OAuth checks + */ +async function runPreflightChecks( + provider: CLIProxyProvider, + oauthConfig: { displayName: string } +): Promise { + console.log(''); + console.log(info(`Pre-flight OAuth check for ${oauthConfig.displayName}...`)); + + const preflight = await enhancedPreflightOAuthCheck(provider); + + for (const check of preflight.checks) { + const icon = check.status === 'ok' ? '[OK]' : check.status === 'warn' ? '[!]' : '[X]'; + console.log(` ${icon} ${check.name}: ${check.message}`); + if (check.fixCommand && check.status !== 'ok') { + console.log(` Fix: ${check.fixCommand}`); + } + } + + if (preflight.firewallWarning) { + console.log(''); + console.log(warn('Windows Firewall may block OAuth callback')); + console.log(' If auth hangs, run as Administrator:'); + console.log(` ${color(preflight.firewallFixCommand || '', 'command')}`); + } + + if (!preflight.ready) { + console.log(''); + console.log(fail('Pre-flight check failed. Resolve issues above and retry.')); + return false; + } + + return true; +} + +/** + * Prepare OAuth binary and config + */ +async function prepareBinary( + provider: CLIProxyProvider, + verbose: boolean +): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> { + showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); + + try { + const binaryPath = await ensureCLIProxyBinary(verbose); + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(1, 4, 'ok', 'CLIProxy binary ready'); + + const tokenDir = getProviderTokenDir(provider); + fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 }); + + const configPath = generateConfig(provider); + if (verbose) { + console.error(`[auth] Config generated: ${configPath}`); + } + + return { binaryPath, tokenDir, configPath }; + } catch (error) { + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary'); + console.error(fail((error as Error).message)); + throw error; + } +} + +/** + * Trigger OAuth flow for provider + * Auto-detects headless environment and uses --no-browser flag accordingly + * Shows real-time step-by-step progress for better user feedback + */ +export async function triggerOAuth( + provider: CLIProxyProvider, + options: OAuthOptions = {} +): Promise { + const oauthConfig = getOAuthConfig(provider); + const { verbose = false, add = false, nickname, fromUI = false } = options; + const callbackPort = OAUTH_PORTS[provider]; + const isCLI = !fromUI; + const headless = options.headless ?? isHeadlessEnvironment(); + + // Check for existing accounts + const existingAccounts = getProviderAccounts(provider); + if (existingAccounts.length > 0 && !add) { + console.log(''); + console.log( + info( + `${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}` + ) + ); + + if (!(await promptAddAccount())) { + console.log(info('Cancelled')); + return null; + } + } + + // Pre-flight checks + if (!(await runPreflightChecks(provider, oauthConfig))) { + return null; + } + + console.log(''); + + // Prepare binary + const prepared = await prepareBinary(provider, verbose); + if (!prepared) return null; + + const { binaryPath, tokenDir, configPath } = prepared; + + // Free callback port if needed + const localCallbackPort = OAUTH_CALLBACK_PORTS[provider]; + if (localCallbackPort) { + const killed = killProcessOnPort(localCallbackPort, verbose); + if (killed && verbose) { + console.error(`[auth] Freed port ${localCallbackPort} for OAuth callback`); + } + } + + // Build args + const args = ['--config', configPath, oauthConfig.authFlag]; + if (headless) { + args.push('--no-browser'); + } + + // Show callback server step + showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort || 'N/A'}...`); + + // Show headless instructions + if (headless) { + console.log(''); + console.log(warn('PORT FORWARDING REQUIRED')); + console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`); + console.log(' Run this on your LOCAL machine:'); + console.log( + ` ${color(`ssh -L ${callbackPort}:localhost:${callbackPort} @`, 'command')}` + ); + console.log(''); + } + + // Execute OAuth process + return executeOAuthProcess({ + provider, + binaryPath, + args, + tokenDir, + oauthConfig, + callbackPort, + headless, + verbose, + isCLI, + nickname, + }); +} + +/** + * Ensure provider is authenticated + * Triggers OAuth flow if not authenticated + */ +export async function ensureAuth( + provider: CLIProxyProvider, + options: { verbose?: boolean; headless?: boolean; account?: string } = {} +): Promise { + if (isAuthenticated(provider)) { + if (options.verbose) { + console.error(`[auth] ${provider} already authenticated`); + } + const defaultAccount = getDefaultAccount(provider); + if (defaultAccount) { + touchAccount(provider, options.account || defaultAccount.id); + } + return true; + } + + const oauthConfig = getOAuthConfig(provider); + console.log(info(`${oauthConfig.displayName} authentication required`)); + + const account = await triggerOAuth(provider, options); + return account !== null; +} diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts new file mode 100644 index 00000000..94b70d1e --- /dev/null +++ b/src/cliproxy/auth/oauth-process.ts @@ -0,0 +1,290 @@ +/** + * OAuth Process Execution for CLIProxyAPI + * + * Handles the spawning and monitoring of CLIProxy OAuth process. + * Separated from oauth-handler.ts for modularity. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { ok, fail, info } from '../../utils/ui'; +import { CLIProxyProvider } from '../types'; +import { AccountInfo } from '../account-manager'; +import { + parseProjectList, + parseDefaultProject, + isProjectSelectionPrompt, + isProjectList, + generateSessionId, + requestProjectSelection, + type GCloudProject, + type ProjectSelectionPrompt, +} from '../project-selection-handler'; +import { ProviderOAuthConfig } from './auth-types'; +import { getTimeoutTroubleshooting, showStep } from './environment-detector'; +import { isAuthenticated, registerAccountFromToken } from './token-manager'; + +/** Options for OAuth process execution */ +export interface OAuthProcessOptions { + provider: CLIProxyProvider; + binaryPath: string; + args: string[]; + tokenDir: string; + oauthConfig: ProviderOAuthConfig; + callbackPort: number | null; + headless: boolean; + verbose: boolean; + isCLI: boolean; + nickname?: string; +} + +/** Internal state for OAuth process */ +interface ProcessState { + stderrData: string; + urlDisplayed: boolean; + browserOpened: boolean; + projectPromptHandled: boolean; + accumulatedOutput: string; + parsedProjects: GCloudProject[]; + sessionId: string; +} + +/** + * Handle project selection prompt + */ +async function handleProjectSelection( + output: string, + state: ProcessState, + options: OAuthProcessOptions, + authProcess: ChildProcess, + log: (msg: string) => void +): Promise { + const defaultProjectId = parseDefaultProject(output) || ''; + + if (state.parsedProjects.length > 0 && !options.isCLI) { + log(`Requesting project selection from UI (session: ${state.sessionId})`); + + const prompt: ProjectSelectionPrompt = { + sessionId: state.sessionId, + provider: options.provider, + projects: state.parsedProjects, + defaultProjectId, + supportsAll: output.includes('ALL'), + }; + + try { + const selectedId = await requestProjectSelection(prompt); + const response = selectedId || ''; + log(`User selected: ${response || '(default)'}`); + authProcess.stdin?.write(response + '\n'); + } catch { + log('Project selection failed, using default'); + authProcess.stdin?.write('\n'); + } + } else { + log('CLI mode or no projects, auto-selecting default'); + authProcess.stdin?.write('\n'); + } +} + +/** + * Handle stdout data from OAuth process + */ +async function handleStdout( + output: string, + state: ProcessState, + options: OAuthProcessOptions, + authProcess: ChildProcess, + log: (msg: string) => void +): Promise { + log(`stdout: ${output.trim()}`); + state.accumulatedOutput += output; + + // Parse project list when available + if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { + state.parsedProjects = parseProjectList(state.accumulatedOutput); + log(`Parsed ${state.parsedProjects.length} projects`); + } + + // Handle project selection prompt + if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) { + state.projectPromptHandled = true; + await handleProjectSelection(output, state, options, authProcess, log); + } + + // Detect callback server / browser + if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) { + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`); + showStep(3, 4, 'progress', 'Opening browser...'); + state.browserOpened = true; + } + + // Display OAuth URLs in headless mode + if (options.headless && !state.urlDisplayed) { + const urlMatch = output.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + console.log(''); + console.log(info(`${options.oauthConfig.displayName} OAuth URL:`)); + console.log(` ${urlMatch[0]}`); + console.log(''); + state.urlDisplayed = true; + } + } +} + +/** Display OAuth URL from stderr if in headless mode */ +function displayUrlFromStderr( + output: string, + state: ProcessState, + oauthConfig: ProviderOAuthConfig +): void { + const urlMatch = output.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + console.log(''); + console.log(info(`${oauthConfig.displayName} OAuth URL:`)); + console.log(` ${urlMatch[0]}`); + console.log(''); + state.urlDisplayed = true; + } +} + +/** Handle token not found after successful process exit */ +function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void { + console.log(''); + console.log(fail('Token not found after authentication')); + console.log(''); + console.log('The browser showed success but callback was not received.'); + + if (process.platform === 'win32') { + console.log(''); + console.log('On Windows, this usually means:'); + console.log(' 1. Windows Firewall blocked the callback'); + console.log(' 2. Antivirus software blocked the connection'); + console.log(''); + console.log('Try running as Administrator:'); + console.log( + ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${callbackPort}` + ); + } + + console.log(''); + console.log(`Try: ccs ${provider} --auth --verbose`); +} + +/** Handle process exit with error */ +function handleProcessError(code: number | null, state: ProcessState, headless: boolean): void { + console.log(''); + console.log(fail(`CLIProxyAPI auth exited with code ${code}`)); + if (state.stderrData && !state.urlDisplayed) { + console.log(` ${state.stderrData.trim().split('\n')[0]}`); + } + if (headless && !state.urlDisplayed) { + console.log(''); + console.log(info('No OAuth URL was displayed. Try with --verbose for details.')); + } +} + +/** + * Execute OAuth process and wait for completion + */ +export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + const { + provider, + binaryPath, + args, + tokenDir, + oauthConfig, + callbackPort, + headless, + verbose, + nickname, + } = options; + + const log = (msg: string) => { + if (verbose) console.error(`[auth] ${msg}`); + }; + + return new Promise((resolve) => { + const authProcess = spawn(binaryPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir }, + }); + + const state: ProcessState = { + stderrData: '', + urlDisplayed: false, + browserOpened: false, + projectPromptHandled: false, + accumulatedOutput: '', + parsedProjects: [], + sessionId: generateSessionId(), + }; + + const startTime = Date.now(); + + authProcess.stdout?.on('data', async (data: Buffer) => { + await handleStdout(data.toString(), state, options, authProcess, log); + }); + + authProcess.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + state.stderrData += output; + log(`stderr: ${output.trim()}`); + if (headless && !state.urlDisplayed) { + displayUrlFromStderr(output, state, oauthConfig); + } + }); + + // Show waiting message after delay + setTimeout(() => { + if (!state.browserOpened) { + process.stdout.write('\x1b[1A\x1b[2K'); + showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`); + showStep(3, 4, 'ok', 'Browser opened'); + state.browserOpened = true; + } + showStep(4, 4, 'progress', 'Waiting for OAuth callback...'); + console.log(''); + console.log(info('Complete the login in your browser. This page will update automatically.')); + if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose')); + }, 2000); + + // Timeout handling + const timeoutMs = headless ? 300000 : 120000; + const timeout = setTimeout(() => { + authProcess.kill(); + console.log(''); + console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); + for (const line of getTimeoutTroubleshooting(provider, callbackPort ?? null)) { + console.log(line); + } + resolve(null); + }, timeoutMs); + + authProcess.on('exit', (code) => { + clearTimeout(timeout); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (code === 0) { + if (isAuthenticated(provider)) { + console.log(''); + console.log(ok(`Authentication successful (${elapsed}s)`)); + resolve(registerAccountFromToken(provider, tokenDir, nickname)); + } else { + handleTokenNotFound(provider, callbackPort); + resolve(null); + } + } else { + handleProcessError(code, state, headless); + resolve(null); + } + }); + + authProcess.on('error', (error) => { + clearTimeout(timeout); + console.log(''); + console.log(fail(`Failed to start auth process: ${error.message}`)); + resolve(null); + }); + }); +} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts new file mode 100644 index 00000000..c1e4fafe --- /dev/null +++ b/src/cliproxy/auth/token-manager.ts @@ -0,0 +1,260 @@ +/** + * Token Manager for CLIProxyAPI + * + * Handles OAuth token storage, retrieval, and validation. + * Tokens are stored in ~/.ccs/cliproxy/auth/ directory. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider } from '../types'; +import { getProviderAuthDir } from '../config-generator'; +import { getProviderAccounts, getDefaultAccount } from '../account-manager'; +import { + AuthStatus, + PROVIDER_AUTH_PREFIXES, + PROVIDER_TYPE_VALUES, + getOAuthConfig, +} from './auth-types'; + +/** + * Get token directory for provider + */ +export function getProviderTokenDir(provider: CLIProxyProvider): string { + return getProviderAuthDir(provider); +} + +/** + * Check if a JSON file contains a token for the given provider + * Reads the file and checks the "type" field + */ +export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvider): boolean { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + const typeValue = (data.type || '').toLowerCase(); + const validTypes = PROVIDER_TYPE_VALUES[provider] || []; + return validTypes.includes(typeValue); + } catch { + return false; + } +} + +/** + * Check if provider has valid authentication + * CLIProxyAPI stores OAuth tokens as JSON files in the auth directory. + * Detection strategy: + * 1. First check by filename prefix (fast path) + * 2. If no match, check JSON content for "type" field (Gemini uses {email}-{projectID}.json without prefix) + */ +export function isAuthenticated(provider: CLIProxyProvider): boolean { + const tokenDir = getProviderTokenDir(provider); + + if (!fs.existsSync(tokenDir)) { + return false; + } + + const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; + + try { + const files = fs.readdirSync(tokenDir); + const jsonFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' + ); + + // Strategy 1: Check by filename prefix (fast path for antigravity, codex) + const prefixMatch = jsonFiles.some((f) => { + const lowerFile = f.toLowerCase(); + return validPrefixes.some((prefix) => lowerFile.startsWith(prefix)); + }); + if (prefixMatch) return true; + + // Strategy 2: Check JSON content for "type" field (needed for Gemini) + for (const f of jsonFiles) { + const filePath = path.join(tokenDir, f); + if (isTokenFileForProvider(filePath, provider)) { + return true; + } + } + + return false; + } catch { + return false; + } +} + +/** + * Get detailed auth status for provider + * Uses same detection strategy as isAuthenticated: prefix first, then content + */ +export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { + const tokenDir = getProviderTokenDir(provider); + let tokenFiles: string[] = []; + let lastAuth: Date | undefined; + + const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; + + if (fs.existsSync(tokenDir)) { + const files = fs.readdirSync(tokenDir); + const jsonFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' + ); + + // Check each file: by prefix OR by content + tokenFiles = jsonFiles.filter((f) => { + const lowerFile = f.toLowerCase(); + // Strategy 1: prefix match + if (validPrefixes.some((prefix) => lowerFile.startsWith(prefix))) { + return true; + } + // Strategy 2: content match (for Gemini tokens without prefix) + const filePath = path.join(tokenDir, f); + return isTokenFileForProvider(filePath, provider); + }); + + // Get most recent modification time + for (const file of tokenFiles) { + const filePath = path.join(tokenDir, file); + try { + const stats = fs.statSync(filePath); + if (!lastAuth || stats.mtime > lastAuth) { + lastAuth = stats.mtime; + } + } catch { + // Skip if can't stat file + } + } + } + + // Get registered accounts for multi-account support + const accounts = getProviderAccounts(provider); + const defaultAccount = getDefaultAccount(provider); + + return { + provider, + authenticated: tokenFiles.length > 0, + tokenDir, + tokenFiles, + lastAuth, + accounts, + defaultAccount: defaultAccount?.id, + }; +} + +/** + * Get auth status for all providers + */ +export function getAllAuthStatus(): AuthStatus[] { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + return providers.map(getAuthStatus); +} + +/** + * Clear authentication for provider + * Only removes files belonging to the specified provider (by prefix or content) + * Does NOT remove the shared auth directory or other providers' files + */ +export function clearAuth(provider: CLIProxyProvider): boolean { + const tokenDir = getProviderTokenDir(provider); + + if (!fs.existsSync(tokenDir)) { + return false; + } + + const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || []; + const files = fs.readdirSync(tokenDir); + let removedCount = 0; + + // Only remove files that belong to this provider + for (const file of files) { + const filePath = path.join(tokenDir, file); + const lowerFile = file.toLowerCase(); + + // Check by prefix first (fast path) + const matchesByPrefix = validPrefixes.some((prefix) => lowerFile.startsWith(prefix)); + + // If no prefix match, check by content (for Gemini tokens without prefix) + const matchesByContent = !matchesByPrefix && isTokenFileForProvider(filePath, provider); + + if (matchesByPrefix || matchesByContent) { + try { + fs.unlinkSync(filePath); + removedCount++; + } catch { + // Failed to remove - skip + } + } + } + + // DO NOT remove the shared auth directory - other providers may still have tokens + return removedCount > 0; +} + +/** + * Register account from newly created token file + * Scans auth directory for new token and extracts email + * @param provider - The CLIProxy provider + * @param tokenDir - Directory containing token files + * @param nickname - Optional nickname (uses auto-generated from email if not provided) + */ +export function registerAccountFromToken( + provider: CLIProxyProvider, + tokenDir: string, + nickname?: string +): import('../account-manager').AccountInfo | null { + const { registerAccount, generateNickname } = require('../account-manager'); + try { + const files = fs.readdirSync(tokenDir); + const jsonFiles = files.filter((f: string) => f.endsWith('.json')); + + let newestFile: string | null = null; + let newestMtime = 0; + + for (const file of jsonFiles) { + const filePath = path.join(tokenDir, file); + if (!isTokenFileForProvider(filePath, provider)) continue; + + const stats = fs.statSync(filePath); + if (stats.mtimeMs > newestMtime) { + newestMtime = stats.mtimeMs; + newestFile = file; + } + } + + if (!newestFile) { + return null; + } + + const tokenPath = path.join(tokenDir, newestFile); + const content = fs.readFileSync(tokenPath, 'utf-8'); + const data = JSON.parse(content); + const email = data.email || undefined; + + return registerAccount(provider, newestFile, email, nickname || generateNickname(email)); + } catch { + return null; + } +} + +/** + * Display auth status for all providers + */ +export function displayAuthStatus(): void { + console.log('CLIProxy Authentication Status:'); + console.log(''); + + const statuses = getAllAuthStatus(); + + for (const status of statuses) { + const oauthConfig = getOAuthConfig(status.provider); + const icon = status.authenticated ? '[OK]' : '[!]'; + const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated'; + const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : ''; + + console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`); + } + + console.log(''); + console.log('To authenticate: ccs --auth'); + console.log('To logout: ccs --logout'); +} diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index fa037060..70adfec1 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -1,66 +1,29 @@ /** * Binary Manager for CLIProxyAPI * - * Download-on-demand binary manager: - * - Downloads platform-specific binary from GitHub releases - * - Verifies SHA256 checksum - * - Extracts and caches binary locally - * - Supports retry logic with exponential backoff - * - Auto-checks for updates on startup (fetches latest from GitHub API) - * + * Facade pattern wrapper for modular binary management. * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as https from 'https'; -import * as http from 'http'; -import * as crypto from 'crypto'; -import * as zlib from 'zlib'; -import { ProgressIndicator } from '../utils/progress-indicator'; -import { ok, info } from '../utils/ui'; -import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; -import { isCliproxyRunning } from './stats-fetcher'; +import { info } from '../utils/ui'; +import { getBinDir } from './config-generator'; +import { BinaryInfo, BinaryManagerConfig } from './types'; +import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; import { - BinaryInfo, - BinaryManagerConfig, - ChecksumResult, - DownloadResult, - ProgressCallback, -} from './types'; -import { - detectPlatform, - getDownloadUrl, - getChecksumsUrl, - getExecutableName, - getArchiveBinaryName, - CLIPROXY_FALLBACK_VERSION, -} from './platform-detector'; - -/** Cache duration for version check (1 hour in milliseconds) */ -const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; - -/** Version pin file name - stores user's explicit version choice */ -const VERSION_PIN_FILE = '.version-pin'; - -/** GitHub API URL for latest release */ -const GITHUB_API_LATEST_RELEASE = - 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest'; - -/** Version cache file structure */ -interface VersionCache { - latestVersion: string; - checkedAt: number; -} - -/** Update check result */ -interface UpdateCheckResult { - hasUpdate: boolean; - currentVersion: string; - latestVersion: string; - fromCache: boolean; - checkedAt: number; // Unix timestamp of last check -} + UpdateCheckResult, + checkForUpdates, + deleteBinary, + getBinaryPath, + isBinaryInstalled, + getBinaryInfo, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, + getVersionPinPath, + readInstalledVersion, + ensureBinary, +} from './binary'; /** Default configuration */ const DEFAULT_CONFIG: BinaryManagerConfig = { @@ -77,925 +40,85 @@ const DEFAULT_CONFIG: BinaryManagerConfig = { */ export class BinaryManager { private config: BinaryManagerConfig; - private verbose: boolean; constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; - this.verbose = this.config.verbose; } - /** - * Ensure binary is available (download if missing, update if outdated) - * @returns Path to executable binary - */ + /** Ensure binary is available (download if missing, update if outdated) */ async ensureBinary(): Promise { - const binaryPath = this.getBinaryPath(); - - // Check if binary already exists - if (fs.existsSync(binaryPath)) { - this.log(`Binary exists: ${binaryPath}`); - - // Skip auto-update if forceVersion is set (user requested specific version) - if (this.config.forceVersion) { - this.log(`Force version mode: skipping auto-update`); - return this.getBinaryPath(); - } - - // Check for updates in background (non-blocking for UX) - try { - const updateResult = await this.checkForUpdates(); - if (updateResult.hasUpdate) { - // Check if CLIProxyAPI is currently running - can't update while running - const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); - if (proxyRunning) { - // Proxy is running - can't update, just notify user - console.log( - info( - `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` - ) - ); - console.log(info('Run "ccs cliproxy stop" then restart to apply update')); - this.log('Skipping update: CLIProxyAPI is currently running'); - } else { - // Proxy not running - safe to update - console.log( - info( - `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` - ) - ); - console.log(info('Updating CLIProxyAPI...')); - - // Delete old binary and download new version - this.deleteBinary(); - this.config.version = updateResult.latestVersion; - await this.downloadAndInstall(); - } - } - } catch (error) { - // Silent fail - don't block startup if update check fails - const err = error as Error; - this.log(`Update check failed (non-blocking): ${err.message}`); - } - - return this.getBinaryPath(); - } - - // Download, verify, extract - this.log('Binary not found, downloading...'); - - // Skip auto-upgrade to latest if forceVersion is set - if (!this.config.forceVersion) { - // Check latest version before first download - try { - const latestVersion = await this.fetchLatestVersion(); - if (latestVersion && this.isNewerVersion(latestVersion, this.config.version)) { - this.log(`Using latest version: ${latestVersion} (instead of ${this.config.version})`); - this.config.version = latestVersion; - } - } catch { - // Use pinned version if API fails - this.log(`Using pinned version: ${this.config.version}`); - } - } else { - this.log(`Force version mode: using specified version ${this.config.version}`); - } - - await this.downloadAndInstall(); - - return binaryPath; + return ensureBinary(this.config); } - /** - * Check for updates by comparing installed version with latest release - * Uses cache to avoid hitting GitHub API on every run - */ + /** Check for updates by comparing installed version with latest release */ async checkForUpdates(): Promise { - const currentVersion = this.getInstalledVersion(); - - // Try cache first - const cache = this.getVersionCache(); - if (cache) { - this.log(`Using cached version: ${cache.latestVersion}`); - return { - hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion), - currentVersion, - latestVersion: cache.latestVersion, - fromCache: true, - checkedAt: cache.checkedAt, - }; - } - - // Fetch from GitHub API - const latestVersion = await this.fetchLatestVersion(); - const now = Date.now(); - this.cacheLatestVersion(latestVersion); - - return { - hasUpdate: this.isNewerVersion(latestVersion, currentVersion), - currentVersion, - latestVersion, - fromCache: false, - checkedAt: now, - }; + return checkForUpdates(this.config.binPath, this.config.version, this.config.verbose); } - /** - * Fetch latest version from GitHub API - */ - private async fetchLatestVersion(): Promise { - const response = await this.fetchJson(GITHUB_API_LATEST_RELEASE); - - // Extract version from tag_name (format: "v6.5.27" or "6.5.27") - const tagName = response.tag_name as string; - if (!tagName) { - throw new Error('No tag_name in GitHub API response'); - } - - return tagName.replace(/^v/, ''); - } - - /** - * Fetch JSON from URL (for GitHub API) - */ - private fetchJson(url: string): Promise> { - return new Promise((resolve, reject) => { - const options = { - headers: { - 'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0', - Accept: 'application/vnd.github.v3+json', - }, - }; - - const handleResponse = (res: http.IncomingMessage) => { - if (res.statusCode === 301 || res.statusCode === 302) { - const redirectUrl = res.headers.location; - if (!redirectUrl) { - reject(new Error('Redirect without location header')); - return; - } - this.fetchJson(redirectUrl).then(resolve).catch(reject); - return; - } - - if (res.statusCode !== 200) { - reject(new Error(`GitHub API error: HTTP ${res.statusCode}`)); - return; - } - - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch { - reject(new Error('Invalid JSON from GitHub API')); - } - }); - res.on('error', reject); - }; - - const req = https.get(url, options, handleResponse); - req.on('error', reject); - req.setTimeout(10000, () => { - req.destroy(); - reject(new Error('GitHub API timeout (10s)')); - }); - }); - } - - /** - * Get installed version from version file or fallback to pinned - */ - private getInstalledVersion(): string { - const versionFile = path.join(this.config.binPath, '.version'); - if (fs.existsSync(versionFile)) { - try { - return fs.readFileSync(versionFile, 'utf8').trim(); - } catch { - return this.config.version; - } - } - return this.config.version; - } - - /** - * Save installed version to file - */ - private saveInstalledVersion(version: string): void { - const versionFile = path.join(this.config.binPath, '.version'); - try { - fs.writeFileSync(versionFile, version, 'utf8'); - } catch { - // Silent fail - not critical - } - } - - /** - * Get version cache data if still valid - */ - private getVersionCache(): VersionCache | null { - const cachePath = this.getVersionCachePath(); - if (!fs.existsSync(cachePath)) { - return null; - } - - try { - const content = fs.readFileSync(cachePath, 'utf8'); - const cache: VersionCache = JSON.parse(content); - - // Check if cache is still valid - if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { - return cache; - } - - // Cache expired - return null; - } catch { - return null; - } - } - - /** - * Cache latest version for future checks - */ - private cacheLatestVersion(version: string): void { - const cachePath = this.getVersionCachePath(); - const cache: VersionCache = { - latestVersion: version, - checkedAt: Date.now(), - }; - - try { - fs.mkdirSync(path.dirname(cachePath), { recursive: true }); - fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); - } catch { - // Silent fail - caching is optional - } - } - - /** - * Get path to version cache file - */ - private getVersionCachePath(): string { - return path.join(getCliproxyDir(), '.version-cache.json'); - } - - /** - * Compare semver versions (true if latest > current) - */ - private isNewerVersion(latest: string, current: string): boolean { - const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0); - const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0); - - // Pad arrays to same length - while (latestParts.length < 3) latestParts.push(0); - while (currentParts.length < 3) currentParts.push(0); - - for (let i = 0; i < 3; i++) { - if (latestParts[i] > currentParts[i]) return true; - if (latestParts[i] < currentParts[i]) return false; - } - - return false; // Equal versions - } - - /** - * Get full path to binary executable - */ + /** Get full path to binary executable */ getBinaryPath(): string { - const execName = getExecutableName(); - return path.join(this.config.binPath, execName); + return getBinaryPath(this.config.binPath); } - /** - * Check if binary exists - */ + /** Check if binary exists */ isBinaryInstalled(): boolean { - return fs.existsSync(this.getBinaryPath()); + return isBinaryInstalled(this.config.binPath); } - /** - * Get binary info if installed - */ + /** Get binary info if installed */ async getBinaryInfo(): Promise { - const binaryPath = this.getBinaryPath(); - if (!fs.existsSync(binaryPath)) { - return null; - } - - const platform = detectPlatform(); - const checksum = await this.computeChecksum(binaryPath); - - return { - path: binaryPath, - version: this.config.version, - platform, - checksum, - }; + return getBinaryInfo(this.config.binPath, this.config.version); } - /** - * Download and install binary - */ - private async downloadAndInstall(): Promise { - const platform = detectPlatform(this.config.version); - const downloadUrl = getDownloadUrl(this.config.version); - const checksumsUrl = getChecksumsUrl(this.config.version); - - // Ensure bin directory exists - fs.mkdirSync(this.config.binPath, { recursive: true }); - - // Download archive - const archivePath = path.join(this.config.binPath, `cliproxy-archive.${platform.extension}`); - - // Use single spinner and update text as we progress (avoids UI jumping) - const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${this.config.version}`); - spinner.start(); - - try { - // Download with retry - const result = await this.downloadWithRetry(downloadUrl, archivePath); - if (!result.success) { - spinner.fail('Download failed'); - throw new Error(result.error || 'Download failed after retries'); - } - - // Verify checksum (update spinner text instead of creating new one) - spinner.update('Verifying checksum'); - - const checksumResult = await this.verifyChecksum( - archivePath, - platform.binaryName, - checksumsUrl - ); - - if (!checksumResult.valid) { - spinner.fail('Checksum mismatch'); - fs.unlinkSync(archivePath); - throw new Error( - `Checksum mismatch for ${platform.binaryName}\n` + - `Expected: ${checksumResult.expected}\n` + - `Actual: ${checksumResult.actual}\n\n` + - `Manual download: ${downloadUrl}` - ); - } - - // Extract archive (update spinner text) - spinner.update('Extracting binary'); - - await this.extractArchive(archivePath, platform.extension); - - spinner.succeed('CLIProxyAPI ready'); - - // Cleanup archive - fs.unlinkSync(archivePath); - - // Make executable (Unix only) - const binaryPath = this.getBinaryPath(); - if (platform.os !== 'windows' && fs.existsSync(binaryPath)) { - fs.chmodSync(binaryPath, 0o755); - this.log(`Set executable permissions: ${binaryPath}`); - } - - // Save installed version for future update checks - this.saveInstalledVersion(this.config.version); - - console.log(ok(`CLIProxyAPI v${this.config.version} installed successfully`)); - } catch (error) { - spinner.fail('Installation failed'); - throw error; - } - } - - /** - * Download file with retry logic and exponential backoff - */ - private async downloadWithRetry(url: string, destPath: string): Promise { - let lastError = ''; - let retries = 0; - - while (retries < this.config.maxRetries) { - try { - await this.downloadFile(url, destPath); - return { success: true, filePath: destPath, retries }; - } catch (error) { - const err = error as Error; - lastError = err.message; - retries++; - - if (retries < this.config.maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, retries - 1) * 1000; - this.log(`Retry ${retries}/${this.config.maxRetries} after ${delay}ms: ${lastError}`); - await this.sleep(delay); - } - } - } - - return { - success: false, - error: `Download failed after ${retries} attempts: ${lastError}`, - retries, - }; - } - - /** - * Download file from URL with progress tracking - */ - private downloadFile( - url: string, - destPath: string, - onProgress?: ProgressCallback - ): Promise { - return new Promise((resolve, reject) => { - const handleResponse = (res: http.IncomingMessage) => { - // Handle redirects (GitHub releases use 302) - if (res.statusCode === 301 || res.statusCode === 302) { - const redirectUrl = res.headers.location; - if (!redirectUrl) { - reject(new Error('Redirect without location header')); - return; - } - this.log(`Following redirect: ${redirectUrl}`); - this.downloadFile(redirectUrl, destPath, onProgress).then(resolve).catch(reject); - return; - } - - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); - return; - } - - const totalBytes = parseInt(res.headers['content-length'] || '0', 10); - let downloadedBytes = 0; - - const fileStream = fs.createWriteStream(destPath); - - res.on('data', (chunk: Buffer) => { - downloadedBytes += chunk.length; - if (onProgress && totalBytes > 0) { - onProgress({ - total: totalBytes, - downloaded: downloadedBytes, - percentage: Math.round((downloadedBytes / totalBytes) * 100), - }); - } - }); - - res.pipe(fileStream); - - fileStream.on('finish', () => { - fileStream.close(); - resolve(); - }); - - fileStream.on('error', (err) => { - fs.unlink(destPath, () => {}); // Cleanup partial file - reject(err); - }); - - res.on('error', (err) => { - fs.unlink(destPath, () => {}); - reject(err); - }); - }; - - const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - - req.on('error', reject); - req.setTimeout(60000, () => { - req.destroy(); - reject(new Error('Download timeout (60s)')); - }); - }); - } - - /** - * Verify file checksum against checksums.txt - */ - private async verifyChecksum( - filePath: string, - binaryName: string, - checksumsUrl: string - ): Promise { - // Download checksums.txt - const checksumsContent = await this.fetchText(checksumsUrl); - - // Parse expected checksum - const expectedHash = this.parseChecksum(checksumsContent, binaryName); - if (!expectedHash) { - throw new Error(`Checksum not found for ${binaryName} in checksums.txt`); - } - - // Compute actual checksum - const actualHash = await this.computeChecksum(filePath); - - return { - valid: actualHash === expectedHash, - expected: expectedHash, - actual: actualHash, - }; - } - - /** - * Parse checksum from checksums.txt content - */ - private parseChecksum(content: string, binaryName: string): string | null { - const lines = content.split('\n'); - for (const line of lines) { - // Format: "hash filename" or "hash filename" - const parts = line.trim().split(/\s+/); - if (parts.length >= 2 && parts[1] === binaryName) { - return parts[0].toLowerCase(); - } - } - return null; - } - - /** - * Compute SHA256 checksum of file - */ - private computeChecksum(filePath: string): Promise { - return new Promise((resolve, reject) => { - const hash = crypto.createHash('sha256'); - const stream = fs.createReadStream(filePath); - - stream.on('data', (data) => hash.update(data)); - stream.on('end', () => resolve(hash.digest('hex'))); - stream.on('error', reject); - }); - } - - /** - * Fetch text content from URL - */ - private fetchText(url: string): Promise { - return new Promise((resolve, reject) => { - const handleResponse = (res: http.IncomingMessage) => { - // Handle redirects - if (res.statusCode === 301 || res.statusCode === 302) { - const redirectUrl = res.headers.location; - if (!redirectUrl) { - reject(new Error('Redirect without location header')); - return; - } - this.fetchText(redirectUrl).then(resolve).catch(reject); - return; - } - - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); - return; - } - - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); - res.on('error', reject); - }; - - const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - req.on('error', reject); - req.setTimeout(30000, () => { - req.destroy(); - reject(new Error('Request timeout (30s)')); - }); - }); - } - - /** - * Extract archive (tar.gz or zip) - */ - private async extractArchive(archivePath: string, extension: 'tar.gz' | 'zip'): Promise { - if (extension === 'tar.gz') { - await this.extractTarGz(archivePath); - } else { - await this.extractZip(archivePath); - } - } - - /** - * Extract tar.gz archive using Node.js built-in modules - */ - private extractTarGz(archivePath: string): Promise { - return new Promise((resolve, reject) => { - const destDir = this.config.binPath; - const execName = getExecutableName(); - const archiveBinaryName = getArchiveBinaryName(); - - // Read and decompress - const gunzip = zlib.createGunzip(); - const input = fs.createReadStream(archivePath); - - let headerBuffer = Buffer.alloc(0); - let currentFile: { name: string; size: number } | null = null; - let bytesRead = 0; - let fileBuffer = Buffer.alloc(0); - - const processData = (data: Buffer) => { - headerBuffer = Buffer.concat([headerBuffer, data]); - - while (headerBuffer.length >= 512) { - if (!currentFile) { - // Parse tar header - const header = headerBuffer.subarray(0, 512); - headerBuffer = headerBuffer.subarray(512); - - // Check for empty header (end of archive) - if (header.every((b) => b === 0)) { - return; - } - - // Extract filename (bytes 0-99) - let name = ''; - for (let i = 0; i < 100 && header[i] !== 0; i++) { - name += String.fromCharCode(header[i]); - } - - // Extract size (bytes 124-135, octal) - let sizeStr = ''; - for (let i = 124; i < 136 && header[i] !== 0; i++) { - sizeStr += String.fromCharCode(header[i]); - } - const size = parseInt(sizeStr.trim(), 8) || 0; - - if (name && size > 0) { - // Extract just the filename (handle directories) - const baseName = path.basename(name); - if ( - baseName === execName || - baseName === archiveBinaryName || - baseName === 'cli-proxy-api' - ) { - currentFile = { name: baseName, size }; - fileBuffer = Buffer.alloc(0); - bytesRead = 0; - } else { - // Skip this file's data - const paddedSize = Math.ceil(size / 512) * 512; - if (headerBuffer.length >= paddedSize) { - headerBuffer = headerBuffer.subarray(paddedSize); - } else { - // Need to skip data in chunks - currentFile = { name: '', size: paddedSize }; - bytesRead = 0; - } - } - } - } else { - // Read file data - const remaining = currentFile.size - bytesRead; - const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length)); - headerBuffer = headerBuffer.subarray(chunk.length); - - if (currentFile.name) { - fileBuffer = Buffer.concat([fileBuffer, chunk]); - } - bytesRead += chunk.length; - - if (bytesRead >= currentFile.size) { - // File complete - if (currentFile.name) { - const destPath = path.join(destDir, execName); - fs.writeFileSync(destPath, fileBuffer); - this.log(`Extracted: ${currentFile.name} -> ${destPath}`); - } - - // Skip padding to 512-byte boundary - const paddedSize = Math.ceil(currentFile.size / 512) * 512; - const padding = paddedSize - currentFile.size; - if (headerBuffer.length >= padding) { - headerBuffer = headerBuffer.subarray(padding); - } - - currentFile = null; - fileBuffer = Buffer.alloc(0); - } - } - } - }; - - input.pipe(gunzip); - gunzip.on('data', processData); - gunzip.on('end', resolve); - gunzip.on('error', reject); - input.on('error', reject); - }); - } - - /** - * Extract zip archive using Node.js (simple implementation) - */ - private extractZip(archivePath: string): Promise { - return new Promise((resolve, reject) => { - const destDir = this.config.binPath; - const execName = getExecutableName(); - const archiveBinaryName = getArchiveBinaryName(); - const buffer = fs.readFileSync(archivePath); - - // Find End of Central Directory record (EOCD) - let eocdOffset = buffer.length - 22; - while (eocdOffset >= 0) { - if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) { - break; - } - eocdOffset--; - } - - if (eocdOffset < 0) { - reject(new Error('Invalid ZIP file: EOCD not found')); - return; - } - - const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16); - let offset = centralDirOffset; - - // Parse central directory - while (offset < eocdOffset) { - const sig = buffer.readUInt32LE(offset); - if (sig !== 0x02014b50) break; - - const compressionMethod = buffer.readUInt16LE(offset + 10); - const compressedSize = buffer.readUInt32LE(offset + 20); - const uncompressedSize = buffer.readUInt32LE(offset + 24); - const fileNameLength = buffer.readUInt16LE(offset + 28); - const extraFieldLength = buffer.readUInt16LE(offset + 30); - const commentLength = buffer.readUInt16LE(offset + 32); - const localHeaderOffset = buffer.readUInt32LE(offset + 42); - - const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength); - const baseName = path.basename(fileName); - - // Check if this is the executable we want - if ( - baseName === execName || - baseName === archiveBinaryName || - baseName === 'cli-proxy-api.exe' - ) { - // Read from local file header - const localOffset = localHeaderOffset; - const localSig = buffer.readUInt32LE(localOffset); - - if (localSig !== 0x04034b50) { - reject(new Error('Invalid local file header')); - return; - } - - const localFileNameLength = buffer.readUInt16LE(localOffset + 26); - const localExtraLength = buffer.readUInt16LE(localOffset + 28); - const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength; - - let fileData: Buffer; - if (compressionMethod === 0) { - // Stored (no compression) - fileData = buffer.subarray(dataOffset, dataOffset + compressedSize); - } else if (compressionMethod === 8) { - // Deflate - const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize); - fileData = zlib.inflateRawSync(compressed); - } else { - reject(new Error(`Unsupported compression method: ${compressionMethod}`)); - return; - } - - if (fileData.length !== uncompressedSize) { - reject(new Error('Decompression size mismatch')); - return; - } - - const destPath = path.join(destDir, execName); - fs.writeFileSync(destPath, fileData); - this.log(`Extracted: ${fileName} -> ${destPath}`); - resolve(); - return; - } - - offset += 46 + fileNameLength + extraFieldLength + commentLength; - } - - reject(new Error(`Executable not found in archive: ${execName}`)); - }); - } - - /** - * Delete binary (for cleanup or reinstall) - */ + /** Delete binary (for cleanup or reinstall) */ deleteBinary(): void { - const binaryPath = this.getBinaryPath(); - if (fs.existsSync(binaryPath)) { - fs.unlinkSync(binaryPath); - this.log(`Deleted: ${binaryPath}`); - } - } - - /** - * Sleep helper - */ - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Log message if verbose - */ - private log(message: string): void { - if (this.verbose) { - console.error(`[cliproxy] ${message}`); - } + deleteBinary(this.config.binPath, this.config.verbose); } } -/** - * Convenience function to ensure binary is available - * Respects version pin if set by user via 'ccs cliproxy --install ' - * @returns Path to CLIProxyAPI executable - */ +/** Convenience function respecting version pin */ export async function ensureCLIProxyBinary(verbose = false): Promise { const pinnedVersion = getPinnedVersion(); - if (pinnedVersion) { - // Version is pinned - use forceVersion to prevent auto-update - if (verbose) { - console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); - } - const manager = new BinaryManager({ + if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); + return new BinaryManager({ version: pinnedVersion, verbose, forceVersion: true, - }); - return manager.ensureBinary(); + }).ensureBinary(); } - - // No pin - allow auto-update to latest - const manager = new BinaryManager({ verbose }); - return manager.ensureBinary(); + return new BinaryManager({ verbose }).ensureBinary(); } -/** - * Check if CLIProxyAPI binary is installed - */ +/** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(): boolean { - const manager = new BinaryManager(); - return manager.isBinaryInstalled(); + return new BinaryManager().isBinaryInstalled(); } -/** - * Get CLIProxyAPI binary path (may not exist) - */ +/** Get CLIProxyAPI binary path (may not exist) */ export function getCLIProxyPath(): string { - const manager = new BinaryManager(); - return manager.getBinaryPath(); + return new BinaryManager().getBinaryPath(); } -/** - * Get installed CLIProxyAPI version from .version file - * Returns the fallback version if not installed or version file missing - */ +/** Get installed CLIProxyAPI version from .version file */ export function getInstalledCliproxyVersion(): string { - const versionFile = path.join(getBinDir(), '.version'); - if (fs.existsSync(versionFile)) { - try { - return fs.readFileSync(versionFile, 'utf8').trim(); - } catch { - return CLIPROXY_FALLBACK_VERSION; - } - } - return CLIPROXY_FALLBACK_VERSION; + return readInstalledVersion(getBinDir(), CLIPROXY_FALLBACK_VERSION); } -/** - * Install a specific version of CLIProxyAPI - * Deletes existing binary and downloads the specified version - * - * @param version Version to install (e.g., "6.5.53") - * @param verbose Enable verbose logging - */ +/** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion(version: string, verbose = false): Promise { - // Use forceVersion to prevent auto-upgrade to latest const manager = new BinaryManager({ version, verbose, forceVersion: true }); - - // Delete existing binary if present if (manager.isBinaryInstalled()) { - const currentVersion = getInstalledCliproxyVersion(); - if (verbose) { - console.log(info(`Removing existing CLIProxyAPI v${currentVersion}`)); - } + if (verbose) + console.log(info(`Removing existing CLIProxyAPI v${getInstalledCliproxyVersion()}`)); manager.deleteBinary(); } - - // Install specified version (forceVersion prevents auto-upgrade) await manager.ensureBinary(); } -/** - * Fetch the latest CLIProxyAPI version from GitHub API - * @returns Latest version string (e.g., "6.5.53") - */ +/** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(): Promise { - const manager = new BinaryManager(); - const result = await manager.checkForUpdates(); + const result = await new BinaryManager().checkForUpdates(); return result.latestVersion; } @@ -1005,76 +128,21 @@ export interface CliproxyUpdateCheckResult { currentVersion: string; latestVersion: string; fromCache: boolean; - checkedAt: number; // Unix timestamp of last check + checkedAt: number; } -/** - * Check for CLIProxyAPI binary updates - * @returns Update check result with version info - */ +/** Check for CLIProxyAPI binary updates */ export async function checkCliproxyUpdate(): Promise { - const manager = new BinaryManager(); - return manager.checkForUpdates(); + return new BinaryManager().checkForUpdates(); } -/** - * Get path to version pin file - * @returns Absolute path to .version-pin file - */ -export function getVersionPinPath(): string { - return path.join(getBinDir(), VERSION_PIN_FILE); -} - -/** - * Get pinned version if one exists - * @returns Pinned version string, or null if not pinned - */ -export function getPinnedVersion(): string | null { - const pinPath = getVersionPinPath(); - if (!fs.existsSync(pinPath)) { - return null; - } - try { - return fs.readFileSync(pinPath, 'utf8').trim(); - } catch { - return null; - } -} - -/** - * Save pinned version to persist user's explicit choice - * @param version Version to pin (e.g., "6.5.50") - */ -export function savePinnedVersion(version: string): void { - const pinPath = getVersionPinPath(); - try { - fs.mkdirSync(path.dirname(pinPath), { recursive: true }); - fs.writeFileSync(pinPath, version, 'utf8'); - } catch { - // Silent fail - not critical but log if verbose - } -} - -/** - * Clear pinned version (unpin) - */ -export function clearPinnedVersion(): void { - const pinPath = getVersionPinPath(); - if (fs.existsSync(pinPath)) { - try { - fs.unlinkSync(pinPath); - } catch { - // Silent fail - } - } -} - -/** - * Check if a version is currently pinned - * @returns true if a version is pinned - */ -export function isVersionPinned(): boolean { - return getPinnedVersion() !== null; -} +// Re-export version pin functions +export { + getVersionPinPath, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, +}; export default BinaryManager; diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts new file mode 100644 index 00000000..8ef38ac0 --- /dev/null +++ b/src/cliproxy/binary/downloader.ts @@ -0,0 +1,230 @@ +/** + * Binary Downloader + * Handles downloading files with retry logic, progress tracking, and redirect following. + */ + +import * as fs from 'fs'; +import * as https from 'https'; +import * as http from 'http'; +import { DownloadResult, ProgressCallback } from '../types'; + +/** Default configuration for downloader */ +export interface DownloaderConfig { + /** Maximum retry attempts */ + maxRetries: number; + /** Enable verbose logging */ + verbose: boolean; +} + +const DEFAULT_CONFIG: DownloaderConfig = { + maxRetries: 3, + verbose: false, +}; + +/** + * Download file from URL with progress tracking + */ +export function downloadFile( + url: string, + destPath: string, + onProgress?: ProgressCallback, + verbose = false +): Promise { + return new Promise((resolve, reject) => { + const handleResponse = (res: http.IncomingMessage) => { + // Handle redirects (GitHub releases use 302) + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + if (verbose) { + console.error(`[cliproxy] Following redirect: ${redirectUrl}`); + } + downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + const totalBytes = parseInt(res.headers['content-length'] || '0', 10); + let downloadedBytes = 0; + + const fileStream = fs.createWriteStream(destPath); + + res.on('data', (chunk: Buffer) => { + downloadedBytes += chunk.length; + if (onProgress && totalBytes > 0) { + onProgress({ + total: totalBytes, + downloaded: downloadedBytes, + percentage: Math.round((downloadedBytes / totalBytes) * 100), + }); + } + }); + + res.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + + fileStream.on('error', (err) => { + fs.unlink(destPath, () => {}); // Cleanup partial file + reject(err); + }); + + res.on('error', (err) => { + fs.unlink(destPath, () => {}); + reject(err); + }); + }; + + const protocol = url.startsWith('https') ? https : http; + const req = protocol.get(url, handleResponse); + + req.on('error', reject); + req.setTimeout(60000, () => { + req.destroy(); + reject(new Error('Download timeout (60s)')); + }); + }); +} + +/** + * Download file with retry logic and exponential backoff + */ +export async function downloadWithRetry( + url: string, + destPath: string, + config: Partial = {} +): Promise { + const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config }; + let lastError = ''; + let retries = 0; + + while (retries < maxRetries) { + try { + await downloadFile(url, destPath, undefined, verbose); + return { success: true, filePath: destPath, retries }; + } catch (error) { + const err = error as Error; + lastError = err.message; + retries++; + + if (retries < maxRetries) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, retries - 1) * 1000; + if (verbose) { + console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`); + } + await sleep(delay); + } + } + } + + return { + success: false, + error: `Download failed after ${retries} attempts: ${lastError}`, + retries, + }; +} + +/** + * Fetch text content from URL + */ +export function fetchText(url: string, verbose = false): Promise { + return new Promise((resolve, reject) => { + const handleResponse = (res: http.IncomingMessage) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + fetchText(redirectUrl, verbose).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + res.on('error', reject); + }; + + const protocol = url.startsWith('https') ? https : http; + const req = protocol.get(url, handleResponse); + req.on('error', reject); + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error('Request timeout (30s)')); + }); + }); +} + +/** + * Fetch JSON from URL (for GitHub API) + */ +export function fetchJson(url: string, verbose = false): Promise> { + return new Promise((resolve, reject) => { + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0', + Accept: 'application/vnd.github.v3+json', + }, + }; + + const handleResponse = (res: http.IncomingMessage) => { + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + fetchJson(redirectUrl, verbose).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`GitHub API error: HTTP ${res.statusCode}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Invalid JSON from GitHub API')); + } + }); + res.on('error', reject); + }; + + const req = https.get(url, options, handleResponse); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('GitHub API timeout (10s)')); + }); + }); +} + +/** + * Sleep helper + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/cliproxy/binary/extractor.ts b/src/cliproxy/binary/extractor.ts new file mode 100644 index 00000000..09d496b1 --- /dev/null +++ b/src/cliproxy/binary/extractor.ts @@ -0,0 +1,28 @@ +/** + * Archive Extractor + * Facade for tar.gz and zip archive extraction. + */ + +import { ArchiveExtension } from '../types'; +import { extractTarGz } from './tar-extractor'; +import { extractZip } from './zip-extractor'; + +// Re-export for convenience +export { extractTarGz } from './tar-extractor'; +export { extractZip } from './zip-extractor'; + +/** + * Extract archive based on extension + */ +export async function extractArchive( + archivePath: string, + destDir: string, + extension: ArchiveExtension, + verbose = false +): Promise { + if (extension === 'tar.gz') { + await extractTarGz(archivePath, destDir, verbose); + } else { + await extractZip(archivePath, destDir, verbose); + } +} diff --git a/src/cliproxy/binary/index.ts b/src/cliproxy/binary/index.ts new file mode 100644 index 00000000..e0336258 --- /dev/null +++ b/src/cliproxy/binary/index.ts @@ -0,0 +1,44 @@ +/** + * Binary Module - Barrel Export + * Re-exports all binary management functionality. + */ + +// Types +export type { VersionCache, UpdateCheckResult } from './types'; +export { VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE, GITHUB_API_LATEST_RELEASE } from './types'; + +// Downloader +export { downloadFile, downloadWithRetry, fetchText, fetchJson } from './downloader'; + +// Verifier +export { computeChecksum, parseChecksum, verifyChecksum } from './verifier'; + +// Version Cache +export { + getVersionCachePath, + getVersionPinPath, + readVersionCache, + writeVersionCache, + readInstalledVersion, + writeInstalledVersion, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, +} from './version-cache'; + +// Version Checker +export { isNewerVersion, fetchLatestVersion, checkForUpdates } from './version-checker'; + +// Extractor +export { extractTarGz, extractZip, extractArchive } from './extractor'; + +// Updater +export { + downloadAndInstall, + deleteBinary, + getBinaryPath, + isBinaryInstalled, + getBinaryInfo, + ensureBinary, +} from './updater'; diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts new file mode 100644 index 00000000..e640a043 --- /dev/null +++ b/src/cliproxy/binary/installer.ts @@ -0,0 +1,119 @@ +/** + * Binary Installer + * Handles downloading, verifying, and extracting binary. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { BinaryManagerConfig } from '../types'; +import { + detectPlatform, + getDownloadUrl, + getChecksumsUrl, + getExecutableName, +} from '../platform-detector'; +import { downloadWithRetry } from './downloader'; +import { verifyChecksum, computeChecksum } from './verifier'; +import { extractArchive } from './extractor'; +import { writeInstalledVersion } from './version-cache'; +import { ProgressIndicator } from '../../utils/progress-indicator'; +import { ok } from '../../utils/ui'; + +/** + * Download and install the binary + */ +export async function downloadAndInstall( + config: BinaryManagerConfig, + verbose = false +): Promise { + const platform = detectPlatform(config.version); + const downloadUrl = getDownloadUrl(config.version); + const checksumsUrl = getChecksumsUrl(config.version); + + fs.mkdirSync(config.binPath, { recursive: true }); + const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`); + const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${config.version}`); + spinner.start(); + + try { + const result = await downloadWithRetry(downloadUrl, archivePath, { + maxRetries: config.maxRetries, + verbose, + }); + if (!result.success) { + spinner.fail('Download failed'); + throw new Error(result.error || 'Download failed after retries'); + } + + spinner.update('Verifying checksum'); + const checksumResult = await verifyChecksum( + archivePath, + platform.binaryName, + checksumsUrl, + verbose + ); + + if (!checksumResult.valid) { + spinner.fail('Checksum mismatch'); + fs.unlinkSync(archivePath); + throw new Error( + `Checksum mismatch for ${platform.binaryName}\nExpected: ${checksumResult.expected}\n` + + `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}` + ); + } + + spinner.update('Extracting binary'); + await extractArchive(archivePath, config.binPath, platform.extension, verbose); + spinner.succeed('CLIProxyAPI ready'); + fs.unlinkSync(archivePath); + + const binaryPath = path.join(config.binPath, getExecutableName()); + if (platform.os !== 'windows' && fs.existsSync(binaryPath)) { + fs.chmodSync(binaryPath, 0o755); + if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`); + } + + writeInstalledVersion(config.binPath, config.version); + console.log(ok(`CLIProxyAPI v${config.version} installed successfully`)); + } catch (error) { + spinner.fail('Installation failed'); + throw error; + } +} + +/** Delete binary (for cleanup or reinstall) */ +export function deleteBinary(binPath: string, verbose = false): void { + const binaryPath = path.join(binPath, getExecutableName()); + if (fs.existsSync(binaryPath)) { + fs.unlinkSync(binaryPath); + if (verbose) console.error(`[cliproxy] Deleted: ${binaryPath}`); + } +} + +/** Get binary path */ +export function getBinaryPath(binPath: string): string { + return path.join(binPath, getExecutableName()); +} + +/** Check if binary exists */ +export function isBinaryInstalled(binPath: string): boolean { + return fs.existsSync(getBinaryPath(binPath)); +} + +/** Get binary info if installed */ +export async function getBinaryInfo( + binPath: string, + version: string +): Promise<{ + path: string; + version: string; + platform: ReturnType; + checksum: string; +} | null> { + const binaryPath = getBinaryPath(binPath); + if (!fs.existsSync(binaryPath)) return null; + + const platform = detectPlatform(); + const checksum = await computeChecksum(binaryPath); + return { path: binaryPath, version, platform, checksum }; +} diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts new file mode 100644 index 00000000..aed9ef3c --- /dev/null +++ b/src/cliproxy/binary/lifecycle.ts @@ -0,0 +1,86 @@ +/** + * Binary Lifecycle Manager + * Handles ensuring binary availability and auto-updates. + */ + +import * as fs from 'fs'; +import { BinaryManagerConfig } from '../types'; +import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker'; +import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; +import { info } from '../../utils/ui'; +import { isCliproxyRunning } from '../stats-fetcher'; +import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; + +/** Log helper */ +function log(message: string, verbose: boolean): void { + if (verbose) console.error(`[cliproxy] ${message}`); +} + +/** Handle auto-update when binary exists */ +async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { + const updateResult = await checkForUpdates(config.binPath, config.version, verbose); + if (!updateResult.hasUpdate) return; + + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); + const updateMsg = `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`; + + if (proxyRunning) { + console.log(info(updateMsg)); + console.log(info('Run "ccs cliproxy stop" then restart to apply update')); + log('Skipping update: CLIProxyAPI is currently running', verbose); + } else { + console.log(info(updateMsg)); + console.log(info('Updating CLIProxyAPI...')); + deleteBinary(config.binPath, verbose); + config.version = updateResult.latestVersion; + await downloadAndInstall(config, verbose); + } +} + +/** + * Ensure binary is available (download if missing, update if outdated) + * @returns Path to executable binary + */ +export async function ensureBinary(config: BinaryManagerConfig): Promise { + const verbose = config.verbose; + const binaryPath = getBinaryPath(config.binPath); + + // Binary exists - check for updates unless forceVersion + if (fs.existsSync(binaryPath)) { + log(`Binary exists: ${binaryPath}`, verbose); + + if (config.forceVersion) { + log('Force version mode: skipping auto-update', verbose); + return binaryPath; + } + + try { + await handleAutoUpdate(config, verbose); + } catch (error) { + const err = error as Error; + log(`Update check failed (non-blocking): ${err.message}`, verbose); + } + + return binaryPath; + } + + // Binary missing - download + log('Binary not found, downloading...', verbose); + + if (!config.forceVersion) { + try { + const latestVersion = await fetchLatestVersion(verbose); + if (latestVersion && isNewerVersion(latestVersion, config.version)) { + log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose); + config.version = latestVersion; + } + } catch { + log(`Using pinned version: ${config.version}`, verbose); + } + } else { + log(`Force version mode: using specified version ${config.version}`, verbose); + } + + await downloadAndInstall(config, verbose); + return binaryPath; +} diff --git a/src/cliproxy/binary/tar-extractor.ts b/src/cliproxy/binary/tar-extractor.ts new file mode 100644 index 00000000..305df2e4 --- /dev/null +++ b/src/cliproxy/binary/tar-extractor.ts @@ -0,0 +1,98 @@ +/** + * Tar.gz Archive Extractor + * Handles extraction of tar.gz archives using Node.js built-in modules. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { getExecutableName, getArchiveBinaryName } from '../platform-detector'; + +/** + * Extract tar.gz archive using Node.js built-in modules + */ +export function extractTarGz(archivePath: string, destDir: string, verbose = false): Promise { + return new Promise((resolve, reject) => { + const execName = getExecutableName(); + const archiveBinaryName = getArchiveBinaryName(); + const gunzip = zlib.createGunzip(); + const input = fs.createReadStream(archivePath); + + let headerBuffer = Buffer.alloc(0); + let currentFile: { name: string; size: number } | null = null; + let bytesRead = 0; + let fileBuffer = Buffer.alloc(0); + + const processData = (data: Buffer) => { + headerBuffer = Buffer.concat([headerBuffer, data]); + + while (headerBuffer.length >= 512) { + if (!currentFile) { + const header = headerBuffer.subarray(0, 512); + headerBuffer = headerBuffer.subarray(512); + + if (header.every((b) => b === 0)) return; + + let name = ''; + for (let i = 0; i < 100 && header[i] !== 0; i++) { + name += String.fromCharCode(header[i]); + } + + let sizeStr = ''; + for (let i = 124; i < 136 && header[i] !== 0; i++) { + sizeStr += String.fromCharCode(header[i]); + } + const size = parseInt(sizeStr.trim(), 8) || 0; + + if (name && size > 0) { + const baseName = path.basename(name); + if ( + baseName === execName || + baseName === archiveBinaryName || + baseName === 'cli-proxy-api' + ) { + currentFile = { name: baseName, size }; + fileBuffer = Buffer.alloc(0); + bytesRead = 0; + } else { + const paddedSize = Math.ceil(size / 512) * 512; + if (headerBuffer.length >= paddedSize) { + headerBuffer = headerBuffer.subarray(paddedSize); + } else { + currentFile = { name: '', size: paddedSize }; + bytesRead = 0; + } + } + } + } else { + const remaining = currentFile.size - bytesRead; + const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length)); + headerBuffer = headerBuffer.subarray(chunk.length); + + if (currentFile.name) fileBuffer = Buffer.concat([fileBuffer, chunk]); + bytesRead += chunk.length; + + if (bytesRead >= currentFile.size) { + if (currentFile.name) { + const destPath = path.join(destDir, execName); + fs.writeFileSync(destPath, fileBuffer); + if (verbose) + console.error(`[cliproxy] Extracted: ${currentFile.name} -> ${destPath}`); + } + const paddedSize = Math.ceil(currentFile.size / 512) * 512; + const padding = paddedSize - currentFile.size; + if (headerBuffer.length >= padding) headerBuffer = headerBuffer.subarray(padding); + currentFile = null; + fileBuffer = Buffer.alloc(0); + } + } + } + }; + + input.pipe(gunzip); + gunzip.on('data', processData); + gunzip.on('end', resolve); + gunzip.on('error', reject); + input.on('error', reject); + }); +} diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts new file mode 100644 index 00000000..461100f0 --- /dev/null +++ b/src/cliproxy/binary/types.ts @@ -0,0 +1,29 @@ +/** + * Binary Module Type Definitions + * Types specific to binary management operations. + */ + +/** Version cache file structure */ +export interface VersionCache { + latestVersion: string; + checkedAt: number; +} + +/** Update check result */ +export interface UpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; + checkedAt: number; // Unix timestamp of last check +} + +/** Cache duration for version check (1 hour in milliseconds) */ +export const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; + +/** Version pin file name - stores user's explicit version choice */ +export const VERSION_PIN_FILE = '.version-pin'; + +/** GitHub API URL for latest release */ +export const GITHUB_API_LATEST_RELEASE = + 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest'; diff --git a/src/cliproxy/binary/updater.ts b/src/cliproxy/binary/updater.ts new file mode 100644 index 00000000..b95c356b --- /dev/null +++ b/src/cliproxy/binary/updater.ts @@ -0,0 +1,16 @@ +/** + * Binary Updater + * Re-exports installer and lifecycle functionality. + */ + +// Re-export installer functions +export { + downloadAndInstall, + deleteBinary, + getBinaryPath, + isBinaryInstalled, + getBinaryInfo, +} from './installer'; + +// Re-export lifecycle functions +export { ensureBinary } from './lifecycle'; diff --git a/src/cliproxy/binary/verifier.ts b/src/cliproxy/binary/verifier.ts new file mode 100644 index 00000000..c0b4bd47 --- /dev/null +++ b/src/cliproxy/binary/verifier.ts @@ -0,0 +1,66 @@ +/** + * Binary Verifier + * Handles checksum verification for downloaded binaries. + */ + +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import { ChecksumResult } from '../types'; +import { fetchText } from './downloader'; + +/** + * Compute SHA256 checksum of file + */ +export function computeChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +/** + * Parse checksum from checksums.txt content + */ +export function parseChecksum(content: string, binaryName: string): string | null { + const lines = content.split('\n'); + for (const line of lines) { + // Format: "hash filename" or "hash filename" + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === binaryName) { + return parts[0].toLowerCase(); + } + } + return null; +} + +/** + * Verify file checksum against checksums.txt + */ +export async function verifyChecksum( + filePath: string, + binaryName: string, + checksumsUrl: string, + verbose = false +): Promise { + // Download checksums.txt + const checksumsContent = await fetchText(checksumsUrl, verbose); + + // Parse expected checksum + const expectedHash = parseChecksum(checksumsContent, binaryName); + if (!expectedHash) { + throw new Error(`Checksum not found for ${binaryName} in checksums.txt`); + } + + // Compute actual checksum + const actualHash = await computeChecksum(filePath); + + return { + valid: actualHash === expectedHash, + expected: expectedHash, + actual: actualHash, + }; +} diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts new file mode 100644 index 00000000..d77cf7cf --- /dev/null +++ b/src/cliproxy/binary/version-cache.ts @@ -0,0 +1,142 @@ +/** + * Version Cache Manager + * Handles reading/writing version cache to avoid excessive GitHub API calls. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCliproxyDir, getBinDir } from '../config-generator'; +import { VersionCache, VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE } from './types'; + +/** + * Get path to version cache file + */ +export function getVersionCachePath(): string { + return path.join(getCliproxyDir(), '.version-cache.json'); +} + +/** + * Get path to version pin file + */ +export function getVersionPinPath(): string { + return path.join(getBinDir(), VERSION_PIN_FILE); +} + +/** + * Read version cache if still valid + */ +export function readVersionCache(): VersionCache | null { + const cachePath = getVersionCachePath(); + if (!fs.existsSync(cachePath)) { + return null; + } + + try { + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: VersionCache = JSON.parse(content); + + // Check if cache is still valid + if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + return cache; + } + + // Cache expired + return null; + } catch { + return null; + } +} + +/** + * Write version to cache + */ +export function writeVersionCache(version: string): void { + const cachePath = getVersionCachePath(); + const cache: VersionCache = { + latestVersion: version, + checkedAt: Date.now(), + }; + + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } catch { + // Silent fail - caching is optional + } +} + +/** + * Read installed version from .version file + */ +export function readInstalledVersion(binPath: string, fallbackVersion: string): string { + const versionFile = path.join(binPath, '.version'); + if (fs.existsSync(versionFile)) { + try { + return fs.readFileSync(versionFile, 'utf8').trim(); + } catch { + return fallbackVersion; + } + } + return fallbackVersion; +} + +/** + * Write installed version to .version file + */ +export function writeInstalledVersion(binPath: string, version: string): void { + const versionFile = path.join(binPath, '.version'); + try { + fs.writeFileSync(versionFile, version, 'utf8'); + } catch { + // Silent fail - not critical + } +} + +/** + * Get pinned version if one exists + */ +export function getPinnedVersion(): string | null { + const pinPath = getVersionPinPath(); + if (!fs.existsSync(pinPath)) { + return null; + } + try { + return fs.readFileSync(pinPath, 'utf8').trim(); + } catch { + return null; + } +} + +/** + * Save pinned version to persist user's explicit choice + */ +export function savePinnedVersion(version: string): void { + const pinPath = getVersionPinPath(); + try { + fs.mkdirSync(path.dirname(pinPath), { recursive: true }); + fs.writeFileSync(pinPath, version, 'utf8'); + } catch { + // Silent fail - not critical + } +} + +/** + * Clear pinned version (unpin) + */ +export function clearPinnedVersion(): void { + const pinPath = getVersionPinPath(); + if (fs.existsSync(pinPath)) { + try { + fs.unlinkSync(pinPath); + } catch { + // Silent fail + } + } +} + +/** + * Check if a version is currently pinned + */ +export function isVersionPinned(): boolean { + return getPinnedVersion() !== null; +} diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts new file mode 100644 index 00000000..c21398e7 --- /dev/null +++ b/src/cliproxy/binary/version-checker.ts @@ -0,0 +1,82 @@ +/** + * Version Checker + * Handles checking GitHub API for latest version and comparing semver. + */ + +import { fetchJson } from './downloader'; +import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache'; +import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types'; + +/** + * Compare semver versions (true if latest > current) + */ +export function isNewerVersion(latest: string, current: string): boolean { + const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0); + const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0); + + // Pad arrays to same length + while (latestParts.length < 3) latestParts.push(0); + while (currentParts.length < 3) currentParts.push(0); + + for (let i = 0; i < 3; i++) { + if (latestParts[i] > currentParts[i]) return true; + if (latestParts[i] < currentParts[i]) return false; + } + + return false; // Equal versions +} + +/** + * Fetch latest version from GitHub API + */ +export async function fetchLatestVersion(verbose = false): Promise { + const response = await fetchJson(GITHUB_API_LATEST_RELEASE, verbose); + + // Extract version from tag_name (format: "v6.5.27" or "6.5.27") + const tagName = response.tag_name as string; + if (!tagName) { + throw new Error('No tag_name in GitHub API response'); + } + + return tagName.replace(/^v/, ''); +} + +/** + * Check for updates by comparing installed version with latest release + * Uses cache to avoid hitting GitHub API on every run + */ +export async function checkForUpdates( + binPath: string, + configVersion: string, + verbose = false +): Promise { + const currentVersion = readInstalledVersion(binPath, configVersion); + + // Try cache first + const cache = readVersionCache(); + if (cache) { + if (verbose) { + console.error(`[cliproxy] Using cached version: ${cache.latestVersion}`); + } + return { + hasUpdate: isNewerVersion(cache.latestVersion, currentVersion), + currentVersion, + latestVersion: cache.latestVersion, + fromCache: true, + checkedAt: cache.checkedAt, + }; + } + + // Fetch from GitHub API + const latestVersion = await fetchLatestVersion(verbose); + const now = Date.now(); + writeVersionCache(latestVersion); + + return { + hasUpdate: isNewerVersion(latestVersion, currentVersion), + currentVersion, + latestVersion, + fromCache: false, + checkedAt: now, + }; +} diff --git a/src/cliproxy/binary/zip-extractor.ts b/src/cliproxy/binary/zip-extractor.ts new file mode 100644 index 00000000..5cc078ae --- /dev/null +++ b/src/cliproxy/binary/zip-extractor.ts @@ -0,0 +1,95 @@ +/** + * Zip Archive Extractor + * Handles extraction of zip archives using Node.js built-in modules. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { getExecutableName, getArchiveBinaryName } from '../platform-detector'; + +/** + * Extract zip archive using Node.js (simple implementation) + */ +export function extractZip(archivePath: string, destDir: string, verbose = false): Promise { + return new Promise((resolve, reject) => { + const execName = getExecutableName(); + const archiveBinaryName = getArchiveBinaryName(); + const buffer = fs.readFileSync(archivePath); + + // Find End of Central Directory record (EOCD) + let eocdOffset = buffer.length - 22; + while (eocdOffset >= 0) { + if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) break; + eocdOffset--; + } + + if (eocdOffset < 0) { + reject(new Error('Invalid ZIP file: EOCD not found')); + return; + } + + const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16); + let offset = centralDirOffset; + + while (offset < eocdOffset) { + const sig = buffer.readUInt32LE(offset); + if (sig !== 0x02014b50) break; + + const compressionMethod = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const fileNameLength = buffer.readUInt16LE(offset + 28); + const extraFieldLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localHeaderOffset = buffer.readUInt32LE(offset + 42); + + const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength); + const baseName = path.basename(fileName); + + if ( + baseName === execName || + baseName === archiveBinaryName || + baseName === 'cli-proxy-api.exe' + ) { + const localOffset = localHeaderOffset; + const localSig = buffer.readUInt32LE(localOffset); + + if (localSig !== 0x04034b50) { + reject(new Error('Invalid local file header')); + return; + } + + const localFileNameLength = buffer.readUInt16LE(localOffset + 26); + const localExtraLength = buffer.readUInt16LE(localOffset + 28); + const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength; + + let fileData: Buffer; + if (compressionMethod === 0) { + fileData = buffer.subarray(dataOffset, dataOffset + compressedSize); + } else if (compressionMethod === 8) { + const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize); + fileData = zlib.inflateRawSync(compressed); + } else { + reject(new Error(`Unsupported compression method: ${compressionMethod}`)); + return; + } + + if (fileData.length !== uncompressedSize) { + reject(new Error('Decompression size mismatch')); + return; + } + + const destPath = path.join(destDir, execName); + fs.writeFileSync(destPath, fileData); + if (verbose) console.error(`[cliproxy] Extracted: ${fileName} -> ${destPath}`); + resolve(); + return; + } + + offset += 46 + fileNameLength + extraFieldLength + commentLength; + } + + reject(new Error(`Executable not found in archive: ${execName}`)); + }); +} diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts new file mode 100644 index 00000000..c044aca4 --- /dev/null +++ b/src/cliproxy/services/binary-service.ts @@ -0,0 +1,176 @@ +/** + * CLIProxy Binary Service + * + * Handles CLIProxyAPI binary version management: + * - Get current version status + * - Install specific versions + * - Install latest version + * - Version pinning + */ + +import { + getInstalledCliproxyVersion, + installCliproxyVersion, + fetchLatestCliproxyVersion, + isCLIProxyInstalled, + getCLIProxyPath, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, +} from '../binary-manager'; +import { CLIPROXY_FALLBACK_VERSION } from '../platform-detector'; + +/** Binary status result */ +export interface BinaryStatusResult { + installed: boolean; + currentVersion: string | null; + pinnedVersion: string | null; + binaryPath: string; + fallbackVersion: string; +} + +/** Install result */ +export interface InstallResult { + success: boolean; + version: string; + error?: string; + wasPinned?: boolean; +} + +/** Latest version check result */ +export interface LatestVersionResult { + success: boolean; + latestVersion?: string; + currentVersion?: string; + updateAvailable?: boolean; + error?: string; +} + +/** + * Get current binary status + */ +export function getBinaryStatus(): BinaryStatusResult { + return { + installed: isCLIProxyInstalled(), + currentVersion: getInstalledCliproxyVersion(), + pinnedVersion: getPinnedVersion(), + binaryPath: getCLIProxyPath(), + fallbackVersion: CLIPROXY_FALLBACK_VERSION, + }; +} + +/** + * Check for latest version + */ +export async function checkLatestVersion(): Promise { + try { + const latestVersion = await fetchLatestCliproxyVersion(); + const currentVersion = getInstalledCliproxyVersion(); + const updateAvailable = latestVersion !== currentVersion; + + return { + success: true, + latestVersion, + currentVersion: currentVersion || undefined, + updateAvailable, + }; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Validate version format + */ +export function isValidVersionFormat(version: string): boolean { + return /^\d+\.\d+\.\d+$/.test(version); +} + +/** + * Install a specific version and pin it + */ +export async function installVersion(version: string, verbose = false): Promise { + if (!isValidVersionFormat(version)) { + return { + success: false, + version, + error: 'Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)', + }; + } + + try { + await installCliproxyVersion(version, verbose); + savePinnedVersion(version); + + return { + success: true, + version, + wasPinned: true, + }; + } catch (error) { + return { + success: false, + version, + error: (error as Error).message, + }; + } +} + +/** + * Install latest version and clear any pin + */ +export async function installLatest(verbose = false): Promise { + try { + const latestVersion = await fetchLatestCliproxyVersion(); + const currentVersion = getInstalledCliproxyVersion(); + const wasPinned = isVersionPinned(); + + if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { + return { + success: true, + version: latestVersion, + error: `Already running latest version: v${latestVersion}`, + }; + } + + await installCliproxyVersion(latestVersion, verbose); + clearPinnedVersion(); + + return { + success: true, + version: latestVersion, + wasPinned, + }; + } catch (error) { + return { + success: false, + version: '', + error: (error as Error).message, + }; + } +} + +/** + * Check if a version is pinned + */ +export function isPinned(): boolean { + return isVersionPinned(); +} + +/** + * Get pinned version if any + */ +export function getPinned(): string | null { + return getPinnedVersion(); +} + +/** + * Clear version pin + */ +export function clearPin(): void { + clearPinnedVersion(); +} diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts new file mode 100644 index 00000000..c4ece9c5 --- /dev/null +++ b/src/cliproxy/services/index.ts @@ -0,0 +1,40 @@ +/** + * CLIProxy Services + * Central export point for cliproxy service layer + */ + +// Variant management +export { + validateProfileName, + variantExists, + listVariants, + createVariant, + removeVariant, + type VariantConfig, + type VariantOperationResult, +} from './variant-service'; + +// Proxy lifecycle +export { + getProxyStatus, + stopProxy, + isProxyRunning, + getActiveSessionCount, + type ProxyStatusResult, + type StopProxyResult, +} from './proxy-lifecycle-service'; + +// Binary management +export { + getBinaryStatus, + checkLatestVersion, + isValidVersionFormat, + installVersion, + installLatest, + isPinned, + getPinned, + clearPin, + type BinaryStatusResult, + type InstallResult, + type LatestVersionResult, +} from './binary-service'; diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts new file mode 100644 index 00000000..7ea4c124 --- /dev/null +++ b/src/cliproxy/services/proxy-lifecycle-service.ts @@ -0,0 +1,58 @@ +/** + * CLIProxy Proxy Lifecycle Service + * + * Handles start/stop/status operations for CLIProxy instances. + * Delegates to session-tracker for actual process management. + */ + +import { + stopProxy as stopProxySession, + getProxyStatus as getProxyStatusSession, +} from '../session-tracker'; + +/** Proxy status result */ +export interface ProxyStatusResult { + running: boolean; + port?: number; + pid?: number; + sessionCount?: number; + startedAt?: string; +} + +/** Stop proxy result */ +export interface StopProxyResult { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} + +/** + * Get current proxy status + */ +export function getProxyStatus(): ProxyStatusResult { + return getProxyStatusSession(); +} + +/** + * Stop the running CLIProxy instance + */ +export async function stopProxy(): Promise { + return stopProxySession(); +} + +/** + * Check if proxy is currently running + */ +export function isProxyRunning(): boolean { + const status = getProxyStatusSession(); + return status.running; +} + +/** + * Get active session count + */ +export function getActiveSessionCount(): number { + const status = getProxyStatusSession(); + return status.sessionCount ?? 0; +} diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts new file mode 100644 index 00000000..a4b09058 --- /dev/null +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -0,0 +1,182 @@ +/** + * CLIProxy Variant Config Adapters + * + * Handles reading/writing variant config in both unified and legacy formats. + */ + +import * as fs from 'fs'; +import { getConfigPath, loadConfig } from '../../utils/config-manager'; +import { CLIProxyProvider } from '../types'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + isUnifiedMode, +} from '../../config/unified-config-loader'; + +/** Variant configuration structure */ +export interface VariantConfig { + provider: string; + settings?: string; + account?: string; + model?: string; +} + +/** + * Check if variant exists in config + */ +export function variantExistsInConfig(name: string): boolean { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return !!(config.cliproxy?.variants && name in config.cliproxy.variants); + } + const config = loadConfig(); + return !!(config.cliproxy && name in config.cliproxy); + } catch { + return false; + } +} + +/** + * List variants from config + */ +export function listVariantsFromConfig(): Record { + try { + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + const result: Record = {}; + for (const name of Object.keys(variants)) { + const v = variants[name]; + result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + } + return result; + } + + const config = loadConfig(); + const variants = config.cliproxy || {}; + const result: Record = {}; + for (const name of Object.keys(variants)) { + const v = variants[name] as { provider: string; settings: string; account?: string }; + result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + } + return result; + } catch { + return {}; + } +} + +/** + * Save variant to unified config + */ +export function saveVariantUnified( + name: string, + provider: CLIProxyProvider, + settingsPath: string, + account?: string +): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy) { + config.cliproxy = { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }; + } + if (!config.cliproxy.variants) { + config.cliproxy.variants = {}; + } + + config.cliproxy.variants[name] = { + provider, + account, + settings: settingsPath, + }; + + saveUnifiedConfig(config); +} + +/** + * Save variant to legacy JSON config + */ +export function saveVariantLegacy( + name: string, + provider: string, + settingsPath: string, + account?: string +): void { + const configPath = getConfigPath(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + if (!config.cliproxy) { + config.cliproxy = {}; + } + + const variantConfig: { provider: string; settings: string; account?: string } = { + provider, + settings: settingsPath, + }; + if (account) { + variantConfig.account = account; + } + config.cliproxy[name] = variantConfig; + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** + * Remove variant from unified config + */ +export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + return null; + } + + const variant = config.cliproxy.variants[name]; + delete config.cliproxy.variants[name]; + saveUnifiedConfig(config); + + return { provider: variant.provider, settings: variant.settings }; +} + +/** + * Remove variant from legacy JSON config + */ +export function removeVariantFromLegacyConfig(name: string): VariantConfig | null { + const configPath = getConfigPath(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + return null; + } + + if (!config.cliproxy || !(name in config.cliproxy)) { + return null; + } + + const variant = config.cliproxy[name] as { provider: string; settings: string }; + delete config.cliproxy[name]; + + if (Object.keys(config.cliproxy).length === 0) { + delete config.cliproxy; + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return variant; +} diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts new file mode 100644 index 00000000..8429ce08 --- /dev/null +++ b/src/cliproxy/services/variant-service.ts @@ -0,0 +1,139 @@ +/** + * CLIProxy Variant Service + * + * Handles CRUD operations for CLIProxy variant profiles. + * Supports both unified config (config.yaml) and legacy JSON format. + */ + +import * as path from 'path'; +import { CLIProxyProfileName } from '../../auth/profile-detector'; +import { CLIProxyProvider } from '../types'; +import { isReservedName } from '../../config/reserved-names'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { + createSettingsFile, + createSettingsFileUnified, + deleteSettingsFile, + getRelativeSettingsPath, +} from './variant-settings'; +import { + VariantConfig, + variantExistsInConfig, + listVariantsFromConfig, + saveVariantUnified, + saveVariantLegacy, + removeVariantFromUnifiedConfig, + removeVariantFromLegacyConfig, +} from './variant-config-adapter'; + +// Re-export VariantConfig from adapter +export type { VariantConfig } from './variant-config-adapter'; + +/** Result of variant operations */ +export interface VariantOperationResult { + success: boolean; + error?: string; + variant?: VariantConfig; + settingsPath?: string; +} + +/** + * Validate CLIProxy profile name + */ +export function validateProfileName(name: string): string | null { + if (!name) { + return 'Profile name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'Name must be 32 characters or less'; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Check if CLIProxy variant profile exists + */ +export function variantExists(name: string): boolean { + return variantExistsInConfig(name); +} + +/** + * List all CLIProxy variants + */ +export function listVariants(): Record { + return listVariantsFromConfig(); +} + +/** + * Create a new CLIProxy variant + */ +export function createVariant( + name: string, + provider: CLIProxyProfileName, + model: string, + account?: string +): VariantOperationResult { + try { + let settingsPath: string; + + if (isUnifiedMode()) { + settingsPath = createSettingsFileUnified(name, provider, model); + saveVariantUnified( + name, + provider as CLIProxyProvider, + getRelativeSettingsPath(provider, name), + account + ); + } else { + settingsPath = createSettingsFile(name, provider, model); + saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account); + } + + return { + success: true, + settingsPath, + variant: { provider, model, account }, + }; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Remove a CLIProxy variant + */ +export function removeVariant(name: string): VariantOperationResult { + try { + let variant: VariantConfig | null; + + if (isUnifiedMode()) { + const unifiedVariant = removeVariantFromUnifiedConfig(name); + if (unifiedVariant?.settings) { + deleteSettingsFile(unifiedVariant.settings); + } + variant = unifiedVariant; + } else { + variant = removeVariantFromLegacyConfig(name); + if (variant?.settings) { + deleteSettingsFile(variant.settings); + } + } + + if (!variant) { + return { success: false, error: `Variant '${name}' not found` }; + } + + return { success: true, variant }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts new file mode 100644 index 00000000..b3c4b185 --- /dev/null +++ b/src/cliproxy/services/variant-settings.ts @@ -0,0 +1,136 @@ +/** + * CLIProxy Variant Settings File Manager + * + * Handles creation of settings.json files for CLIProxy variants. + * These files contain environment variables for Claude CLI integration. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CLIProxyProfileName } from '../../auth/profile-detector'; +import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; +import { CLIProxyProvider } from '../types'; + +/** Environment settings structure */ +interface SettingsEnv { + ANTHROPIC_BASE_URL: string; + ANTHROPIC_AUTH_TOKEN: string; + ANTHROPIC_MODEL: string; + ANTHROPIC_DEFAULT_OPUS_MODEL: string; + ANTHROPIC_DEFAULT_SONNET_MODEL: string; + ANTHROPIC_DEFAULT_HAIKU_MODEL: string; +} + +interface SettingsFile { + env: SettingsEnv; +} + +/** + * Build settings env object for a variant + */ +function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv { + const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); + + return { + ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', + ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, + }; +} + +/** + * Ensure directory exists + */ +function ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Write settings file atomically + */ +function writeSettings(filePath: string, settings: SettingsFile): void { + fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); +} + +/** + * Get settings file path for a variant + */ +export function getSettingsFilePath(provider: CLIProxyProfileName, name: string): string { + const ccsDir = getCcsDir(); + return path.join(ccsDir, `${provider}-${name}.settings.json`); +} + +/** + * Get settings file name (without path) + */ +export function getSettingsFileName(provider: CLIProxyProfileName, name: string): string { + return `${provider}-${name}.settings.json`; +} + +/** + * Get relative settings path with ~ prefix + */ +export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: string): string { + return `~/.ccs/${getSettingsFileName(provider, name)}`; +} + +/** + * Create settings.json file for CLIProxy variant (legacy mode) + */ +export function createSettingsFile( + name: string, + provider: CLIProxyProfileName, + model: string +): string { + const ccsDir = getCcsDir(); + const settingsPath = getSettingsFilePath(provider, name); + + const settings: SettingsFile = { + env: buildSettingsEnv(provider, model), + }; + + ensureDir(ccsDir); + writeSettings(settingsPath, settings); + + return settingsPath; +} + +/** + * Create settings.json file for CLIProxy variant (unified mode) + */ +export function createSettingsFileUnified( + name: string, + provider: CLIProxyProfileName, + model: string +): string { + const ccsDir = path.join(os.homedir(), '.ccs'); + const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name)); + + const settings: SettingsFile = { + env: buildSettingsEnv(provider, model), + }; + + ensureDir(ccsDir); + writeSettings(settingsPath, settings); + + return settingsPath; +} + +/** + * Delete settings file if it exists + */ +export function deleteSettingsFile(settingsPath: string): boolean { + const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + if (fs.existsSync(resolvedPath)) { + fs.unlinkSync(resolvedPath); + return true; + } + return false; +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index a541fdf5..9aa579ec 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -4,14 +4,10 @@ * Manages CCS API profiles for custom API providers. * Commands: create, list, remove * - * Supports dual-mode configuration: - * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists - * - Legacy JSON format (config.json, *.settings.json) as fallback + * CLI parsing and output formatting only. + * Business logic delegated to src/api/services/. */ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; import { initUI, header, @@ -26,15 +22,19 @@ import { infoBox, } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; -import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; -import { isReservedName } from '../config/reserved-names'; import { - hasUnifiedConfig, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../config/unified-config-loader'; -import { deleteAllProfileSecrets } from '../config/secrets-manager'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; + validateApiName, + validateUrl, + getUrlWarning, + sanitizeBaseUrl, + apiProfileExists, + listApiProfiles, + createApiProfile, + removeApiProfile, + getApiProfileNames, + isUsingUnifiedConfig, + type ModelMapping, +} from '../api/services'; interface ApiCommandArgs { name?: string; @@ -45,9 +45,7 @@ interface ApiCommandArgs { yes?: boolean; } -/** - * Parse command line arguments for api commands - */ +/** Parse command line arguments for api commands */ function parseArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = {}; @@ -72,228 +70,7 @@ function parseArgs(args: string[]): ApiCommandArgs { return result; } -/** - * Validate API profile name - */ -function validateApiName(name: string): string | null { - if (!name) { - return 'API name is required'; - } - if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { - return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore'; - } - if (name.length > 32) { - return 'API name must be 32 characters or less'; - } - // Use centralized reserved names list - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } - return null; -} - -/** - * Validate URL format and warn about common mistakes - */ -function validateUrl(url: string): string | null { - if (!url) { - return 'Base URL is required'; - } - try { - new URL(url); - return null; - } catch { - return 'Invalid URL format (must include protocol, e.g., https://)'; - } -} - -/** - * Check if URL looks like it includes endpoint path (common mistake) - * Returns warning message if problematic, null if OK - */ -function getUrlWarning(url: string): string | null { - const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions']; - const lowerUrl = url.toLowerCase(); - - for (const path of problematicPaths) { - if (lowerUrl.endsWith(path)) { - return ( - `URL ends with "${path}" - Claude appends this automatically.\n` + - ` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}` - ); - } - } - return null; -} - -/** - * Check if unified config mode is active - */ -function isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); -} - -/** - * Check if API profile already exists in config - */ -function apiExists(name: string): boolean { - try { - if (isUnifiedMode()) { - const config = loadOrCreateUnifiedConfig(); - return name in config.profiles; - } - const config = loadConfig(); - return name in config.profiles; - } catch { - return false; - } -} - -/** Model mapping for API profiles */ -interface ModelMapping { - default: string; - opus: string; - sonnet: string; - haiku: string; -} - -/** - * Create settings.json file for API profile - * Includes all 4 model fields for proper Claude CLI integration - */ -function createSettingsFile( - name: string, - baseUrl: string, - apiKey: string, - models: ModelMapping -): string { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${name}.settings.json`); - - const settings = { - env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: models.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, - }, - }; - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - return settingsPath; -} - -/** - * Update config.json with new API profile - */ -function updateConfig(name: string, _settingsPath: string): void { - const configPath = getConfigPath(); - const ccsDir = getCcsDir(); - - // Read existing config or create new - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - config = { profiles: {} }; - } - - // Use relative path with ~ for portability - const relativePath = `~/.ccs/${name}.settings.json`; - config.profiles[name] = relativePath; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - // Write config atomically (write to temp, then rename) - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); -} - -/** - * Create API profile in unified config - * Creates *.settings.json file and stores reference in config.yaml - * (matching Claude's ~/.claude/settings.json pattern) - */ -function createApiProfileUnified( - name: string, - baseUrl: string, - apiKey: string, - models: ModelMapping -): void { - const ccsDir = path.join(os.homedir(), '.ccs'); - const settingsFile = `${name}.settings.json`; - const settingsPath = path.join(ccsDir, settingsFile); - - // Create settings file with all env vars (matching Claude's pattern) - const settings = { - env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: models.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, - }, - }; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - // Write settings file - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - - // Store reference in config.yaml - const config = loadOrCreateUnifiedConfig(); - config.profiles[name] = { - type: 'api', - settings: `~/.ccs/${settingsFile}`, - }; - saveUnifiedConfig(config); -} - -/** - * Remove API profile from unified config - */ -function removeApiProfileUnified(name: string): void { - const config = loadOrCreateUnifiedConfig(); - const profile = config.profiles[name]; - - if (!profile) { - throw new Error(`API profile not found: ${name}`); - } - - // Delete the settings file if it exists - if (profile.settings) { - const settingsPath = profile.settings.replace(/^~/, os.homedir()); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); - } - } - - delete config.profiles[name]; - - // Clear default if it was the deleted profile - if (config.default === name) { - config.default = undefined; - } - - saveUnifiedConfig(config); - - // Remove any legacy secrets (backward compat) - deleteAllProfileSecrets(name); -} - -/** - * Handle 'ccs api create' command - */ +/** Handle 'ccs api create' command */ async function handleCreate(args: string[]): Promise { await initUI(); const parsedArgs = parseArgs(args); @@ -316,7 +93,7 @@ async function handleCreate(args: string[]): Promise { } // Check if exists - if (apiExists(name) && !parsedArgs.force) { + if (apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); @@ -327,9 +104,7 @@ async function handleCreate(args: string[]): Promise { if (!baseUrl) { baseUrl = await InteractivePrompt.input( 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', - { - validate: validateUrl, - } + { validate: validateUrl } ); } else { const error = validateUrl(baseUrl); @@ -348,10 +123,9 @@ async function handleCreate(args: string[]): Promise { default: false, }); if (!continueAnyway) { - // Let user re-enter URL baseUrl = await InteractivePrompt.input('API Base URL', { validate: validateUrl, - default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''), + default: sanitizeBaseUrl(baseUrl), }); } } @@ -366,7 +140,7 @@ async function handleCreate(args: string[]): Promise { } } - // Step 4: Model (optional) + // Step 4: Model configuration const defaultModel = 'claude-sonnet-4-5-20250929'; let model = parsedArgs.model; if (!model && !parsedArgs.yes) { @@ -377,16 +151,12 @@ async function handleCreate(args: string[]): Promise { model = model || defaultModel; // Step 5: Model mapping for Opus/Sonnet/Haiku - // Auto-show if user entered a custom model, otherwise ask let opusModel = model; let sonnetModel = model; let haikuModel = model; - const isCustomModel = model !== defaultModel; if (!parsedArgs.yes) { - // If user entered custom model, auto-prompt for model mapping - // Otherwise, ask if they want to configure it let wantCustomMapping = isCustomModel; if (!isCustomModel) { @@ -400,11 +170,13 @@ async function handleCreate(args: string[]): Promise { if (wantCustomMapping) { console.log(''); - if (isCustomModel) { - console.log(dim('Configure model IDs for each tier (defaults to your model):')); - } else { - console.log(dim('Leave blank to use the default model for each.')); - } + console.log( + dim( + isCustomModel + ? 'Configure model IDs for each tier (defaults to your model):' + : 'Leave blank to use the default model for each.' + ) + ); opusModel = (await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', { default: model, @@ -420,7 +192,6 @@ async function handleCreate(args: string[]): Promise { } } - // Build model mapping const models: ModelMapping = { default: model, opus: opusModel, @@ -428,234 +199,102 @@ async function handleCreate(args: string[]): Promise { haiku: haikuModel, }; - // Check if custom model mapping is configured - const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; - - // Create files + // Create profile console.log(''); console.log(info('Creating API profile...')); - try { - const settingsFile = `~/.ccs/${name}.settings.json`; + const result = createApiProfile(name, baseUrl, apiKey, models); - if (isUnifiedMode()) { - // Use unified config format - createApiProfileUnified(name, baseUrl, apiKey, models); - console.log(''); - - // Build info message - let infoMsg = - `API: ${name}\n` + - `Config: ~/.ccs/config.yaml\n` + - `Settings: ${settingsFile}\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`; - - if (hasCustomMapping) { - infoMsg += - `\n\nModel Mapping:\n` + - ` Opus: ${opusModel}\n` + - ` Sonnet: ${sonnetModel}\n` + - ` Haiku: ${haikuModel}`; - } - - console.log(infoBox(infoMsg, 'API Profile Created')); - } else { - // Use legacy JSON format - const settingsPath = createSettingsFile(name, baseUrl, apiKey, models); - updateConfig(name, settingsPath); - console.log(''); - - let infoMsg = - `API: ${name}\n` + - `Settings: ${settingsFile}\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`; - - if (hasCustomMapping) { - infoMsg += - `\n\nModel Mapping:\n` + - ` Opus: ${opusModel}\n` + - ` Sonnet: ${sonnetModel}\n` + - ` Haiku: ${haikuModel}`; - } - - console.log(infoBox(infoMsg, 'API Profile Created')); - } - - console.log(''); - console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); - console.log(''); - console.log(header('Edit Settings')); - console.log(` ${dim('To modify env vars later:')}`); - console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`); - console.log(''); - } catch (error) { - console.log(fail(`Failed to create API profile: ${(error as Error).message}`)); + if (!result.success) { + console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); } -} -/** - * Check if API profile has real API key (not placeholder) - */ -function isApiConfigured(apiName: string): boolean { - try { - if (isUnifiedMode()) { - // Check secrets.yaml for unified config - const { getProfileSecrets } = require('../config/secrets-manager'); - const secrets = getProfileSecrets(apiName); - const token = secrets?.ANTHROPIC_AUTH_TOKEN || ''; - return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); - } - // Legacy: check settings.json file - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); - if (!fs.existsSync(settingsPath)) return false; + // Display success + console.log(''); + const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; + let infoMsg = + `API: ${name}\n` + + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + + `Settings: ${result.settingsFile}\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`; - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || ''; - // Check if it's a placeholder or empty - return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); - } catch { - return false; + if (hasCustomMapping) { + infoMsg += + `\n\nModel Mapping:\n` + + ` Opus: ${opusModel}\n` + + ` Sonnet: ${sonnetModel}\n` + + ` Haiku: ${haikuModel}`; } + + console.log(infoBox(infoMsg, 'API Profile Created')); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + console.log(header('Edit Settings')); + console.log(` ${dim('To modify env vars later:')}`); + console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`); + console.log(''); } -/** - * Handle 'ccs api list' command - */ +/** Handle 'ccs api list' command */ async function handleList(): Promise { await initUI(); console.log(header('CCS API Profiles')); console.log(''); - try { - if (isUnifiedMode()) { - // List from unified config - const unifiedConfig = loadOrCreateUnifiedConfig(); - const apis = Object.keys(unifiedConfig.profiles); + const { profiles, variants } = listApiProfiles(); - if (apis.length === 0) { - console.log(warn('No API profiles configured')); - console.log(''); - console.log('To create an API profile:'); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - return; - } + if (profiles.length === 0) { + console.log(warn('No API profiles configured')); + console.log(''); + console.log('To create an API profile:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } - // Build table data with status indicators - const rows: string[][] = apis.map((name) => { - const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning'); - return [name, 'config.yaml', status]; - }); + // Build table data + const rows: string[][] = profiles.map((p) => { + const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); + return [p.name, p.settingsPath, status]; + }); - // Print table - console.log( - table(rows, { - head: ['API', 'Config', 'Status'], - colWidths: [15, 20, 10], - }) - ); - console.log(''); + const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10]; + console.log( + table(rows, { + head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], + colWidths, + }) + ); + console.log(''); - // Show CLIProxy variants if any - const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); - if (variants.length > 0) { - console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((name) => { - const variant = unifiedConfig.cliproxy?.variants[name]; - return [name, variant?.provider || 'unknown', variant?.settings || '-']; - }); - - console.log( - table(cliproxyRows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 15, 30], - }) - ); - console.log(''); - } - - console.log(dim(`Total: ${apis.length} API profile(s)`)); - console.log(''); - return; - } - - // Legacy: list from config.json - const config = loadConfig(); - const apis = Object.keys(config.profiles); - - if (apis.length === 0) { - console.log(warn('No API profiles configured')); - console.log(''); - console.log('To create an API profile:'); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - return; - } - - // Build table data with status indicators - const rows: string[][] = apis.map((name) => { - const settingsPath = config.profiles[name]; - const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning'); - - return [name, settingsPath, status]; - }); - - // Print table + // Show CLIProxy variants if any + if (variants.length > 0) { + console.log(subheader('CLIProxy Variants')); + const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]); console.log( - table(rows, { - head: ['API', 'Settings File', 'Status'], - colWidths: [15, 35, 10], + table(cliproxyRows, { + head: ['Variant', 'Provider', 'Settings'], + colWidths: [15, 15, 30], }) ); console.log(''); - - // Show CLIProxy variants if any - if (config.cliproxy && Object.keys(config.cliproxy).length > 0) { - console.log(subheader('CLIProxy Variants')); - const cliproxyRows = Object.entries(config.cliproxy).map(([name, v]) => { - const variant = v as { provider: string; settings: string }; - return [name, variant.provider, variant.settings]; - }); - - console.log( - table(cliproxyRows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 15, 30], - }) - ); - console.log(''); - } - - console.log(dim(`Total: ${apis.length} API profile(s)`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to list API profiles: ${(error as Error).message}`)); - process.exit(1); } + + console.log(dim(`Total: ${profiles.length} API profile(s)`)); + console.log(''); } -/** - * Handle 'ccs api remove' command - */ +/** Handle 'ccs api remove' command */ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseArgs(args); - // Get available APIs based on config mode - let apis: string[]; - if (isUnifiedMode()) { - const unifiedConfig = loadOrCreateUnifiedConfig(); - apis = Object.keys(unifiedConfig.profiles); - } else { - const config = loadConfig(); - apis = Object.keys(config.profiles); - } + const apis = getApiProfileNames(); if (apis.length === 0) { console.log(warn('No API profiles to remove')); @@ -691,7 +330,7 @@ async function handleRemove(args: string[]): Promise { // Confirm deletion console.log(''); console.log(`API '${color(name, 'command')}' will be removed.`); - if (isUnifiedMode()) { + if (isUsingUnifiedConfig()) { console.log(' Config: ~/.ccs/config.yaml'); console.log(' Secrets: ~/.ccs/secrets.yaml'); } else { @@ -708,37 +347,18 @@ async function handleRemove(args: string[]): Promise { process.exit(0); } - try { - if (isUnifiedMode()) { - // Remove from unified config - removeApiProfileUnified(name); - } else { - // Remove from legacy config.json - const config = loadConfig(); - delete config.profiles[name]; - const configPath = getConfigPath(); - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); + const result = removeApiProfile(name); - // Remove settings file if it exists - const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(expandedPath)) { - fs.unlinkSync(expandedPath); - } - } - - console.log(ok(`API profile removed: ${name}`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to remove API profile: ${(error as Error).message}`)); + if (!result.success) { + console.log(fail(`Failed to remove API profile: ${result.error}`)); process.exit(1); } + + console.log(ok(`API profile removed: ${name}`)); + console.log(''); } -/** - * Show help for api commands - */ +/** Show help for api commands */ async function showHelp(): Promise { await initUI(); @@ -774,9 +394,7 @@ async function showHelp(): Promise { console.log(''); } -/** - * Main api command router - */ +/** Main api command router */ export async function handleApiCommand(args: string[]): Promise { const command = args[0]; diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 5c7cf58e..38090a9b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -18,27 +18,14 @@ * - Legacy JSON format (config.json) as fallback */ -import * as fs from 'fs'; import * as path from 'path'; -import { - getInstalledCliproxyVersion, - installCliproxyVersion, - fetchLatestCliproxyVersion, - isCLIProxyInstalled, - getCLIProxyPath, - getPinnedVersion, - savePinnedVersion, - clearPinnedVersion, - isVersionPinned, -} from '../cliproxy'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; -import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; -import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; -import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../cliproxy/model-catalog'; +import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; import { CLIProxyProvider } from '../cliproxy/types'; +import { isUnifiedMode } from '../config/unified-config-loader'; import { initUI, header, @@ -53,17 +40,24 @@ import { infoBox, } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; -import { isReservedName } from '../config/reserved-names'; + +// Import services import { - hasUnifiedConfig, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; -import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker'; + validateProfileName, + variantExists, + listVariants, + createVariant, + removeVariant, + getProxyStatus, + stopProxy, + getBinaryStatus, + checkLatestVersion, + installVersion, + installLatest, +} from '../cliproxy/services'; // ============================================================================ -// PROFILE MANAGEMENT +// ARGUMENT PARSING // ============================================================================ interface CliproxyProfileArgs { @@ -75,15 +69,10 @@ interface CliproxyProfileArgs { yes?: boolean; } -/** - * Parse command line arguments for profile commands - */ function parseProfileArgs(args: string[]): CliproxyProfileArgs { const result: CliproxyProfileArgs = {}; - for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { result.provider = args[++i] as CLIProxyProfileName; } else if (arg === '--model' && args[i + 1]) { @@ -98,270 +87,21 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.name = arg; } } - return result; } -/** - * Validate CLIProxy profile name - */ -function validateProfileName(name: string): string | null { - if (!name) { - return 'Profile name is required'; - } - if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { - return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; - } - if (name.length > 32) { - return 'Name must be 32 characters or less'; - } - // Use centralized reserved names list (includes built-in cliproxy profiles) - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } - return null; -} - -/** - * Check if unified config mode is active - */ -function isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); -} - -/** - * Check if CLIProxy variant profile exists - */ -function cliproxyVariantExists(name: string): boolean { - try { - if (isUnifiedMode()) { - const config = loadOrCreateUnifiedConfig(); - return !!(config.cliproxy?.variants && name in config.cliproxy.variants); - } - const config = loadConfig(); - return !!(config.cliproxy && name in config.cliproxy); - } catch { - return false; - } -} - -/** - * Create settings.json file for CLIProxy variant - * Includes all 6 fields for proper Claude CLI integration - */ -function createCliproxySettingsFile( - name: string, - provider: CLIProxyProfileName, - model: string, - _account?: string -): string { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`); - - // Get base env vars from provider config - const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); - - const settings = { - env: { - ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', - ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, - }, - }; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - return settingsPath; -} - -/** - * Update config.json with new CLIProxy variant - */ -function addCliproxyVariant( - name: string, - provider: CLIProxyProfileName, - settingsPath: string, - account?: string -): void { - const configPath = getConfigPath(); - - // Read existing config or create new - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - config = { profiles: {} }; - } - - // Ensure cliproxy section exists - if (!config.cliproxy) { - config.cliproxy = {}; - } - - // Use relative path with ~ for portability - const relativePath = `~/.ccs/${path.basename(settingsPath)}`; - - // Build variant config with optional account - const variantConfig: { provider: string; settings: string; account?: string } = { - provider, - settings: relativePath, - }; - if (account) { - variantConfig.account = account; - } - config.cliproxy[name] = variantConfig; - - // Write config atomically - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); -} - -/** - * Remove CLIProxy variant from config.json - */ -function removeCliproxyVariant(name: string): { provider: string; settings: string } | null { - const configPath = getConfigPath(); - - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - return null; - } - - if (!config.cliproxy || !(name in config.cliproxy)) { - return null; - } - - const variant = config.cliproxy[name] as { provider: string; settings: string }; - delete config.cliproxy[name]; - - // Clean up empty cliproxy section - if (Object.keys(config.cliproxy).length === 0) { - delete config.cliproxy; - } - - // Write config atomically - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); - - return variant; -} - -/** - * Add CLIProxy variant to unified config (config.yaml) - * Creates *.settings.json file and stores reference in config.yaml - */ -function addCliproxyVariantUnified( - name: string, - provider: CLIProxyProfileName, - model: string, - account?: string -): void { - const ccsDir = path.join(require('os').homedir(), '.ccs'); - const settingsFile = `${provider}-${name}.settings.json`; - const settingsPath = path.join(ccsDir, settingsFile); - - // Get base env vars from provider config - const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); - - // Create settings file with model override - const settings = { - env: { - ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', - ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, - }, - }; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - // Write settings file - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - - // Update config.yaml with reference - const config = loadOrCreateUnifiedConfig(); - - // Ensure cliproxy.variants section exists - if (!config.cliproxy) { - config.cliproxy = { - oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], - variants: {}, - }; - } - if (!config.cliproxy.variants) { - config.cliproxy.variants = {}; - } - - // Add variant with settings file reference - config.cliproxy.variants[name] = { - provider: provider as CLIProxyProvider, - account, - settings: `~/.ccs/${settingsFile}`, - }; - - saveUnifiedConfig(config); -} - -/** - * Remove CLIProxy variant from unified config - */ -function removeCliproxyVariantUnified( - name: string -): { provider: string; settings?: string } | null { - const config = loadOrCreateUnifiedConfig(); - - if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { - return null; - } - - const variant = config.cliproxy.variants[name]; - - // Delete the settings file if it exists - if (variant.settings) { - const settingsPath = variant.settings.replace(/^~/, require('os').homedir()); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); - } - } - - delete config.cliproxy.variants[name]; - saveUnifiedConfig(config); - - return { provider: variant.provider, settings: variant.settings }; -} - -/** - * Format model entry for display - */ function formatModelOption(model: ModelEntry): string { const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; return `${model.name}${tierBadge}`; } -/** - * Handle 'ccs cliproxy create' command - */ +// ============================================================================ +// COMMAND HANDLERS +// ============================================================================ + async function handleCreate(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - console.log(header('Create CLIProxy Variant')); console.log(''); @@ -379,8 +119,7 @@ async function handleCreate(args: string[]): Promise { } } - // Check if exists - if (cliproxyVariantExists(name) && !parsedArgs.force) { + if (variantExists(name) && !parsedArgs.force) { console.log(fail(`Variant '${name}' already exists`)); console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); @@ -393,7 +132,6 @@ async function handleCreate(args: string[]): Promise { id: p, label: p.charAt(0).toUpperCase() + p.slice(1), })); - provider = (await InteractivePrompt.selectFromList( 'Select provider:', providerOptions @@ -410,76 +148,57 @@ async function handleCreate(args: string[]): Promise { if (!account) { if (providerAccounts.length === 0) { - // No accounts - prompt to authenticate first console.log(''); console.log(warn(`No accounts authenticated for ${provider}`)); console.log(''); - const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { default: true, }); - if (!shouldAuth) { console.log(''); console.log(info('Run authentication first:')); console.log(` ${color(`ccs ${provider} --auth`, 'command')}`); process.exit(0); } - - // Trigger OAuth inline console.log(''); const newAccount = await triggerOAuth(provider as CLIProxyProvider, { add: true, verbose: args.includes('--verbose'), }); - if (!newAccount) { console.log(fail('Authentication failed')); process.exit(1); } - account = newAccount.id; console.log(''); console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); } else if (providerAccounts.length === 1) { - // Single account - auto-select account = providerAccounts[0].id; } else { - // Multiple accounts - show selector with "Add new" option const ADD_NEW_ID = '__add_new__'; - const accountOptions = [ ...providerAccounts.map((acc) => ({ id: acc.id, label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`, })), - { - id: ADD_NEW_ID, - label: color('[+ Add new account...]', 'info'), - }, + { id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') }, ]; - const defaultIdx = providerAccounts.findIndex((a) => a.isDefault); - const selectedAccount = await InteractivePrompt.selectFromList( 'Select account:', accountOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0 } ); - if (selectedAccount === ADD_NEW_ID) { - // Add new account inline console.log(''); const newAccount = await triggerOAuth(provider as CLIProxyProvider, { add: true, verbose: args.includes('--verbose'), }); - if (!newAccount) { console.log(fail('Authentication failed')); process.exit(1); } - account = newAccount.id; console.log(''); console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); @@ -488,15 +207,14 @@ async function handleCreate(args: string[]): Promise { } } } else { - // Validate provided account exists const exists = providerAccounts.find((a) => a.id === account); if (!exists) { console.log(fail(`Account '${account}' not found for ${provider}`)); console.log(''); console.log('Available accounts:'); - providerAccounts.forEach((a) => { - console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`); - }); + providerAccounts.forEach((a) => + console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`) + ); process.exit(1); } } @@ -504,24 +222,16 @@ async function handleCreate(args: string[]): Promise { // Step 3: Model selection let model = parsedArgs.model; if (!model) { - // Check if provider has model catalog for interactive selection if (supportsModelConfig(provider as CLIProxyProvider)) { const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ - id: m.id, - label: formatModelOption(m), - })); - + const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); - model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, }); } } - - // Fallback to manual input if no catalog if (!model) { model = await InteractivePrompt.input('Model name', { validate: (val) => (val ? null : 'Model is required'), @@ -529,189 +239,103 @@ async function handleCreate(args: string[]): Promise { } } - // Create files + // Create variant console.log(''); console.log(info('Creating CLIProxy variant...')); + const result = createVariant(name, provider, model, account); - try { - if (isUnifiedMode()) { - // Use unified config format (no settings file needed - env vars derived at runtime) - addCliproxyVariantUnified(name, provider, model, account); - - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Config: ~/.ccs/config.yaml`, - 'CLIProxy Variant Created (Unified Config)' - ) - ); - } else { - // Legacy: create settings.json file - const settingsPath = createCliproxySettingsFile(name, provider, model, account); - addCliproxyVariant(name, provider, settingsPath, account); - - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Settings: ~/.ccs/${path.basename(settingsPath)}`, - 'CLIProxy Variant Created' - ) - ); - } - console.log(''); - console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); - console.log(''); - console.log(dim('To change model later:')); - console.log(` ${color(`ccs ${name} --config`, 'command')}`); - console.log(''); - } catch (error) { - console.log(fail(`Failed to create variant: ${(error as Error).message}`)); + if (!result.success) { + console.log(fail(`Failed to create variant: ${result.error}`)); process.exit(1); } + + console.log(''); + const configType = isUnifiedMode() + ? 'CLIProxy Variant Created (Unified Config)' + : 'CLIProxy Variant Created'; + const settingsDisplay = isUnifiedMode() + ? '~/.ccs/config.yaml' + : `~/.ccs/${path.basename(result.settingsPath || '')}`; + console.log( + infoBox( + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + configType + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + console.log(dim('To change model later:')); + console.log(` ${color(`ccs ${name} --config`, 'command')}`); + console.log(''); } -/** - * Handle 'ccs cliproxy list' command - */ async function handleList(): Promise { await initUI(); - console.log(header('CLIProxy Profiles')); console.log(''); - try { - // Show auth status for built-in profiles - console.log(subheader('Built-in Profiles')); - const authStatuses = getAllAuthStatus(); - - for (const status of authStatuses) { - const oauthConfig = getOAuthConfig(status.provider); - const icon = status.authenticated ? ok('') : warn(''); - const authLabel = status.authenticated - ? color('authenticated', 'success') - : dim('not authenticated'); - const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : ''; - - console.log( - ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}` - ); - } - console.log(''); - console.log(dim(' To authenticate: ccs --auth')); - console.log(dim(' To logout: ccs --logout')); - console.log(''); - - // Show custom variants if any (from unified or legacy config) - let variantNames: string[]; - let variantData: Record; - - if (isUnifiedMode()) { - const unifiedConfig = loadOrCreateUnifiedConfig(); - const variants = unifiedConfig.cliproxy?.variants || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name]; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } else { - const config = loadConfig(); - const variants = config.cliproxy || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name] as { provider: string; settings: string }; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } - - if (variantNames.length > 0) { - console.log(subheader('Custom Variants')); - - // Build table data - const rows: string[][] = variantNames.map((name) => { - const variant = variantData[name]; - return [name, variant.provider, variant.settings || '-']; - }); - - // Print table - console.log( - table(rows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 12, 35], - }) - ); - console.log(''); - console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); - console.log(''); - } - - console.log(dim('To create a custom variant:')); - console.log(` ${color('ccs cliproxy create', 'command')}`); - console.log(''); - } catch (error) { - console.log(fail(`Failed to list profiles: ${(error as Error).message}`)); - process.exit(1); + // Built-in profiles + console.log(subheader('Built-in Profiles')); + const authStatuses = getAllAuthStatus(); + for (const status of authStatuses) { + const oauthConfig = getOAuthConfig(status.provider); + const icon = status.authenticated ? ok('') : warn(''); + const authLabel = status.authenticated + ? color('authenticated', 'success') + : dim('not authenticated'); + const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : ''; + console.log( + ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}` + ); } + console.log(''); + console.log(dim(' To authenticate: ccs --auth')); + console.log(dim(' To logout: ccs --logout')); + console.log(''); + + // Custom variants + const variants = listVariants(); + const variantNames = Object.keys(variants); + + if (variantNames.length > 0) { + console.log(subheader('Custom Variants')); + const rows = variantNames.map((name) => { + const variant = variants[name]; + return [name, variant.provider, variant.settings || '-']; + }); + console.log( + table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] }) + ); + console.log(''); + console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); + console.log(''); + } + + console.log(dim('To create a custom variant:')); + console.log(` ${color('ccs cliproxy create', 'command')}`); + console.log(''); } -/** - * Handle 'ccs cliproxy remove' command - */ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - - // Get available variants based on config mode - let variantNames: string[]; - let variantData: Record; - - if (isUnifiedMode()) { - const unifiedConfig = loadOrCreateUnifiedConfig(); - const variants = unifiedConfig.cliproxy?.variants || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name]; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } else { - const config = loadConfig(); - const variants = config.cliproxy || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name] as { provider: string; settings: string }; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } + const variants = listVariants(); + const variantNames = Object.keys(variants); if (variantNames.length === 0) { console.log(warn('No CLIProxy variants to remove')); process.exit(0); } - // Interactive selection if not provided let name = parsedArgs.name; if (!name) { console.log(header('Remove CLIProxy Variant')); console.log(''); console.log('Available variants:'); - variantNames.forEach((n, i) => { - const v = variantData[n]; - console.log(` ${i + 1}. ${n} (${v.provider})`); - }); + variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`)); console.log(''); - name = await InteractivePrompt.input('Variant name to remove', { validate: (val) => { if (!val) return 'Variant name is required'; @@ -729,9 +353,7 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const variant = variantData[name]; - - // Confirm deletion + const variant = variants[name]; console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); console.log(` Provider: ${variant.provider}`); @@ -740,337 +362,27 @@ async function handleRemove(args: string[]): Promise { const confirmed = parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false })); - if (!confirmed) { console.log(info('Cancelled')); process.exit(0); } - try { - if (isUnifiedMode()) { - // Remove from unified config - removeCliproxyVariantUnified(name); - } else { - // Remove from legacy config - const removed = removeCliproxyVariant(name); - if (!removed) { - console.log(fail('Failed to remove variant from config')); - process.exit(1); - } - - // Remove settings file if it exists - const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); - if (fs.existsSync(settingsFile)) { - fs.unlinkSync(settingsFile); - } - } - - console.log(ok(`Variant removed: ${name}`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to remove variant: ${(error as Error).message}`)); - process.exit(1); - } -} - -// ============================================================================ -// BINARY MANAGEMENT -// ============================================================================ - -/** - * Show cliproxy command help with styled UI - */ -async function showHelp(): Promise { - await initUI(); - - console.log(''); - console.log(header('CLIProxy Management')); - console.log(''); - - // Usage - console.log(subheader('Usage:')); - console.log(` ${color('ccs cliproxy', 'command')} [options]`); - console.log(''); - - // Profile Commands - console.log(subheader('Profile Commands:')); - const profileCmds: [string, string][] = [ - ['create [name]', 'Create new CLIProxy variant profile'], - ['list', 'List all CLIProxy variant profiles'], - ['remove ', 'Remove a CLIProxy variant profile'], - ]; - const maxProfileLen = Math.max(...profileCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of profileCmds) { - console.log(` ${color(cmd.padEnd(maxProfileLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Proxy Lifecycle Commands - console.log(subheader('Proxy Lifecycle:')); - const lifecycleCmds: [string, string][] = [ - ['status', 'Show running CLIProxy status'], - ['stop', 'Stop running CLIProxy instance'], - ]; - const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of lifecycleCmds) { - console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`); - } - console.log(''); - console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); - console.log(''); - - // Binary Commands - console.log(subheader('Binary Commands:')); - const binaryCmds: [string, string][] = [ - ['--install ', 'Install and pin a specific version'], - ['--latest', 'Install the latest version (no pin)'], - ['--update', 'Unpin and update to latest version'], - ]; - const maxBinaryLen = Math.max(...binaryCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of binaryCmds) { - console.log(` ${color(cmd.padEnd(maxBinaryLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Multi-Account Commands - console.log(subheader('Multi-Account Commands:')); - const multiAcctCmds: [string, string][] = [ - ['--auth', 'Authenticate with a provider (first account)'], - ['--auth --add', 'Add another account to a provider'], - ['--nickname ', 'Set friendly name for account'], - ['--accounts', 'List all accounts for a provider'], - ['--use ', 'Switch to account by nickname/email'], - ]; - const maxMultiLen = Math.max(...multiAcctCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of multiAcctCmds) { - console.log(` ${color(cmd.padEnd(maxMultiLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Create Options - console.log(subheader('Create Options:')); - const createOpts: [string, string][] = [ - ['--provider ', 'Provider (gemini, codex, agy, qwen)'], - ['--model ', 'Model name'], - ['--account ', 'Account ID (email or default)'], - ['--force', 'Overwrite existing variant'], - ['--yes, -y', 'Skip confirmation prompts'], - ]; - const maxOptLen = Math.max(...createOpts.map(([opt]) => opt.length)); - for (const [opt, desc] of createOpts) { - console.log(` ${color(opt.padEnd(maxOptLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Examples - console.log(subheader('Examples:')); - console.log( - ` $ ${color('ccs cliproxy create', 'command')} ${dim('# Interactive wizard')}` - ); - console.log(` $ ${color('ccs cliproxy create g3 --provider gemini', 'command')}`); - console.log( - ` ${color('--model gemini-3-pro-preview', 'command')} ${dim('# Non-interactive')}` - ); - console.log( - ` $ ${color('ccs cliproxy list', 'command')} ${dim('# Show all variants')}` - ); - console.log( - ` $ ${color('ccs cliproxy remove g3', 'command')} ${dim('# Remove variant')}` - ); - console.log( - ` $ ${color('ccs cliproxy --latest', 'command')} ${dim('# Update binary')}` - ); - console.log(''); - console.log(subheader('Multi-Account Examples:')); - console.log( - ` $ ${color('ccs gemini --auth', 'command')} ${dim('# First account')}` - ); - console.log( - ` $ ${color('ccs gemini --auth --add', 'command')} ${dim('# Add second account')}` - ); - console.log( - ` $ ${color('ccs gemini --auth --add --nickname work', 'command')} ${dim('# With nickname')}` - ); - console.log( - ` $ ${color('ccs agy --accounts', 'command')} ${dim('# List accounts')}` - ); - console.log( - ` $ ${color('ccs agy --use work', 'command')} ${dim('# Switch account')}` - ); - console.log(''); - - // Notes - console.log(subheader('Notes:')); - console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`); - console.log( - ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}` - ); - console.log(''); -} - -/** - * Show current cliproxy status - */ -async function showStatus(verbose: boolean): Promise { - await initUI(); - - const installed = isCLIProxyInstalled(); - const currentVersion = getInstalledCliproxyVersion(); - const binaryPath = getCLIProxyPath(); - const pinnedVersion = getPinnedVersion(); - - console.log(''); - console.log(color('CLIProxyAPI Status', 'primary')); - console.log(''); - - if (installed) { - console.log(` Installed: ${color('Yes', 'success')}`); - const versionLabel = pinnedVersion - ? `${color(`v${currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` - : color(`v${currentVersion}`, 'info'); - console.log(` Version: ${versionLabel}`); - console.log(` Binary: ${dim(binaryPath)}`); - } else { - console.log(` Installed: ${color('No', 'error')}`); - console.log(` Fallback: ${color(`v${CLIPROXY_FALLBACK_VERSION}`, 'info')}`); - console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`); - } - - // Try to fetch latest version - try { - console.log(''); - console.log(` ${dim('Checking for updates...')}`); - const latestVersion = await fetchLatestCliproxyVersion(); - - if (latestVersion !== currentVersion) { - if (pinnedVersion) { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(pinned to v' + pinnedVersion + ')')}` - ); - console.log(''); - console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); - } else { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` - ); - console.log(''); - console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); - } - } else { - console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`); - } - } catch (error) { - if (verbose) { - const err = error as Error; - console.log(` Latest: ${dim(`Could not fetch (${err.message})`)}`); - } - } - - console.log(''); - console.log(dim(`Run "ccs cliproxy --help" for all available commands`)); - console.log(''); -} - -/** - * Install a specific version (pins the version to prevent auto-update) - */ -async function installVersion(version: string, verbose: boolean): Promise { - // Validate version format (basic semver check) - if (!/^\d+\.\d+\.\d+$/.test(version)) { - console.error(fail('Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)')); + const result = removeVariant(name); + if (!result.success) { + console.log(fail(`Failed to remove variant: ${result.error}`)); process.exit(1); } - console.log(info(`Installing CLIProxyAPI v${version}...`)); + console.log(ok(`Variant removed: ${name}`)); console.log(''); - - try { - await installCliproxyVersion(version, verbose); - - // Pin the version to prevent auto-update - savePinnedVersion(version); - - console.log(''); - console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); - console.log(''); - console.log(dim('This version will be used until you run:')); - console.log( - ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` - ); - console.log(''); - } catch (error) { - const err = error as Error; - console.error(''); - console.error(fail(`Failed to install CLIProxyAPI v${version}`)); - console.error(` ${err.message}`); - console.error(''); - console.error('Possible causes:'); - console.error(' 1. Version does not exist on GitHub'); - console.error(' 2. Network connectivity issues'); - console.error(' 3. GitHub API rate limiting'); - console.error(''); - console.error('Check available versions at:'); - console.error(' https://github.com/router-for-me/CLIProxyAPI/releases'); - process.exit(1); - } } -/** - * Install latest version (clears any version pin) - */ -async function installLatest(verbose: boolean): Promise { - console.log(info('Fetching latest CLIProxyAPI version...')); - - try { - const latestVersion = await fetchLatestCliproxyVersion(); - const currentVersion = getInstalledCliproxyVersion(); - const wasPinned = isVersionPinned(); - - if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { - console.log(ok(`Already running latest version: v${latestVersion}`)); - return; - } - - console.log(info(`Latest version: v${latestVersion}`)); - if (isCLIProxyInstalled()) { - console.log(info(`Current version: v${currentVersion}`)); - } - if (wasPinned) { - console.log(info(`Removing version pin (was v${getPinnedVersion()})`)); - } - console.log(''); - - await installCliproxyVersion(latestVersion, verbose); - - // Clear any version pin so auto-update works again - clearPinnedVersion(); - - console.log(''); - console.log(ok(`CLIProxyAPI updated to v${latestVersion}`)); - console.log(dim('Auto-update is now enabled.')); - console.log(''); - } catch (error) { - const err = error as Error; - console.error(fail(`Failed to install latest version: ${err.message}`)); - process.exit(1); - } -} - -// ============================================================================ -// PROXY LIFECYCLE COMMANDS -// ============================================================================ - -/** - * Handle 'ccs cliproxy stop' - Stop running CLIProxy instance - */ async function handleStop(): Promise { await initUI(); - console.log(header('Stop CLIProxy')); console.log(''); const result = await stopProxy(); - if (result.stopped) { console.log(ok(`CLIProxy stopped (PID ${result.pid})`)); if (result.sessionCount && result.sessionCount > 0) { @@ -1082,25 +394,19 @@ async function handleStop(): Promise { console.log(''); } -/** - * Handle 'ccs cliproxy status' - Show running proxy status - */ async function handleProxyStatus(): Promise { await initUI(); - console.log(header('CLIProxy Status')); console.log(''); const status = getProxyStatus(); - if (status.running) { console.log(` Status: ${color('Running', 'success')}`); console.log(` PID: ${status.pid}`); console.log(` Port: ${status.port}`); console.log(` Sessions: ${status.sessionCount || 0} active`); if (status.startedAt) { - const started = new Date(status.startedAt); - console.log(` Started: ${started.toLocaleString()}`); + console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); } console.log(''); console.log(dim('To stop: ccs cliproxy stop')); @@ -1112,24 +418,175 @@ async function handleProxyStatus(): Promise { console.log(''); } +async function showStatus(verbose: boolean): Promise { + await initUI(); + const status = getBinaryStatus(); + + console.log(''); + console.log(color('CLIProxyAPI Status', 'primary')); + console.log(''); + + if (status.installed) { + console.log(` Installed: ${color('Yes', 'success')}`); + const versionLabel = status.pinnedVersion + ? `${color(`v${status.currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` + : color(`v${status.currentVersion}`, 'info'); + console.log(` Version: ${versionLabel}`); + console.log(` Binary: ${dim(status.binaryPath)}`); + } else { + console.log(` Installed: ${color('No', 'error')}`); + console.log(` Fallback: ${color(`v${status.fallbackVersion}`, 'info')}`); + console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`); + } + + const latestCheck = await checkLatestVersion(); + if (latestCheck.success && latestCheck.latestVersion) { + console.log(''); + if (latestCheck.updateAvailable) { + if (status.pinnedVersion) { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(pinned to v' + status.pinnedVersion + ')')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); + } else { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(update available)')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); + } + } else { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(up to date)')}` + ); + } + } else if (verbose && latestCheck.error) { + console.log(` Latest: ${dim(`Could not fetch (${latestCheck.error})`)}`); + } + + console.log(''); + console.log(dim('Run "ccs cliproxy --help" for all available commands')); + console.log(''); +} + +async function handleInstallVersion(version: string, verbose: boolean): Promise { + console.log(info(`Installing CLIProxyAPI v${version}...`)); + console.log(''); + + const result = await installVersion(version, verbose); + if (!result.success) { + console.error(''); + console.error(fail(`Failed to install CLIProxyAPI v${version}`)); + console.error(` ${result.error}`); + console.error(''); + console.error('Possible causes:'); + console.error(' 1. Version does not exist on GitHub'); + console.error(' 2. Network connectivity issues'); + console.error(' 3. GitHub API rate limiting'); + console.error(''); + console.error('Check available versions at:'); + console.error(' https://github.com/router-for-me/CLIProxyAPI/releases'); + process.exit(1); + } + + console.log(''); + console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); + console.log(''); + console.log(dim('This version will be used until you run:')); + console.log( + ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` + ); + console.log(''); +} + +async function handleInstallLatest(verbose: boolean): Promise { + console.log(info('Fetching latest CLIProxyAPI version...')); + + const result = await installLatest(verbose); + if (!result.success) { + console.error(fail(`Failed to install latest version: ${result.error}`)); + process.exit(1); + } + + if (result.error?.startsWith('Already')) { + console.log(ok(result.error)); + return; + } + + console.log(''); + console.log(ok(`CLIProxyAPI updated to v${result.version}`)); + console.log(dim('Auto-update is now enabled.')); + console.log(''); +} + +async function showHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Management')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs cliproxy', 'command')} [options]`); + console.log(''); + + const sections: [string, [string, string][]][] = [ + [ + 'Profile Commands:', + [ + ['create [name]', 'Create new CLIProxy variant profile'], + ['list', 'List all CLIProxy variant profiles'], + ['remove ', 'Remove a CLIProxy variant profile'], + ], + ], + [ + 'Proxy Lifecycle:', + [ + ['status', 'Show running CLIProxy status'], + ['stop', 'Stop running CLIProxy instance'], + ], + ], + [ + 'Binary Commands:', + [ + ['--install ', 'Install and pin a specific version'], + ['--latest', 'Install the latest version (no pin)'], + ['--update', 'Unpin and update to latest version'], + ], + ], + ]; + + for (const [title, cmds] of sections) { + console.log(subheader(title)); + const maxLen = Math.max(...cmds.map(([cmd]) => cmd.length)); + for (const [cmd, desc] of cmds) { + console.log(` ${color(cmd.padEnd(maxLen + 2), 'command')} ${desc}`); + } + console.log(''); + } + + console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); + console.log(''); + console.log(subheader('Notes:')); + console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`); + console.log( + ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}` + ); + console.log(''); +} + // ============================================================================ // MAIN ROUTER // ============================================================================ -/** - * Main cliproxy command handler - */ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = args.includes('--verbose') || args.includes('-v'); const command = args[0]; - // Handle --help if (args.includes('--help') || args.includes('-h')) { await showHelp(); return; } - // Handle profile commands if (command === 'create') { await handleCreate(args.slice(1)); return; @@ -1145,7 +602,6 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Handle proxy lifecycle commands if (command === 'stop') { await handleStop(); return; @@ -1156,7 +612,6 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Handle --install const installIdx = args.indexOf('--install'); if (installIdx !== -1) { const version = args[installIdx + 1]; @@ -1166,22 +621,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { console.error(' Example: ccs cliproxy --install 6.5.53'); process.exit(1); } - await installVersion(version, verbose); + await handleInstallVersion(version, verbose); return; } - // Handle --latest - if (args.includes('--latest')) { - await installLatest(verbose); + if (args.includes('--latest') || args.includes('--update')) { + await handleInstallLatest(verbose); return; } - // Handle --update (unpin and update to latest) - if (args.includes('--update')) { - await installLatest(verbose); - return; - } - - // Default: show status await showStatus(verbose); } diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 00000000..007664e5 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,17 @@ +/** + * Commands module barrel export + */ + +export { handleApiCommand } from './api-command'; +export { handleCleanupCommand } from './cleanup-command'; +export { handleCliproxyCommand } from './cliproxy-command'; +export { handleConfigCommand } from './config-command'; +export { handleCopilotCommand } from './copilot-command'; +export { handleDoctorCommand } from './doctor-command'; +export { handleHelpCommand } from './help-command'; +export { handleInstallCommand } from './install-command'; +export { handleMigrateCommand } from './migrate-command'; +export { handleShellCompletionCommand } from './shell-completion-command'; +export { handleSyncCommand } from './sync-command'; +export { handleUpdateCommand } from './update-command'; +export { handleVersionCommand } from './version-command'; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 24a36b0b..4189a994 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -449,6 +449,16 @@ export function updateUnifiedConfig(updates: Partial): UnifiedCon return updated; } +/** + * Check if unified config mode is active. + * Returns true if config.yaml exists OR CCS_UNIFIED_CONFIG=1. + * + * Use this centralized function instead of duplicating the logic. + */ +export function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + /** * Get or set default profile name. */ diff --git a/src/delegation/executor/index.ts b/src/delegation/executor/index.ts new file mode 100644 index 00000000..5919df20 --- /dev/null +++ b/src/delegation/executor/index.ts @@ -0,0 +1,7 @@ +/** + * Barrel export for executor module + */ + +export * from './types'; +export { StreamBuffer, formatToolVerbose } from './stream-parser'; +export { buildExecutionResult, extractSessionInfo } from './result-aggregator'; diff --git a/src/delegation/executor/result-aggregator.ts b/src/delegation/executor/result-aggregator.ts new file mode 100644 index 00000000..c7415ba6 --- /dev/null +++ b/src/delegation/executor/result-aggregator.ts @@ -0,0 +1,80 @@ +/** + * Result aggregation utilities for headless executor + */ + +import type { ExecutionResult, StreamMessage } from './types'; +import { warn } from '../../utils/ui'; + +/** + * Build execution result from stream messages + * @param params - Parameters for building result + * @returns ExecutionResult with all fields populated + */ +export function buildExecutionResult(params: { + exitCode: number; + stdout: string; + stderr: string; + cwd: string; + profile: string; + duration: number; + timedOut: boolean; + messages: StreamMessage[]; +}): ExecutionResult { + const { exitCode, stdout, stderr, cwd, profile, duration, timedOut, messages } = params; + + const result: ExecutionResult = { + exitCode, + stdout, + stderr, + cwd, + profile, + duration, + timedOut, + success: exitCode === 0 && !timedOut, + messages, + }; + + // Extract metadata from final 'result' message in stream-json + const resultMessage = messages.find((m) => m.type === 'result'); + if (resultMessage) { + result.sessionId = resultMessage.session_id || undefined; + result.totalCost = resultMessage.total_cost_usd || 0; + result.numTurns = resultMessage.num_turns || 0; + result.isError = resultMessage.is_error || false; + result.type = resultMessage.type || null; + result.subtype = resultMessage.subtype || undefined; + result.durationApi = resultMessage.duration_api_ms || 0; + result.permissionDenials = resultMessage.permission_denials || []; + result.errors = resultMessage.errors || []; + result.content = resultMessage.result || ''; + } else { + // Fallback: no result message found (shouldn't happen) + result.content = stdout; + if (process.env.CCS_DEBUG) { + console.error(warn('No result message found in stream-json output')); + } + } + + return result; +} + +/** + * Extract session info from result for session management + * @param result - Execution result + * @returns Session info or null + */ +export function extractSessionInfo(result: ExecutionResult): { + sessionId: string; + totalCost?: number; + cwd: string; +} | null { + if (!result.sessionId) { + return null; + } + + return { + sessionId: result.sessionId, + totalCost: result.totalCost, + cwd: result.cwd, + }; +} diff --git a/src/delegation/executor/stream-parser.ts b/src/delegation/executor/stream-parser.ts new file mode 100644 index 00000000..a78ce8c3 --- /dev/null +++ b/src/delegation/executor/stream-parser.ts @@ -0,0 +1,151 @@ +/** + * Stream parsing utilities for Claude CLI stream-json output + */ + +import type { StreamMessage, ToolInput } from './types'; +import { warn } from '../../utils/ui'; + +/** + * Buffer for incomplete JSON lines during streaming + */ +export class StreamBuffer { + private partialLine = ''; + + /** + * Process incoming data chunk and extract complete JSON messages + * @param dataStr - Raw data string from stream + * @returns Array of parsed StreamMessage objects + */ + parseChunk(dataStr: string): StreamMessage[] { + const messages: StreamMessage[] = []; + const chunk = this.partialLine + dataStr; + const lines = chunk.split('\n'); + this.partialLine = lines.pop() || ''; // Save incomplete line for next chunk + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const msg: StreamMessage = JSON.parse(line); + messages.push(msg); + } catch (parseError) { + // Skip malformed JSON lines (shouldn't happen with stream-json) + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to parse stream-json line: ${(parseError as Error).message}`)); + } + } + } + + return messages; + } + + /** + * Reset buffer state + */ + reset(): void { + this.partialLine = ''; + } +} + +/** + * Format tool use message for verbose logging + * @param toolName - Name of the tool + * @param toolInput - Tool input parameters + * @returns Formatted verbose message + */ +export function formatToolVerbose(toolName: string, toolInput: ToolInput): string { + let verboseMsg = `[Tool] ${toolName}`; + + switch (toolName) { + case 'Bash': + if (toolInput.command) { + const command = toolInput.command as string; + const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command; + verboseMsg += `: ${cmd}`; + } + break; + + case 'Edit': + case 'Write': + case 'Read': + if (toolInput.file_path) { + verboseMsg += `: ${toolInput.file_path}`; + } + break; + + case 'NotebookEdit': + case 'NotebookRead': + if (toolInput.notebook_path) { + verboseMsg += `: ${toolInput.notebook_path}`; + } + break; + + case 'Grep': + if (toolInput.pattern) { + verboseMsg += `: searching for "${toolInput.pattern}"`; + if (toolInput.path) { + verboseMsg += ` in ${toolInput.path}`; + } + } + break; + + case 'Glob': + if (toolInput.pattern) { + verboseMsg += `: ${toolInput.pattern}`; + } + break; + + case 'SlashCommand': + if (toolInput.command) { + verboseMsg += `: ${toolInput.command}`; + } + break; + + case 'Task': + if (toolInput.description) { + verboseMsg += `: ${toolInput.description}`; + } else if (toolInput.prompt) { + const promptText = toolInput.prompt as string; + const prompt = promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText; + verboseMsg += `: ${prompt}`; + } + break; + + case 'TodoWrite': + if (toolInput.todos && Array.isArray(toolInput.todos)) { + const inProgressTask = toolInput.todos.find( + (t: { status: string; activeForm?: string }) => t.status === 'in_progress' + ); + if (inProgressTask && inProgressTask.activeForm) { + verboseMsg += `: ${inProgressTask.activeForm}`; + } else { + verboseMsg += `: ${toolInput.todos.length} task(s)`; + } + } + break; + + case 'WebFetch': + if (toolInput.url) { + verboseMsg += `: ${toolInput.url}`; + } + break; + + case 'WebSearch': + if (toolInput.query) { + verboseMsg += `: "${toolInput.query}"`; + } + break; + + default: + // For unknown tools, show first meaningful parameter + if (Object.keys(toolInput).length > 0) { + const firstKey = Object.keys(toolInput)[0]; + const firstValue = toolInput[firstKey]; + if (typeof firstValue === 'string' && firstValue.length < 60) { + verboseMsg += `: ${firstValue}`; + } + } + } + + return verboseMsg; +} diff --git a/src/delegation/executor/types.ts b/src/delegation/executor/types.ts new file mode 100644 index 00000000..4efd61c6 --- /dev/null +++ b/src/delegation/executor/types.ts @@ -0,0 +1,124 @@ +/** + * Type definitions for headless executor + */ + +/** + * Claude message from stream-json output + */ +export interface ClaudeMessage { + type: string; + content?: string; + thinking?: string; + tool_use?: { + id: string; + name: string; + input: Record; + }; + tool_result?: { + tool_use_id: string; + content: string; + }; +} + +/** + * Permission denial information + */ +export interface PermissionDenial { + tool_name?: string; + reason?: string; + tool_input?: { + command?: string; + description?: string; + [key: string]: unknown; + }; +} + +/** + * Execution error information + */ +export interface ExecutionError { + message?: string; + error?: string; + type?: string; + tool_name?: string; + [key: string]: unknown; +} + +/** + * Options for headless execution + */ +export interface ExecutionOptions { + cwd?: string; + timeout?: number; + outputFormat?: string; + permissionMode?: string; + resumeSession?: boolean; + sessionId?: string; + maxRetries?: number; + extraArgs?: string[]; // Passthrough args for Claude CLI +} + +/** + * Result of headless execution + */ +export interface ExecutionResult { + exitCode: number; + stdout: string; + stderr: string; + cwd: string; + profile: string; + duration: number; + timedOut: boolean; + success: boolean; + messages: StreamMessage[]; + sessionId?: string; + totalCost?: number; + numTurns?: number; + isError?: boolean; + type?: string | null; + subtype?: string; + durationApi?: number; + permissionDenials?: PermissionDenial[]; + errors?: ExecutionError[]; + content?: string; +} + +/** + * Stream message from Claude CLI stream-json output + */ +export interface StreamMessage { + type: string; + message?: { + content?: Array<{ + type: string; + name?: string; + input?: Record; + }>; + }; + session_id?: string; + total_cost_usd?: number; + num_turns?: number; + is_error?: boolean; + result?: string; + duration_api_ms?: number; + permission_denials?: PermissionDenial[]; + errors?: ExecutionError[]; + subtype?: string; +} + +/** + * Tool input types for verbose logging + */ +export interface ToolInput { + command?: string; + file_path?: string; + notebook_path?: string; + pattern?: string; + path?: string; + description?: string; + prompt?: string; + todos?: Array<{ status: string; activeForm?: string }>; + url?: string; + query?: string; + [key: string]: unknown; +} diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index c0217593..815c73a2 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -1,5 +1,10 @@ #!/usr/bin/env node +/** + * Headless executor for Claude CLI delegation + * Spawns claude with -p flag for single-turn execution + */ + import { spawn } from 'child_process'; import * as path from 'path'; import * as os from 'os'; @@ -7,92 +12,15 @@ import * as fs from 'fs'; import { SessionManager } from './session-manager'; import { SettingsParser } from './settings-parser'; import { ui, warn, info } from '../utils/ui'; +import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types'; +import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; +import { buildExecutionResult } from './executor/result-aggregator'; -// Type definitions for delegation responses -interface ClaudeMessage { - type: string; - content?: string; - thinking?: string; - tool_use?: { - id: string; - name: string; - input: Record; - }; - tool_result?: { - tool_use_id: string; - content: string; - }; -} - -interface PermissionDenial { - tool_name: string; - reason: string; -} - -interface ExecutionError { - message?: string; - error?: string; - type?: string; - tool_name?: string; - [key: string]: unknown; -} - -interface ExecutionOptions { - cwd?: string; - timeout?: number; - outputFormat?: string; - permissionMode?: string; - resumeSession?: boolean; - sessionId?: string; - maxRetries?: number; - extraArgs?: string[]; // Passthrough args for Claude CLI -} - -interface ExecutionResult { - exitCode: number; - stdout: string; - stderr: string; - cwd: string; - profile: string; - duration: number; - timedOut: boolean; - success: boolean; - messages: ClaudeMessage[]; - sessionId?: string; - totalCost?: number; - numTurns?: number; - isError?: boolean; - type?: string | null; - subtype?: string; - durationApi?: number; - permissionDenials?: PermissionDenial[]; - errors?: ExecutionError[]; - content?: string; -} - -interface StreamMessage { - type: string; - message?: { - content?: Array<{ - type: string; - name?: string; - input?: Record; - }>; - }; - session_id?: string; - total_cost_usd?: number; - num_turns?: number; - is_error?: boolean; - result?: string; - duration_api_ms?: number; - permission_denials?: PermissionDenial[]; - errors?: ExecutionError[]; - subtype?: string; -} +// Re-export types for consumers +export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; /** * Headless executor for Claude CLI delegation - * Spawns claude with -p flag for single-turn execution */ export class HeadlessExecutor { /** @@ -141,26 +69,20 @@ export class HeadlessExecutor { } // Smart slash command detection and preservation - // Detects if prompt contains slash command and restructures for proper execution const processedPrompt = this._processSlashCommand(enhancedPrompt); // Prepare arguments const args: string[] = ['-p', processedPrompt, '--settings', settingsPath]; // Always use stream-json for real-time progress visibility - // Note: --verbose is required when using --print with stream-json args.push('--output-format', 'stream-json', '--verbose'); // Add permission mode if (permissionMode && permissionMode !== 'default') { if (permissionMode === 'bypassPermissions') { args.push('--dangerously-skip-permissions'); - // Warn about dangerous mode if (process.env.CCS_DEBUG) { console.warn(warn('WARNING: Using --dangerously-skip-permissions mode')); - console.warn( - warn('This bypasses ALL permission checks. Use only in trusted environments.') - ); } } else { args.push('--permission-mode', permissionMode); @@ -170,56 +92,35 @@ export class HeadlessExecutor { // Add resume flag for multi-turn sessions if (resumeSession) { const lastSession = sessionMgr.getLastSession(profile); - if (lastSession) { args.push('--resume', lastSession.sessionId); if (process.env.CCS_DEBUG) { - const cost = - lastSession.totalCost !== undefined && lastSession.totalCost !== null - ? lastSession.totalCost.toFixed(4) - : '0.0000'; - console.error( - info( - `Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})` - ) - ); + const cost = lastSession.totalCost?.toFixed(4) || '0.0000'; + console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`)); } } else if (sessionId) { args.push('--resume', sessionId); - if (process.env.CCS_DEBUG) { - console.error(info(`Resuming specific session: ${sessionId}`)); - } } else { console.warn(warn('No previous session found, starting new session')); } } else if (sessionId) { args.push('--resume', sessionId); - if (process.env.CCS_DEBUG) { - console.error(info(`Resuming specific session: ${sessionId}`)); - } } // Add tool restrictions from settings const toolRestrictions = SettingsParser.parseToolRestrictions(cwd); - if (toolRestrictions.allowedTools.length > 0) { - args.push('--allowedTools'); - toolRestrictions.allowedTools.forEach((tool) => args.push(tool)); + args.push('--allowedTools', ...toolRestrictions.allowedTools); } - if (toolRestrictions.disallowedTools.length > 0) { - args.push('--disallowedTools'); - toolRestrictions.disallowedTools.forEach((tool) => args.push(tool)); + args.push('--disallowedTools', ...toolRestrictions.disallowedTools); } - // Note: No max-turns limit - using time-based limits instead (default 10min timeout) - - // Passthrough extra args (from Claude CLI flags like --agent, --system-prompt-file, etc.) + // Passthrough extra args if (extraArgs.length > 0) { args.push(...extraArgs); } - // Debug log args if (process.env.CCS_DEBUG) { console.error(info(`Claude CLI args: ${args.join(' ')}`)); } @@ -228,13 +129,38 @@ export class HeadlessExecutor { await ui.init(); // Execute with spawn + return this._spawnAndExecute(claudeCli, args, { + cwd, + profile, + timeout, + resumeSession, + sessionId, + sessionMgr, + }); + } + + /** + * Spawn Claude CLI and handle execution + */ + private static _spawnAndExecute( + claudeCli: string, + args: string[], + ctx: { + cwd: string; + profile: string; + timeout: number; + resumeSession: boolean; + sessionId: string | null; + sessionMgr: SessionManager; + } + ): Promise { + const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx; + return new Promise((resolve, reject) => { const startTime = Date.now(); - - // Show progress unless explicitly disabled with CCS_QUIET const showProgress = !process.env.CCS_QUIET; + const streamBuffer = new StreamBuffer(); - // Show initial progress message if (showProgress) { const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); @@ -250,40 +176,28 @@ export class HeadlessExecutor { let stdout = ''; let stderr = ''; let progressInterval: NodeJS.Timeout | undefined; - const messages: StreamMessage[] = []; // Accumulate stream-json messages - let partialLine = ''; // Buffer for incomplete JSON lines + const messages: StreamMessage[] = []; + let timedOut = false; - // Handle parent process termination (Ctrl+C or Esc in Claude) - // When main Claude session is killed, cleanup spawned child process + // Setup signal handlers for cleanup const cleanupHandler = () => { if (!proc.killed) { - if (process.env.CCS_DEBUG) { - console.error(warn('Parent process terminating, killing delegated session...')); - } proc.kill('SIGTERM'); - // Force kill if not dead after 2s setTimeout(() => { - if (!proc.killed) { - proc.kill('SIGKILL'); - } + if (!proc.killed) proc.kill('SIGKILL'); }, 2000); } }; - - // Register signal handlers for parent process termination process.once('SIGINT', cleanupHandler); process.once('SIGTERM', cleanupHandler); - - // Cleanup signal handlers when child process exits const removeSignalHandlers = () => { process.removeListener('SIGINT', cleanupHandler); process.removeListener('SIGTERM', cleanupHandler); }; - proc.on('close', removeSignalHandlers); proc.on('error', removeSignalHandlers); - // Progress indicator (show elapsed time every 5 seconds) + // Progress indicator if (showProgress) { progressInterval = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -291,155 +205,34 @@ export class HeadlessExecutor { }, 5000); } - // Capture stdout (stream-json format - jsonl) + // Capture stdout (stream-json format) proc.stdout?.on('data', (data: Buffer) => { const dataStr = data.toString(); stdout += dataStr; - // Parse stream-json messages (jsonl format - one JSON per line) - const chunk = partialLine + dataStr; - const lines = chunk.split('\n'); - partialLine = lines.pop() || ''; // Save incomplete line for next chunk + const parsedMessages = streamBuffer.parseChunk(dataStr); + for (const msg of parsedMessages) { + messages.push(msg); - for (const line of lines) { - if (!line.trim()) continue; - - try { - const msg: StreamMessage = JSON.parse(line); - messages.push(msg); - - // Show real-time tool use with verbose details - if (showProgress && msg.type === 'assistant') { - const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || []; - - for (const tool of toolUses) { - process.stderr.write('\r\x1b[K'); // Clear line - - // Show verbose tool use with description/input if available - const toolInput = tool.input || {}; - let verboseMsg = `[Tool] ${tool.name}`; - - // Add context based on tool type (all Claude Code tools) - switch (tool.name) { - case 'Bash': - if (toolInput.command) { - // Truncate long commands - const command = toolInput.command as string; - const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command; - verboseMsg += `: ${cmd}`; - } - break; - - case 'Edit': - case 'Write': - case 'Read': - if (toolInput.file_path) { - verboseMsg += `: ${toolInput.file_path}`; - } - break; - - case 'NotebookEdit': - case 'NotebookRead': - if (toolInput.notebook_path) { - verboseMsg += `: ${toolInput.notebook_path}`; - } - break; - - case 'Grep': - if (toolInput.pattern) { - verboseMsg += `: searching for "${toolInput.pattern}"`; - if (toolInput.path) { - verboseMsg += ` in ${toolInput.path}`; - } - } - break; - - case 'Glob': - if (toolInput.pattern) { - verboseMsg += `: ${toolInput.pattern}`; - } - break; - - case 'SlashCommand': - if (toolInput.command) { - verboseMsg += `: ${toolInput.command}`; - } - break; - - case 'Task': - if (toolInput.description) { - verboseMsg += `: ${toolInput.description}`; - } else if (toolInput.prompt) { - const promptText = toolInput.prompt as string; - const prompt = - promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText; - verboseMsg += `: ${prompt}`; - } - break; - - case 'TodoWrite': - if (toolInput.todos && Array.isArray(toolInput.todos)) { - // Show in_progress task instead of just count - const inProgressTask = toolInput.todos.find( - (t: { status: string; activeForm?: string }) => t.status === 'in_progress' - ); - if (inProgressTask && inProgressTask.activeForm) { - verboseMsg += `: ${inProgressTask.activeForm}`; - } else { - // Fallback to count if no in_progress task - verboseMsg += `: ${toolInput.todos.length} task(s)`; - } - } - break; - - case 'WebFetch': - if (toolInput.url) { - verboseMsg += `: ${toolInput.url}`; - } - break; - - case 'WebSearch': - if (toolInput.query) { - verboseMsg += `: "${toolInput.query}"`; - } - break; - - default: - // For unknown tools, show first meaningful parameter - if (Object.keys(toolInput).length > 0) { - const firstKey = Object.keys(toolInput)[0]; - const firstValue = toolInput[firstKey]; - if (typeof firstValue === 'string' && firstValue.length < 60) { - verboseMsg += `: ${firstValue}`; - } - } - } - - process.stderr.write(`${verboseMsg}\n`); - } - } - } catch (parseError) { - // Skip malformed JSON lines (shouldn't happen with stream-json) - if (process.env.CCS_DEBUG) { - console.error( - warn(`Failed to parse stream-json line: ${(parseError as Error).message}`) - ); + // Show real-time tool use + if (showProgress && msg.type === 'assistant') { + const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || []; + for (const tool of toolUses) { + process.stderr.write('\r\x1b[K'); + const toolInput = tool.input || {}; + const verboseMsg = formatToolVerbose(tool.name || 'Unknown', toolInput); + process.stderr.write(`${verboseMsg}\n`); } } } }); - // Stream stderr in real-time (progress messages from Claude CLI) + // Stream stderr in real-time proc.stderr?.on('data', (data: Buffer) => { const stderrText = data.toString(); stderr += stderrText; - - // Show stderr in real-time if in TTY if (showProgress) { - // Clear progress line before showing stderr - if (progressInterval) { - process.stderr.write('\r\x1b[K'); // Clear line - } + if (progressInterval) process.stderr.write('\r\x1b[K'); process.stderr.write(stderrText); } }); @@ -448,80 +241,44 @@ export class HeadlessExecutor { proc.on('close', (exitCode: number | null) => { const duration = Date.now() - startTime; - // Clear progress indicator if (progressInterval) { clearInterval(progressInterval); - process.stderr.write('\r\x1b[K'); // Clear line + process.stderr.write('\r\x1b[K'); } - // Show completion message if (showProgress) { const durationSec = (duration / 1000).toFixed(1); - if (timedOut) { - console.error(ui.warn(`Execution timed out after ${durationSec}s`)); - } else { - console.error(ui.info(`Execution completed in ${durationSec}s`)); - } - console.error(''); // Blank line before formatted output + console.error( + timedOut + ? ui.warn(`Timed out after ${durationSec}s`) + : ui.info(`Completed in ${durationSec}s`) + ); + console.error(''); } - const result: ExecutionResult = { + const result = buildExecutionResult({ exitCode: exitCode || 0, stdout, stderr, cwd, profile, duration, - timedOut: false, - success: exitCode === 0 && !timedOut, - messages, // Include all stream-json messages - }; + timedOut, + messages, + }); - // Extract metadata from final 'result' message in stream-json - const resultMessage = messages.find((m) => m.type === 'result'); - if (resultMessage) { - // Add parsed fields from result message - result.sessionId = resultMessage.session_id || undefined; - result.totalCost = resultMessage.total_cost_usd || 0; - result.numTurns = resultMessage.num_turns || 0; - result.isError = resultMessage.is_error || false; - result.type = resultMessage.type || null; - result.subtype = resultMessage.subtype || undefined; - result.durationApi = resultMessage.duration_api_ms || 0; - result.permissionDenials = resultMessage.permission_denials || []; - result.errors = resultMessage.errors || []; - - // Extract content from result message - result.content = resultMessage.result || ''; - } else { - // Fallback: no result message found (shouldn't happen) - result.content = stdout; - if (process.env.CCS_DEBUG) { - console.error(warn('No result message found in stream-json output')); - } - } - - // Store or update session if we have session ID (even on timeout, for :continue support) + // Store session if (result.sessionId) { if (resumeSession || sessionId) { - // Update existing session - sessionMgr.updateSession(profile, result.sessionId, { - totalCost: result.totalCost, - }); + sessionMgr.updateSession(profile, result.sessionId, { totalCost: result.totalCost }); } else { - // Store new session sessionMgr.storeSession(profile, { sessionId: result.sessionId, totalCost: result.totalCost, - cwd: result.cwd, + cwd, }); } - - // Cleanup expired sessions periodically - if (Math.random() < 0.1) { - // 10% chance - sessionMgr.cleanupExpired(); - } + if (Math.random() < 0.1) sessionMgr.cleanupExpired(); } resolve(result); @@ -529,57 +286,31 @@ export class HeadlessExecutor { // Handle errors proc.on('error', (error: Error) => { - if (progressInterval) { - clearInterval(progressInterval); - } + if (progressInterval) clearInterval(progressInterval); reject(new Error(`Failed to execute Claude CLI: ${error.message}`)); }); - // Handle timeout with graceful SIGTERM then forceful SIGKILL - let timedOut = false; + // Handle timeout if (timeout > 0) { const timeoutHandle = setTimeout(() => { if (!proc.killed) { timedOut = true; - if (progressInterval) { clearInterval(progressInterval); - process.stderr.write('\r\x1b[K'); // Clear line + process.stderr.write('\r\x1b[K'); } - - if (process.env.CCS_DEBUG) { - console.error( - warn(`Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`) - ); - } - - // Send SIGTERM for graceful shutdown proc.kill('SIGTERM'); - - // If process doesn't terminate within 10s, force kill setTimeout(() => { - if (!proc.killed) { - if (process.env.CCS_DEBUG) { - console.error(warn('Process did not terminate gracefully, sending SIGKILL...')); - } - proc.kill('SIGKILL'); - } - }, 10000); // Give 10s for graceful shutdown instead of 5s + if (!proc.killed) proc.kill('SIGKILL'); + }, 10000); } }, timeout); - - // Clear timeout on successful completion proc.on('close', () => clearTimeout(timeoutHandle)); } }); } - /** - * Validate permission mode - * @param mode - Permission mode - * @throws {Error} If mode is invalid - * @private - */ + /** Validate permission mode */ private static _validatePermissionMode(mode: string): void { const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; if (!VALID_MODES.includes(mode)) { @@ -587,34 +318,18 @@ export class HeadlessExecutor { } } - /** - * Detect Claude CLI executable - * @returns Path to claude CLI or null if not found - * @private - */ + /** Detect Claude CLI executable */ private static _detectClaudeCli(): string | null { - // Check environment variable override - if (process.env.CCS_CLAUDE_PATH) { - return process.env.CCS_CLAUDE_PATH; - } - - // Try to find in PATH + if (process.env.CCS_CLAUDE_PATH) return process.env.CCS_CLAUDE_PATH; const { execSync } = require('child_process'); try { - const result = execSync('command -v claude', { encoding: 'utf8' }); - return result.trim(); - } catch (_error) { + return execSync('command -v claude', { encoding: 'utf8' }).trim(); + } catch { return null; } } - /** - * Execute with retry logic - * @param profile - Profile name - * @param enhancedPrompt - Enhanced prompt - * @param options - Execution options - * @returns execution result - */ + /** Execute with retry logic */ static async executeWithRetry( profile: string, enhancedPrompt: string, @@ -626,103 +341,59 @@ export class HeadlessExecutor { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const result = await this.execute(profile, enhancedPrompt, execOptions); - - // If successful, return immediately - if (result.success) { - return result; - } - - // If not last attempt, retry + if (result.success) return result; if (attempt < maxRetries) { console.error(warn(`Attempt ${attempt + 1} failed, retrying...`)); - await this._sleep(1000 * (attempt + 1)); // Exponential backoff + await this._sleep(1000 * (attempt + 1)); continue; } - - // Last attempt failed, return result anyway return result; } catch (error) { lastError = error as Error; - if (attempt < maxRetries) { - console.error( - warn(`Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...`) - ); + console.error(warn(`Attempt ${attempt + 1} errored, retrying...`)); await this._sleep(1000 * (attempt + 1)); } } } - - // All retries exhausted throw lastError || new Error('Execution failed after all retry attempts'); } - /** - * Sleep utility for retry backoff - * @param ms - Milliseconds to sleep - * @returns Promise - * @private - */ + /** Sleep utility for retry backoff */ private static _sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } - /** - * Process prompt to detect and preserve slash commands - * Implements smart enhancement: preserves slash command at start, allows context in rest - * @param prompt - Original prompt (may contain slash command) - * @returns Processed prompt with slash command preserved - * @private - */ + /** Process prompt to detect and preserve slash commands */ private static _processSlashCommand(prompt: string): string { const trimmed = prompt.trim(); - // Case 1: Already starts with slash command - keep as-is - if (trimmed.match(/^\/[\w:-]+(\s|$)/)) { - return prompt; - } + // Case 1: Already starts with slash command + if (trimmed.match(/^\/[\w:-]+(\s|$)/)) return prompt; // Case 2: Find slash command embedded in text - // Look for /command that's NOT part of a file path - // File paths: /home/user, /path/to/file (have / before or after) - // Commands: /cook, /plan (standalone, preceded by space/colon/start) - // Strategy: Find LAST occurrence that looks like a command, not a path const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/); - if (embeddedSlash) { - const command = embeddedSlash[1]; // e.g., "/cook" - const args = (embeddedSlash[2] || '').trim(); // Everything after command - - // Calculate where the command starts (excluding preceding char if any) + const command = embeddedSlash[1]; + const args = (embeddedSlash[2] || '').trim(); const matchIndex = embeddedSlash.index || 0; const matchStart = matchIndex + (embeddedSlash[0][0] === '/' ? 0 : 1); const beforeCommand = trimmed.substring(0, matchStart).trim(); - // Restructure: command first, context after - if (beforeCommand && args) { - return `${command} ${args}\n\nContext: ${beforeCommand}`; - } else if (beforeCommand) { - return `${command}\n\nContext: ${beforeCommand}`; - } + if (beforeCommand && args) return `${command} ${args}\n\nContext: ${beforeCommand}`; + if (beforeCommand) return `${command}\n\nContext: ${beforeCommand}`; return args ? `${command} ${args}` : command; } - // No slash command detected, return as-is return prompt; } - /** - * Test if profile is executable (quick health check) - * @param profile - Profile name - * @returns True if profile can execute - */ + /** Test if profile is executable */ static async testProfile(profile: string): Promise { try { - const result = await this.execute(profile, 'Say "test successful"', { - timeout: 10000, - }); + const result = await this.execute(profile, 'Say "test successful"', { timeout: 10000 }); return result.success; - } catch (_error) { + } catch { return false; } } diff --git a/src/delegation/index.ts b/src/delegation/index.ts new file mode 100644 index 00000000..c7274a60 --- /dev/null +++ b/src/delegation/index.ts @@ -0,0 +1,13 @@ +/** + * Delegation module barrel export + */ + +export { HeadlessExecutor } from './headless-executor'; +export type { ExecutionOptions, ExecutionResult, StreamMessage } from './headless-executor'; +export { SessionManager } from './session-manager'; +export { SettingsParser } from './settings-parser'; +export { ResultFormatter } from './result-formatter'; +export { DelegationHandler } from './delegation-handler'; + +// Re-export executor sub-module +export * from './executor'; diff --git a/src/delegation/result-formatter.ts b/src/delegation/result-formatter.ts index 1b2497d8..4eae4f9f 100644 --- a/src/delegation/result-formatter.ts +++ b/src/delegation/result-formatter.ts @@ -9,40 +9,10 @@ import * as path from 'path'; import { execSync } from 'child_process'; import * as fs from 'fs'; import { ui } from '../utils/ui'; +import type { ExecutionResult, ExecutionError, PermissionDenial } from './executor/types'; -interface ExecutionResult { - profile: string; - cwd: string; - exitCode: number; - stdout: string; - stderr: string; - duration: number; - success: boolean; - content?: string; - sessionId?: string; - totalCost?: number; - numTurns?: number; - subtype?: string; - permissionDenials?: PermissionDenial[]; - errors?: ErrorInfo[]; - // json?: any; // Removed: unused parameter - timedOut?: boolean; -} - -interface PermissionDenial { - tool_name?: string; - tool_input?: { - command?: string; - description?: string; - [key: string]: unknown; - }; -} - -interface ErrorInfo { - message?: string; - error?: string; - [key: string]: unknown; -} +// Alias for backward compatibility +type ErrorInfo = ExecutionError; interface FileChanges { created: string[]; diff --git a/src/errors/cleanup-registry.ts b/src/errors/cleanup-registry.ts new file mode 100644 index 00000000..151eaf4d --- /dev/null +++ b/src/errors/cleanup-registry.ts @@ -0,0 +1,151 @@ +/** + * Cleanup registry for CCS CLI + * + * Manages cleanup callbacks that should be run on exit or error. + * Used to clean up resources like: + * - Spawned processes (proxies, child processes) + * - Temporary files + * - Network connections + * - Open file handles + */ + +/** + * Cleanup callback type + * Callbacks should be synchronous and non-throwing + */ +export type CleanupCallback = () => void; + +/** + * Registry of cleanup callbacks + * Executed in LIFO order (last registered = first executed) + */ +const cleanupCallbacks: CleanupCallback[] = []; + +/** + * Flag to prevent double execution + */ +let cleanupRan = false; + +/** + * Register a cleanup callback + * Callbacks are executed in LIFO order (stack-like behavior) + * + * @param fn - Cleanup function to register + * @returns Unregister function to remove the callback + */ +export function registerCleanup(fn: CleanupCallback): () => void { + cleanupCallbacks.push(fn); + + // Return unregister function + return () => { + const index = cleanupCallbacks.indexOf(fn); + if (index !== -1) { + cleanupCallbacks.splice(index, 1); + } + }; +} + +/** + * Run all registered cleanup callbacks + * Executes in LIFO order, catches and logs individual errors + * Can only be run once per process + */ +export function runCleanup(): void { + if (cleanupRan) { + return; + } + cleanupRan = true; + + const isDebug = process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true'; + + // Execute in reverse order (LIFO) + while (cleanupCallbacks.length > 0) { + const callback = cleanupCallbacks.pop(); + if (callback) { + try { + callback(); + } catch (error) { + // Log cleanup errors in debug mode but don't throw + if (isDebug) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[!] Cleanup error: ${message}`); + } + } + } + } +} + +/** + * Clear all registered cleanup callbacks + * Primarily used for testing + */ +export function clearCleanup(): void { + cleanupCallbacks.length = 0; + cleanupRan = false; +} + +/** + * Get the number of registered cleanup callbacks + * Primarily used for testing + */ +export function getCleanupCount(): number { + return cleanupCallbacks.length; +} + +/** + * Check if cleanup has already run + * Primarily used for testing + */ +export function hasCleanupRun(): boolean { + return cleanupRan; +} + +/** + * Create a cleanup scope for automatic resource management + * Resources registered within the scope are cleaned up when done + * + * @example + * ```typescript + * const scope = createCleanupScope(); + * scope.register(() => process.kill()); + * try { + * // ... do work + * } finally { + * scope.cleanup(); + * } + * ``` + */ +export function createCleanupScope(): { + register: (fn: CleanupCallback) => void; + cleanup: () => void; +} { + const scopeCallbacks: CleanupCallback[] = []; + const unregisters: Array<() => void> = []; + + return { + register: (fn: CleanupCallback) => { + scopeCallbacks.push(fn); + // Also register with global cleanup in case of unexpected exit + const unregister = registerCleanup(fn); + unregisters.push(unregister); + }, + cleanup: () => { + // Unregister from global cleanup first + for (const unregister of unregisters) { + unregister(); + } + + // Execute scope callbacks in LIFO order + while (scopeCallbacks.length > 0) { + const callback = scopeCallbacks.pop(); + if (callback) { + try { + callback(); + } catch (_error) { + // Silently ignore scope cleanup errors + } + } + } + }, + }; +} diff --git a/src/errors/error-handler.ts b/src/errors/error-handler.ts new file mode 100644 index 00000000..a5adf988 --- /dev/null +++ b/src/errors/error-handler.ts @@ -0,0 +1,167 @@ +/** + * Centralized error handler for CCS CLI + * + * Provides unified error handling with: + * - Consistent error formatting + * - Exit code management + * - Cleanup callback execution + * - Debug mode support + */ + +import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes'; +import { isCCSError } from './error-types'; +import { runCleanup } from './cleanup-registry'; + +/** + * Debug mode flag - set via CCS_DEBUG environment variable + */ +const isDebugMode = (): boolean => { + return process.env['CCS_DEBUG'] === '1' || process.env['CCS_DEBUG'] === 'true'; +}; + +/** + * Format error message for display + * Uses ASCII-only formatting per codebase standards (no emojis) + */ +function formatErrorMessage(error: unknown): string { + if (isCCSError(error)) { + return `[X] ${error.message}`; + } + + if (error instanceof Error) { + return `[X] ${error.message}`; + } + + if (typeof error === 'string') { + return `[X] ${error}`; + } + + return '[X] An unexpected error occurred'; +} + +/** + * Get exit code from error + * CCSError types have their own codes, others default to GENERAL_ERROR + */ +function getExitCode(error: unknown): ExitCode { + if (isCCSError(error)) { + return error.code; + } + return ExitCode.GENERAL_ERROR; +} + +/** + * Log debug information for an error + * Only outputs when CCS_DEBUG is enabled + */ +function logDebugInfo(error: unknown, code: ExitCode): void { + if (!isDebugMode()) return; + + console.error(''); + console.error('[i] Debug information:'); + console.error(` Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`); + + if (error instanceof Error) { + console.error(` Error type: ${error.constructor.name}`); + if (error.stack) { + console.error(' Stack trace:'); + const stackLines = error.stack.split('\n').slice(1, 6); + for (const line of stackLines) { + console.error(` ${line.trim()}`); + } + } + } + + // Log additional properties for CCSError types + if (isCCSError(error)) { + console.error(` Recoverable: ${error.recoverable}`); + } +} + +/** + * Central error handler + * Formats error, runs cleanup, and exits with appropriate code + * + * @param error - The error to handle + * @returns never - Always exits the process + */ +export function handleError(error: unknown): never { + // Run cleanup callbacks first + runCleanup(); + + const code = getExitCode(error); + const message = formatErrorMessage(error); + + // Output error message to stderr + console.error(message); + + // Log debug info if enabled + logDebugInfo(error, code); + + // Exit with appropriate code + process.exit(code); +} + +/** + * Exit with an error message and code + * Convenience function for simple error exits + * + * @param message - Error message to display + * @param code - Exit code (defaults to GENERAL_ERROR) + * @returns never - Always exits the process + */ +export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never { + runCleanup(); + console.error(`[X] ${message}`); + + if (isDebugMode()) { + console.error(''); + console.error(`[i] Exit code: ${code} (${EXIT_CODE_DESCRIPTIONS[code] || 'Unknown'})`); + } + + process.exit(code); +} + +/** + * Exit with success + * Runs cleanup and exits with SUCCESS code + * + * @param message - Optional success message to display + * @returns never - Always exits the process + */ +export function exitWithSuccess(message?: string): never { + runCleanup(); + if (message) { + console.log(`[OK] ${message}`); + } + process.exit(ExitCode.SUCCESS); +} + +/** + * Create a wrapped error handler for async operations + * Wraps an async function to catch and handle errors + */ +export function withErrorHandling( + fn: (...args: T) => Promise +): (...args: T) => Promise { + return async (...args: T): Promise => { + try { + await fn(...args); + } catch (error) { + handleError(error); + } + }; +} + +/** + * Assert a condition, throwing a CCSError if false + */ +export function assertOrExit( + condition: boolean, + message: string, + code: ExitCode = ExitCode.GENERAL_ERROR +): asserts condition { + if (!condition) { + exitWithError(message, code); + } +} diff --git a/src/errors/error-types.ts b/src/errors/error-types.ts new file mode 100644 index 00000000..92cf2cd7 --- /dev/null +++ b/src/errors/error-types.ts @@ -0,0 +1,173 @@ +/** + * Custom error types for CCS CLI + * + * All custom errors extend CCSError which provides: + * - Standardized exit codes + * - Recoverable flag for retry logic + * - Consistent error formatting + */ + +import { ExitCode } from './exit-codes'; + +/** + * Base error class for all CCS errors + * Extends standard Error with exit code and recovery information + */ +export class CCSError extends Error { + constructor( + message: string, + public readonly code: ExitCode = ExitCode.GENERAL_ERROR, + public readonly recoverable: boolean = false + ) { + super(message); + this.name = 'CCSError'; + // Maintain proper stack trace in V8 environments + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Configuration-related errors + * Examples: missing config file, invalid JSON, corrupt settings + */ +export class ConfigError extends CCSError { + constructor( + message: string, + public readonly configPath?: string + ) { + super(message, ExitCode.CONFIG_ERROR, false); + this.name = 'ConfigError'; + } +} + +/** + * Network-related errors + * Examples: connection refused, timeout, DNS resolution failure + */ +export class NetworkError extends CCSError { + constructor( + message: string, + public readonly url?: string, + public readonly statusCode?: number + ) { + super(message, ExitCode.NETWORK_ERROR, true); // Network errors are typically recoverable + this.name = 'NetworkError'; + } +} + +/** + * Authentication/authorization errors + * Examples: invalid API key, expired token, insufficient permissions + */ +export class AuthError extends CCSError { + constructor( + message: string, + public readonly provider?: string + ) { + super(message, ExitCode.AUTH_ERROR, false); + this.name = 'AuthError'; + } +} + +/** + * Binary/executable errors + * Examples: Claude CLI not found, corrupted binary, permission denied + */ +export class BinaryError extends CCSError { + constructor( + message: string, + public readonly binaryPath?: string + ) { + super(message, ExitCode.BINARY_ERROR, false); + this.name = 'BinaryError'; + } +} + +/** + * Provider-specific errors + * Examples: API rate limit, service unavailable, invalid model + */ +export class ProviderError extends CCSError { + constructor( + message: string, + public readonly provider: string, + public readonly details?: unknown + ) { + super(message, ExitCode.PROVIDER_ERROR, true); // Provider errors may be recoverable + this.name = 'ProviderError'; + } +} + +/** + * Profile-related errors + * Examples: profile not found, invalid profile name, duplicate profile + */ +export class ProfileError extends CCSError { + constructor( + message: string, + public readonly profileName?: string, + public readonly availableProfiles?: string[] + ) { + super(message, ExitCode.PROFILE_ERROR, false); + this.name = 'ProfileError'; + } +} + +/** + * Proxy-related errors + * Examples: proxy startup failure, port conflict, proxy timeout + */ +export class ProxyError extends CCSError { + constructor( + message: string, + public readonly port?: number + ) { + super(message, ExitCode.PROXY_ERROR, false); + this.name = 'ProxyError'; + } +} + +/** + * Migration-related errors + * Examples: failed to migrate config, backup creation failed + */ +export class MigrationError extends CCSError { + constructor( + message: string, + public readonly fromVersion?: string, + public readonly toVersion?: string + ) { + super(message, ExitCode.MIGRATION_ERROR, false); + this.name = 'MigrationError'; + } +} + +/** + * User abort error (Ctrl+C, SIGINT) + * Used when user explicitly cancels an operation + */ +export class UserAbortError extends CCSError { + constructor(message: string = 'Operation cancelled by user') { + super(message, ExitCode.USER_ABORT, false); + this.name = 'UserAbortError'; + } +} + +/** + * Type guard to check if an error is a CCSError + */ +export function isCCSError(error: unknown): error is CCSError { + return error instanceof CCSError; +} + +/** + * Type guard to check if an error is recoverable + */ +export function isRecoverableError(error: unknown): boolean { + if (isCCSError(error)) { + return error.recoverable; + } + return false; +} diff --git a/src/errors/exit-codes.ts b/src/errors/exit-codes.ts new file mode 100644 index 00000000..a1b450cf --- /dev/null +++ b/src/errors/exit-codes.ts @@ -0,0 +1,79 @@ +/** + * Standardized exit codes for CCS CLI + * + * Exit codes follow Unix conventions: + * - 0: Success + * - 1-125: Application errors + * - 126-127: Command execution errors (reserved by shell) + * - 128+N: Signal termination (128 + signal number) + * - 130: SIGINT (Ctrl+C) - 128 + 2 + */ + +export enum ExitCode { + /** Successful execution */ + SUCCESS = 0, + + /** General/unspecified error */ + GENERAL_ERROR = 1, + + /** Configuration file errors (missing, invalid, corrupt) */ + CONFIG_ERROR = 2, + + /** Network-related errors (connection, timeout, DNS) */ + NETWORK_ERROR = 3, + + /** Authentication/authorization errors (invalid token, expired, forbidden) */ + AUTH_ERROR = 4, + + /** Binary/executable errors (missing Claude CLI, corrupted binary) */ + BINARY_ERROR = 5, + + /** Provider-specific errors (API errors, rate limits, service unavailable) */ + PROVIDER_ERROR = 6, + + /** Profile not found or invalid */ + PROFILE_ERROR = 7, + + /** Proxy-related errors (startup failure, port conflict) */ + PROXY_ERROR = 8, + + /** Migration errors (failed to migrate config) */ + MIGRATION_ERROR = 9, + + /** User aborted operation (Ctrl+C, SIGINT) */ + USER_ABORT = 130, +} + +/** + * Human-readable descriptions for exit codes + * Used in error messages and documentation + */ +export const EXIT_CODE_DESCRIPTIONS: Record = { + [ExitCode.SUCCESS]: 'Success', + [ExitCode.GENERAL_ERROR]: 'General error', + [ExitCode.CONFIG_ERROR]: 'Configuration error', + [ExitCode.NETWORK_ERROR]: 'Network error', + [ExitCode.AUTH_ERROR]: 'Authentication error', + [ExitCode.BINARY_ERROR]: 'Binary/executable error', + [ExitCode.PROVIDER_ERROR]: 'Provider error', + [ExitCode.PROFILE_ERROR]: 'Profile error', + [ExitCode.PROXY_ERROR]: 'Proxy error', + [ExitCode.MIGRATION_ERROR]: 'Migration error', + [ExitCode.USER_ABORT]: 'User abort (Ctrl+C)', +}; + +/** + * Check if an exit code indicates success + */ +export function isSuccess(code: ExitCode | number): boolean { + return code === ExitCode.SUCCESS; +} + +/** + * Check if an exit code indicates a recoverable error + * (errors that might succeed on retry) + */ +export function isRecoverable(code: ExitCode | number): boolean { + const recoverableCodes = [ExitCode.NETWORK_ERROR, ExitCode.PROVIDER_ERROR]; + return recoverableCodes.includes(code as ExitCode); +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 00000000..40d2cd4b --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,63 @@ +/** + * CCS Error Handling Module + * + * Centralized error handling system for CCS CLI providing: + * - Standardized exit codes + * - Custom error types with exit code mapping + * - Centralized error handler with cleanup + * - Cleanup callback registry + * + * @example + * ```typescript + * import { handleError, ConfigError, ExitCode, registerCleanup } from './errors'; + * + * // Register cleanup for spawned process + * registerCleanup(() => proxy.kill()); + * + * // Throw typed error + * throw new ConfigError('Invalid config file', '~/.ccs/config.json'); + * + * // Or handle directly + * handleError(new NetworkError('Connection refused')); + * ``` + */ + +// Exit codes +export { ExitCode, EXIT_CODE_DESCRIPTIONS, isSuccess, isRecoverable } from './exit-codes'; + +// Error types +export { + CCSError, + ConfigError, + NetworkError, + AuthError, + BinaryError, + ProviderError, + ProfileError, + ProxyError, + MigrationError, + UserAbortError, + isCCSError, + isRecoverableError, +} from './error-types'; + +// Error handler +export { + handleError, + exitWithError, + exitWithSuccess, + withErrorHandling, + assertOrExit, +} from './error-handler'; + +// Cleanup registry +export { + registerCleanup, + runCleanup, + clearCleanup, + getCleanupCount, + hasCleanupRun, + createCleanupScope, +} from './cleanup-registry'; + +export type { CleanupCallback } from './cleanup-registry'; diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index dd038f6f..cc6aae19 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -1,452 +1,136 @@ /** - * GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking and tool support + * GlmtTransformer - Orchestrator for Anthropic ↔ OpenAI format transformation * - * Features: - * - Request: Anthropic → OpenAI (inject reasoning params, transform tools) - * - Response: OpenAI reasoning_content → Anthropic thinking blocks - * - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional) - * - Streaming: Real-time tool calls with input_json deltas - * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG=1) - * - Verbose mode: Console logging with timestamps - * - Validation: Self-test transformation results - * - * Control Tags (in user prompt): - * - Enable/disable reasoning - * - Control reasoning depth + * Pipeline Architecture: + * - RequestTransformer: Anthropic → OpenAI request conversion + * - StreamParser: Delta processing for streaming responses + * - ResponseBuilder: SSE event generation + * - ToolCallHandler: Tool call processing */ -import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { DeltaAccumulator } from './delta-accumulator'; -import { LocaleEnforcer } from './locale-enforcer'; -import { ReasoningEnforcer } from './reasoning-enforcer'; - -// Types -interface ContentBlock { - type: string; - text?: string; - thinking?: string; - signature?: ThinkingSignature; - id?: string; - name?: string; - input?: Record; - tool_use_id?: string; - content?: string | unknown; -} - -interface Message { - role: string; - content: string | ContentBlock[]; -} - -interface AnthropicTool { - name: string; - description: string; - input_schema: Record; -} - -interface AnthropicRequest { - model: string; - messages: Message[]; - max_tokens?: number; - temperature?: number; - top_p?: number; - tools?: AnthropicTool[]; - stream?: boolean; - thinking?: { - type: 'enabled' | 'disabled'; - budget_tokens?: number; - }; -} - -interface OpenAITool { - type: 'function'; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -interface OpenAIRequest { - model: string; - messages: Message[]; - max_tokens?: number; - temperature?: number; - top_p?: number; - tools?: OpenAITool[]; - tool_choice?: string; - stream?: boolean; - do_sample?: boolean; - reasoning?: boolean; - reasoning_effort?: string; -} - -interface ThinkingConfig { - thinking: boolean; - effort: string; -} - -interface TransformResult { - openaiRequest: OpenAIRequest | AnthropicRequest; - thinkingConfig: ThinkingConfig; - error?: string; -} - -interface ThinkingSignature { - type: string; - hash: string; - length: number; - timestamp: number; -} - -interface OpenAIToolCall { - id: string; - type: string; - function: { - name: string; - arguments: string; - }; -} - -interface OpenAIChoice { - message: { - role?: string; - content?: string | null; - reasoning_content?: string; - tool_calls?: OpenAIToolCall[]; - }; - delta?: { - role?: string; - content?: string; - reasoning_content?: string; - tool_calls?: OpenAIToolCallDelta[]; - }; - finish_reason?: string; -} - -interface OpenAIToolCallDelta { - index: number; - id?: string; - type?: string; - function?: { - name?: string; - arguments?: string; - }; -} - -interface OpenAIResponse { - id?: string; - model?: string; - choices?: OpenAIChoice[]; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - }; -} - -interface AnthropicResponse { - id: string; - type: string; - role: string; - content: ContentBlock[]; - model: string; - stop_reason: string; - usage: { - input_tokens: number; - output_tokens: number; - }; -} - -interface SSEEvent { - event?: string; - data?: OpenAIResponse & { - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - }; - }; -} - -interface AnthropicSSEEvent { - event: string; - data: Record; -} - -interface ValidationResult { - checks: Record; - passed: number; - total: number; - valid: boolean; -} - -interface GlmtTransformerConfig { - defaultThinking?: boolean; - verbose?: boolean; - debugLog?: boolean; - debugMode?: boolean; - debugLogDir?: string; - explicitReasoning?: boolean; -} - -interface AccumulatorBlock { - index: number; - type: string; - content: string; - stopped: boolean; -} +import { + RequestTransformer, + StreamParser, + ResponseBuilder, + ToolCallHandler, + ContentTransformer, + type AnthropicRequest, + type OpenAIResponse, + type AnthropicResponse, + type ThinkingConfig, + type TransformResult, + type SSEEvent, + type AnthropicSSEEvent, + type GlmtTransformerConfig, + type ContentBlock, + type Message, + type ThinkingSignature, + type ValidationResult, +} from './pipeline'; export class GlmtTransformer { - private defaultThinking: boolean; private verbose: boolean; private debugLog: boolean; - private debugMode: boolean; - debugLogDir: string; // public for external access - private modelMaxTokens: Record; - private localeEnforcer: LocaleEnforcer; - private reasoningEnforcer: ReasoningEnforcer; + debugLogDir: string; + + private requestTransformer: RequestTransformer; + private streamParser: StreamParser; + private responseBuilder: ResponseBuilder; + private toolCallHandler: ToolCallHandler; + private contentTransformer: ContentTransformer; constructor(config: GlmtTransformerConfig = {}) { - this.defaultThinking = config.defaultThinking ?? true; this.verbose = config.verbose || false; - // CCS_DEBUG controls all debug logging (file + console) const debugEnabled = process.env.CCS_DEBUG === '1'; this.debugLog = config.debugLog ?? debugEnabled; - this.debugMode = config.debugMode ?? debugEnabled; - this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs'); - this.modelMaxTokens = { - 'GLM-4.6': 128000, - 'GLM-4.5': 96000, - 'GLM-4.5-air': 16000, - }; - // Initialize locale enforcer (always enforce English) - this.localeEnforcer = new LocaleEnforcer(); + // Initialize pipeline components + this.requestTransformer = new RequestTransformer({ + defaultThinking: config.defaultThinking ?? true, + verbose: this.verbose, + explicitReasoning: config.explicitReasoning ?? true, + log: (msg) => this.log(msg), + }); - // Initialize reasoning enforcer (enabled by default for all GLMT usage) - this.reasoningEnforcer = new ReasoningEnforcer({ - enabled: config.explicitReasoning ?? true, + this.responseBuilder = new ResponseBuilder(this.verbose); + this.toolCallHandler = new ToolCallHandler(); + this.contentTransformer = new ContentTransformer(config.defaultThinking ?? true); + this.streamParser = new StreamParser({ + verbose: this.verbose, + debugMode: config.debugMode ?? debugEnabled, + debugLog: this.debugLog, + writeDebugLog: (type, data) => this.writeDebugLog(type, data), }); } - /** - * Transform Anthropic request to OpenAI format - */ + /** Transform Anthropic request to OpenAI format */ transformRequest(anthropicRequest: AnthropicRequest): TransformResult { - // Log original request this.writeDebugLog('request-anthropic', anthropicRequest); - - try { - // 1. Extract thinking control from messages (tags like ) - const thinkingConfig = this.extractThinkingControl(anthropicRequest.messages || []); - const hasControlTags = this.hasThinkingTags(anthropicRequest.messages || []); - - // 2. Detect "think" keywords in user prompts (Anthropic-style) - const keywordConfig = this.detectThinkKeywords(anthropicRequest.messages || []); - if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) { - thinkingConfig.thinking = keywordConfig.thinking; - thinkingConfig.effort = keywordConfig.effort; - this.log( - `Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}` - ); - } - - // 3. Check anthropicRequest.thinking parameter (takes precedence) - if (anthropicRequest.thinking) { - if (anthropicRequest.thinking.type === 'enabled') { - thinkingConfig.thinking = true; - this.log('Claude CLI explicitly enabled thinking (overrides budget)'); - } else if (anthropicRequest.thinking.type === 'disabled') { - thinkingConfig.thinking = false; - this.log('Claude CLI explicitly disabled thinking (overrides budget)'); - } else { - this.log(`Warning: Unknown thinking type: ${anthropicRequest.thinking.type}`); - } - } - - this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`); - - // 3. Map model - const glmModel = this.mapModel(anthropicRequest.model); - - // 4. Inject locale instruction before sanitization - const messagesWithLocale = this.localeEnforcer.injectInstruction( - (anthropicRequest.messages || []) as Parameters< - typeof this.localeEnforcer.injectInstruction - >[0] - ) as unknown as Message[]; - - // 4.5. Inject reasoning instruction (if enabled or thinking requested) - const messagesWithReasoning = this.reasoningEnforcer.injectInstruction( - messagesWithLocale as unknown as Parameters< - typeof this.reasoningEnforcer.injectInstruction - >[0], - thinkingConfig - ) as unknown as Message[]; - - // 5. Convert to OpenAI format - const openaiRequest: OpenAIRequest = { - model: glmModel, - messages: this.sanitizeMessages(messagesWithReasoning), - max_tokens: this.getMaxTokens(glmModel), - stream: anthropicRequest.stream ?? false, - }; - - // 5.5. Transform tools parameter if present - if (anthropicRequest.tools && anthropicRequest.tools.length > 0) { - openaiRequest.tools = this.transformTools(anthropicRequest.tools); - // Always use "auto" as Z.AI doesn't support other modes - openaiRequest.tool_choice = 'auto'; - this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`); - } - - // 6. Preserve optional parameters - if (anthropicRequest.temperature !== undefined) { - openaiRequest.temperature = anthropicRequest.temperature; - } - if (anthropicRequest.top_p !== undefined) { - openaiRequest.top_p = anthropicRequest.top_p; - } - - // 7. Handle streaming - if (anthropicRequest.stream !== undefined) { - openaiRequest.stream = anthropicRequest.stream; - } - - // 8. Inject reasoning parameters - this.injectReasoningParams(openaiRequest, thinkingConfig); - - // Log transformed request - this.writeDebugLog('request-openai', openaiRequest); - - return { openaiRequest, thinkingConfig }; - } catch (error) { - const err = error as Error; - console.error('[glmt-transformer] Request transformation error:', err); - // Return original request with warning - return { - openaiRequest: anthropicRequest, - thinkingConfig: { thinking: false, effort: 'medium' }, - error: err.message, - }; - } + const result = this.requestTransformer.transform(anthropicRequest); + this.writeDebugLog('request-openai', result.openaiRequest); + return result; } - /** - * Transform OpenAI response to Anthropic format - */ + /** Transform OpenAI response to Anthropic format */ transformResponse( openaiResponse: OpenAIResponse, _thinkingConfig: ThinkingConfig = { thinking: false, effort: 'medium' } ): AnthropicResponse { - // Log original response this.writeDebugLog('response-openai', openaiResponse); try { const choice = openaiResponse.choices?.[0]; - if (!choice) { - throw new Error('No choices in OpenAI response'); - } + if (!choice) throw new Error('No choices in OpenAI response'); const message = choice.message; const content: ContentBlock[] = []; - // Add thinking block if reasoning_content exists if (message.reasoning_content) { - const length = message.reasoning_content.length; - const lineCount = message.reasoning_content.split('\n').length; - const preview = message.reasoning_content.substring(0, 100).replace(/\n/g, ' ').trim(); - - this.log(`Detected reasoning_content:`); - this.log(` Length: ${length} characters`); - this.log(` Lines: ${lineCount}`); - this.log(` Preview: ${preview}...`); - + this.log(`Detected reasoning_content: ${message.reasoning_content.length} chars`); content.push({ type: 'thinking', thinking: message.reasoning_content, - signature: this.generateThinkingSignature(message.reasoning_content), + signature: this.responseBuilder.generateThinkingSignature(message.reasoning_content), }); - } else { - this.log('No reasoning_content in OpenAI response'); - this.log('Note: This is expected if thinking not requested or model cannot reason'); } - // Add text content if (message.content) { - content.push({ - type: 'text', - text: message.content, - }); + content.push({ type: 'text', text: message.content }); } - // Handle tool_calls if present - if (message.tool_calls && message.tool_calls.length > 0) { - message.tool_calls.forEach((toolCall) => { - let parsedInput: Record; - try { - parsedInput = JSON.parse(toolCall.function.arguments || '{}'); - } catch (parseError) { - const err = parseError as Error; - this.log(`Warning: Invalid JSON in tool arguments: ${err.message}`); - parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments }; - } - - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedInput, - }); - }); + if (message.tool_calls?.length) { + content.push(...this.toolCallHandler.processToolCalls(message.tool_calls)); } const anthropicResponse: AnthropicResponse = { id: openaiResponse.id || 'msg_' + Date.now(), type: 'message', role: 'assistant', - content: content, + content, model: openaiResponse.model || 'glm-4.6', - stop_reason: this.mapStopReason(choice.finish_reason || 'stop'), + stop_reason: this.responseBuilder.mapStopReason(choice.finish_reason || 'stop'), usage: { input_tokens: openaiResponse.usage?.prompt_tokens || 0, output_tokens: openaiResponse.usage?.completion_tokens || 0, }, }; - // Validate transformation in verbose mode - if (this.verbose) { - const validation = this.validateTransformation(anthropicResponse); - this.log( - `Transformation validation: ${validation.passed}/${validation.total} checks passed` - ); - if (!validation.valid) { - this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`); - } - } - - // Log transformed response this.writeDebugLog('response-anthropic', anthropicResponse); - return anthropicResponse; } catch (error) { const err = error as Error; console.error('[glmt-transformer] Response transformation error:', err); - // Return minimal valid response return { id: 'msg_error_' + Date.now(), type: 'message', role: 'assistant', - content: [ - { - type: 'text', - text: '[Transformation Error] ' + err.message, - }, - ], + content: [{ type: 'text', text: '[Transformation Error] ' + err.message }], model: 'glm-4.6', stop_reason: 'end_turn', usage: { input_tokens: 0, output_tokens: 0 }, @@ -454,261 +138,55 @@ export class GlmtTransformer { } } - /** - * Sanitize messages for OpenAI API compatibility - */ - private sanitizeMessages(messages: Message[]): Message[] { - const result: Message[] = []; - - for (const msg of messages) { - // If content is a string, add as-is - if (typeof msg.content === 'string') { - result.push(msg); - continue; - } - - // If content is an array, process blocks - if (Array.isArray(msg.content)) { - // Separate tool_result blocks from other content - const toolResults = msg.content.filter((block) => block.type === 'tool_result'); - const textBlocks = msg.content.filter((block) => block.type === 'text'); - // const toolUseBlocks = msg.content.filter(block => block.type === 'tool_use'); - - // CRITICAL: Tool messages must come BEFORE user text in OpenAI API - for (const toolResult of toolResults) { - result.push({ - role: 'tool', - content: - typeof toolResult.content === 'string' - ? toolResult.content - : JSON.stringify(toolResult.content), - } as Message & { tool_call_id: string }); - } - - // Add text content as user/assistant message AFTER tool messages - if (textBlocks.length > 0) { - const textContent = - textBlocks.length === 1 - ? textBlocks[0].text || '' - : textBlocks.map((b) => b.text || '').join('\n'); - - result.push({ - role: msg.role, - content: textContent, - }); - } - - // If no content at all, add empty message - if (textBlocks.length === 0 && toolResults.length === 0) { - result.push({ - role: msg.role, - content: '', - }); - } - - continue; - } - - // Fallback: return message as-is - result.push(msg); - } - - return result; + /** Transform streaming delta (delegates to StreamParser) */ + transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + return this.streamParser.transformDelta(openaiEvent, accumulator); } - /** - * Transform Anthropic tools to OpenAI tools format - */ - private transformTools(anthropicTools: AnthropicTool[]): OpenAITool[] { - return anthropicTools.map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: tool.input_schema || {}, - }, - })); + /** Finalize streaming (delegates to StreamParser) */ + finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + return this.streamParser.finalizeDelta(accumulator); } - /** - * Check if messages contain thinking control tags - */ - private hasThinkingTags(messages: Message[]): boolean { - for (const msg of messages) { - if (msg.role !== 'user') continue; - const content = msg.content; - if (typeof content !== 'string') continue; - - // Check for control tags - if (//i.test(content) || //i.test(content)) { - return true; - } - } - return false; - } - - /** - * Extract thinking control tags from user messages - */ - private extractThinkingControl(messages: Message[]): ThinkingConfig { - const config: ThinkingConfig = { - thinking: this.defaultThinking, - effort: 'medium', - }; - - // Scan user messages for control tags - for (const msg of messages) { - if (msg.role !== 'user') continue; - - const content = msg.content; - if (typeof content !== 'string') continue; - - // Check for - const thinkingMatch = content.match(//i); - if (thinkingMatch) { - config.thinking = thinkingMatch[1].toLowerCase() === 'on'; - } - - // Check for - const effortMatch = content.match(//i); - if (effortMatch) { - config.effort = effortMatch[1].toLowerCase(); - } - } - - return config; - } - - /** - * Generate thinking signature for Claude Code UI - */ - private generateThinkingSignature(thinking: string): ThinkingSignature { - // Generate signature hash - const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16); - - return { - type: 'thinking_signature', - hash: hash, - length: thinking.length, - timestamp: Date.now(), - }; - } - - /** - * Detect Anthropic-style "think" keywords in user prompts - */ - private detectThinkKeywords( - messages: Message[] - ): { thinking: boolean; effort: string; keyword: string } | null { - if (!messages || messages.length === 0) return null; - - // Extract text from user messages - const text = messages - .filter((m) => m.role === 'user') - .map((m) => { - if (typeof m.content === 'string') return m.content; - if (Array.isArray(m.content)) { - return m.content - .filter((block) => block.type === 'text') - .map((block) => block.text || '') - .join(' '); - } - return ''; - }) - .join(' '); - - // Priority: ultrathink > think harder > think hard > think - if (/\bultrathink\b/i.test(text)) { - return { thinking: true, effort: 'max', keyword: 'ultrathink' }; - } - if (/\bthink\s+harder\b/i.test(text)) { - return { thinking: true, effort: 'high', keyword: 'think harder' }; - } - if (/\bthink\s+hard\b/i.test(text)) { - return { thinking: true, effort: 'medium', keyword: 'think hard' }; - } - if (/\bthink\b/i.test(text)) { - return { thinking: true, effort: 'low', keyword: 'think' }; - } - - return null; // No keywords detected - } - - /** - * Inject reasoning parameters into OpenAI request - */ - private injectReasoningParams( - openaiRequest: OpenAIRequest, - thinkingConfig: ThinkingConfig - ): void { - // Always enable sampling for temperature/top_p to work - openaiRequest.do_sample = true; - - // Add thinking-specific parameters if enabled - if (thinkingConfig.thinking) { - openaiRequest.reasoning = true; - openaiRequest.reasoning_effort = thinkingConfig.effort; - } - } - - /** - * Map Anthropic model to GLM model - */ - private mapModel(_anthropicModel: string): string { - // Default to GLM-4.6 (latest and most capable) - return 'GLM-4.6'; - } - - /** - * Get max tokens for model - */ - private getMaxTokens(model: string): number { - return this.modelMaxTokens[model] || 128000; - } - - /** - * Map OpenAI stop reason to Anthropic stop reason - */ - private mapStopReason(openaiReason: string): string { - const mapping: Record = { - stop: 'end_turn', - length: 'max_tokens', - tool_calls: 'tool_use', - content_filter: 'stop_sequence', - }; - return mapping[openaiReason] || 'end_turn'; - } - - /** - * Write debug log to file - */ private writeDebugLog(type: string, data: unknown): void { if (!this.debugLog) return; - try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0]; - const filename = `${timestamp}-${type}.json`; - const filepath = path.join(this.debugLogDir, filename); - - // Ensure directory exists + const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`); fs.mkdirSync(this.debugLogDir, { recursive: true }); - - // Write file (pretty-printed) fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8'); - - if (this.verbose) { - this.log(`Debug log written: ${filepath}`); - } } catch (error) { - const err = error as Error; - console.error(`[glmt-transformer] Failed to write debug log: ${err.message}`); + console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`); } } - /** - * Validate transformed Anthropic response - */ - private validateTransformation(anthropicResponse: AnthropicResponse): ValidationResult { + private log(message: string): void { + if (this.verbose) { + console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`); + } + } + + // ========== Backwards-compatible public methods ========== + + /** Generate thinking signature (delegates to ResponseBuilder) */ + generateThinkingSignature(thinking: string): ThinkingSignature { + return this.responseBuilder.generateThinkingSignature(thinking); + } + + /** Map stop reason (delegates to ResponseBuilder) */ + mapStopReason(openaiReason: string): string { + return this.responseBuilder.mapStopReason(openaiReason); + } + + /** Detect think keywords (delegates to ContentTransformer) */ + detectThinkKeywords( + messages: Message[] + ): { thinking: boolean; effort: string; keyword: string } | null { + return this.contentTransformer.detectThinkKeywords(messages); + } + + /** Validate transformation result */ + validateTransformation(anthropicResponse: AnthropicResponse): ValidationResult { const checks: Record = { hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0), hasThinking: anthropicResponse.content?.some((block) => block.type === 'thinking') || false, @@ -723,400 +201,6 @@ export class GlmtTransformer { return { checks, passed, total, valid: passed === total }; } - - /** - * Transform OpenAI streaming delta to Anthropic events - */ - transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] { - const events: AnthropicSSEEvent[] = []; - - // Debug logging for streaming deltas - if (this.debugLog && openaiEvent.data) { - this.writeDebugLog('delta-openai', openaiEvent.data); - } - - // Handle [DONE] marker - if (openaiEvent.event === 'done') { - if (!accumulator.isFinalized()) { - return this.finalizeDelta(accumulator); - } - return []; // Already finalized - } - - // Usage update (appears in final chunk, may be before choice data) - if (openaiEvent.data?.usage) { - accumulator.updateUsage(openaiEvent.data.usage); - - // If we have both usage AND finish_reason, finalize immediately - if (accumulator.getFinishReason()) { - events.push(...this.finalizeDelta(accumulator)); - return events; - } - } - - const choice = openaiEvent.data?.choices?.[0]; - if (!choice) return events; - - const delta = choice.delta; - if (!delta) return events; - - // Message start - if (!accumulator.isMessageStarted()) { - if (openaiEvent.data?.model) { - accumulator.setModel(openaiEvent.data.model); - } - events.push(this.createMessageStartEvent(accumulator)); - accumulator.setMessageStarted(true); - } - - // Role - if (delta.role) { - accumulator.setRole(delta.role); - } - - // Reasoning content delta - if (delta.reasoning_content) { - const currentBlock = accumulator.getCurrentBlock(); - - if (this.debugMode) { - console.error(`[GLMT-DEBUG] Reasoning delta: ${delta.reasoning_content.length} chars`); - console.error( - `[GLMT-DEBUG] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}` - ); - } - - if (!currentBlock || currentBlock.type !== 'thinking') { - // Start thinking block - const block = accumulator.startBlock('thinking'); - events.push(this.createContentBlockStartEvent(block)); - - if (this.debugMode) { - console.error(`[GLMT-DEBUG] Started new thinking block ${block.index}`); - } - } - - accumulator.addDelta(delta.reasoning_content); - const currentThinkingBlock = accumulator.getCurrentBlock(); - if (currentThinkingBlock) { - events.push(this.createThinkingDeltaEvent(currentThinkingBlock, delta.reasoning_content)); - } - } - - // Text content delta - if (delta.content) { - const currentBlock = accumulator.getCurrentBlock(); - - // Close thinking block if transitioning from thinking to text - if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) { - const signatureEvent = this.createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { - events.push(signatureEvent); - } - events.push(this.createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock()?.type !== 'text') { - // Start text block - const block = accumulator.startBlock('text'); - events.push(this.createContentBlockStartEvent(block)); - } - - accumulator.addDelta(delta.content); - const currentTextBlock = accumulator.getCurrentBlock(); - if (currentTextBlock) { - events.push(this.createTextDeltaEvent(currentTextBlock, delta.content)); - } - } - - // Check for planning loop - if (accumulator.checkForLoop()) { - this.log( - 'WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls' - ); - this.log('Forcing early finalization to prevent unbounded planning'); - - // Close current block if any - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - const signatureEvent = this.createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { - events.push(signatureEvent); - } - } - events.push(this.createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // Force finalization - events.push(...this.finalizeDelta(accumulator)); - return events; - } - - // Tool calls deltas - if (delta.tool_calls && delta.tool_calls.length > 0) { - // Close current content block ONCE before processing any tool calls - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - const signatureEvent = this.createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { - events.push(signatureEvent); - } - } - events.push(this.createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // Process each tool call delta - for (const toolCallDelta of delta.tool_calls) { - // Track tool call state - const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index); - accumulator.addToolCallDelta(toolCallDelta); - - // Emit tool use events (start + input_json deltas) - if (isNewToolCall) { - // Start new tool_use block in accumulator - const block = accumulator.startBlock('tool_use'); - const toolCall = accumulator.getToolCall(toolCallDelta.index); - - events.push({ - event: 'content_block_start', - data: { - type: 'content_block_start', - index: block.index, - content_block: { - type: 'tool_use', - id: toolCall?.id || `tool_${toolCallDelta.index}`, - name: toolCall?.function?.name || '', - }, - }, - }); - } - - // Emit input_json delta if arguments present - if (toolCallDelta.function?.arguments) { - const currentToolBlock = accumulator.getCurrentBlock(); - if (currentToolBlock && currentToolBlock.type === 'tool_use') { - events.push({ - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: currentToolBlock.index, - delta: { - type: 'input_json_delta', - partial_json: toolCallDelta.function.arguments, - }, - }, - }); - } - } - } - } - - // Finish reason - if (choice.finish_reason) { - accumulator.setFinishReason(choice.finish_reason); - - // If we have both finish_reason AND usage, finalize immediately - if (accumulator.hasUsageReceived()) { - events.push(...this.finalizeDelta(accumulator)); - } - } - - // Debug logging for generated events - if (this.debugLog && events.length > 0) { - this.writeDebugLog('delta-anthropic-events', { - events, - accumulator: accumulator.getSummary(), - }); - } - - return events; - } - - /** - * Finalize streaming and generate closing events - */ - finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] { - if (accumulator.isFinalized()) { - return []; // Already finalized - } - - const events: AnthropicSSEEvent[] = []; - - // Close current content block if any - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - const signatureEvent = this.createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { - events.push(signatureEvent); - } - } - events.push(this.createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // Message delta (stop reason + usage) - events.push({ - event: 'message_delta', - data: { - type: 'message_delta', - delta: { - stop_reason: this.mapStopReason(accumulator.getFinishReason() || 'stop'), - }, - usage: { - input_tokens: accumulator.getInputTokens(), - output_tokens: accumulator.getOutputTokens(), - }, - }, - }); - - // Message stop - events.push({ - event: 'message_stop', - data: { - type: 'message_stop', - }, - }); - - accumulator.setFinalized(true); - return events; - } - - /** - * Create message_start event - */ - private createMessageStartEvent(accumulator: DeltaAccumulator): AnthropicSSEEvent { - return { - event: 'message_start', - data: { - type: 'message_start', - message: { - id: accumulator.getMessageId(), - type: 'message', - role: accumulator.getRole(), - content: [], - model: accumulator.getModel() || 'glm-4.6', - stop_reason: null, - usage: { - input_tokens: accumulator.getInputTokens(), - output_tokens: 0, - }, - }, - }, - }; - } - - /** - * Create content_block_start event - */ - private createContentBlockStartEvent(block: AccumulatorBlock): AnthropicSSEEvent { - return { - event: 'content_block_start', - data: { - type: 'content_block_start', - index: block.index, - content_block: { - type: block.type, - [block.type]: '', - }, - }, - }; - } - - /** - * Create thinking_delta event - */ - private createThinkingDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent { - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'thinking_delta', - thinking: delta, - }, - }, - }; - } - - /** - * Create text_delta event - */ - private createTextDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent { - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'text_delta', - text: delta, - }, - }, - }; - } - - /** - * Create thinking signature delta event - */ - private createSignatureDeltaEvent(block: AccumulatorBlock): AnthropicSSEEvent | null { - // FIX: Guard against empty content (signature timing race) - if (!block.content || block.content.length === 0) { - if (this.verbose) { - this.log(`WARNING: Skipping signature for empty thinking block ${block.index}`); - this.log( - `This indicates a race condition - signature requested before content accumulated` - ); - } - return null; - } - - const signature = this.generateThinkingSignature(block.content); - - if (this.verbose) { - this.log(`Generating signature for block ${block.index}: ${block.content.length} chars`); - } - - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'thinking_signature_delta', - signature: signature, - }, - }, - }; - } - - /** - * Create content_block_stop event - */ - private createContentBlockStopEvent(block: AccumulatorBlock): AnthropicSSEEvent { - return { - event: 'content_block_stop', - data: { - type: 'content_block_stop', - index: block.index, - }, - }; - } - - /** - * Log message if verbose - */ - private log(message: string): void { - if (this.verbose) { - const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS - console.error(`[glmt-transformer] [${timestamp}] ${message}`); - } - } } export default GlmtTransformer; diff --git a/src/glmt/index.ts b/src/glmt/index.ts new file mode 100644 index 00000000..772a49b1 --- /dev/null +++ b/src/glmt/index.ts @@ -0,0 +1,21 @@ +/** + * GLMT (GLM Thinking) Module Barrel Export + * + * Provides OpenAI-to-Anthropic protocol translation for GLM models with + * extended thinking support. + */ + +// Core proxy and transformer +export { GlmtProxy } from './glmt-proxy'; +export { GlmtTransformer } from './glmt-transformer'; + +// Streaming utilities +export { SSEParser } from './sse-parser'; +export { DeltaAccumulator } from './delta-accumulator'; + +// Content enforcers +export { LocaleEnforcer } from './locale-enforcer'; +export { ReasoningEnforcer } from './reasoning-enforcer'; + +// Pipeline components and types +export * from './pipeline'; diff --git a/src/glmt/pipeline/content-transformer.ts b/src/glmt/pipeline/content-transformer.ts new file mode 100644 index 00000000..21b67533 --- /dev/null +++ b/src/glmt/pipeline/content-transformer.ts @@ -0,0 +1,183 @@ +/** + * ContentTransformer - Handle message sanitization and content transformations + * + * Responsibilities: + * - Sanitize messages for OpenAI API compatibility + * - Extract thinking control tags from messages + * - Detect think keywords in user prompts + * - Transform tools between Anthropic and OpenAI formats + */ + +import type { Message, ContentBlock, AnthropicTool, OpenAITool, ThinkingConfig } from './types'; + +export class ContentTransformer { + private defaultThinking: boolean; + + constructor(defaultThinking = true) { + this.defaultThinking = defaultThinking; + } + + /** + * Sanitize messages for OpenAI API compatibility + */ + sanitizeMessages(messages: Message[]): Message[] { + const result: Message[] = []; + + for (const msg of messages) { + // If content is a string, add as-is + if (typeof msg.content === 'string') { + result.push(msg); + continue; + } + + // If content is an array, process blocks + if (Array.isArray(msg.content)) { + // Separate tool_result blocks from other content + const toolResults = msg.content.filter((block) => block.type === 'tool_result'); + const textBlocks = msg.content.filter((block) => block.type === 'text'); + + // CRITICAL: Tool messages must come BEFORE user text in OpenAI API + for (const toolResult of toolResults) { + result.push({ + role: 'tool', + content: + typeof toolResult.content === 'string' + ? toolResult.content + : JSON.stringify(toolResult.content), + } as Message & { tool_call_id: string }); + } + + // Add text content as user/assistant message AFTER tool messages + if (textBlocks.length > 0) { + const textContent = + textBlocks.length === 1 + ? textBlocks[0].text || '' + : textBlocks.map((b) => b.text || '').join('\n'); + + result.push({ + role: msg.role, + content: textContent, + }); + } + + // If no content at all, add empty message + if (textBlocks.length === 0 && toolResults.length === 0) { + result.push({ + role: msg.role, + content: '', + }); + } + + continue; + } + + // Fallback: return message as-is + result.push(msg); + } + + return result; + } + + /** + * Transform Anthropic tools to OpenAI tools format + */ + transformTools(anthropicTools: AnthropicTool[]): OpenAITool[] { + return anthropicTools.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema || {}, + }, + })); + } + + /** + * Check if messages contain thinking control tags + */ + hasThinkingTags(messages: Message[]): boolean { + for (const msg of messages) { + if (msg.role !== 'user') continue; + const content = msg.content; + if (typeof content !== 'string') continue; + + // Check for control tags + if (//i.test(content) || //i.test(content)) { + return true; + } + } + return false; + } + + /** + * Extract thinking control tags from user messages + */ + extractThinkingControl(messages: Message[]): ThinkingConfig { + const config: ThinkingConfig = { + thinking: this.defaultThinking, + effort: 'medium', + }; + + // Scan user messages for control tags + for (const msg of messages) { + if (msg.role !== 'user') continue; + + const content = msg.content; + if (typeof content !== 'string') continue; + + // Check for + const thinkingMatch = content.match(//i); + if (thinkingMatch) { + config.thinking = thinkingMatch[1].toLowerCase() === 'on'; + } + + // Check for + const effortMatch = content.match(//i); + if (effortMatch) { + config.effort = effortMatch[1].toLowerCase(); + } + } + + return config; + } + + /** + * Detect Anthropic-style "think" keywords in user prompts + */ + detectThinkKeywords( + messages: Message[] + ): { thinking: boolean; effort: string; keyword: string } | null { + if (!messages || messages.length === 0) return null; + + // Extract text from user messages + const text = messages + .filter((m) => m.role === 'user') + .map((m) => { + if (typeof m.content === 'string') return m.content; + if (Array.isArray(m.content)) { + return (m.content as ContentBlock[]) + .filter((block) => block.type === 'text') + .map((block) => block.text || '') + .join(' '); + } + return ''; + }) + .join(' '); + + // Priority: ultrathink > think harder > think hard > think + if (/\bultrathink\b/i.test(text)) { + return { thinking: true, effort: 'max', keyword: 'ultrathink' }; + } + if (/\bthink\s+harder\b/i.test(text)) { + return { thinking: true, effort: 'high', keyword: 'think harder' }; + } + if (/\bthink\s+hard\b/i.test(text)) { + return { thinking: true, effort: 'medium', keyword: 'think hard' }; + } + if (/\bthink\b/i.test(text)) { + return { thinking: true, effort: 'low', keyword: 'think' }; + } + + return null; // No keywords detected + } +} diff --git a/src/glmt/pipeline/index.ts b/src/glmt/pipeline/index.ts new file mode 100644 index 00000000..7b157513 --- /dev/null +++ b/src/glmt/pipeline/index.ts @@ -0,0 +1,35 @@ +/** + * Pipeline Module Exports + * + * Barrel file for the GLMT transformation pipeline + */ + +// Types +export type { + ContentBlock, + Message, + AnthropicTool, + OpenAITool, + OpenAIToolCall, + OpenAIToolCallDelta, + AnthropicRequest, + OpenAIRequest, + ThinkingConfig, + TransformResult, + ThinkingSignature, + OpenAIChoice, + OpenAIResponse, + AnthropicResponse, + SSEEvent, + AnthropicSSEEvent, + AccumulatorBlock, + ValidationResult, + GlmtTransformerConfig, +} from './types'; + +// Pipeline components +export { ContentTransformer } from './content-transformer'; +export { ToolCallHandler } from './tool-call-handler'; +export { ResponseBuilder } from './response-builder'; +export { StreamParser, type StreamParserConfig } from './stream-parser'; +export { RequestTransformer, type RequestTransformerConfig } from './request-transformer'; diff --git a/src/glmt/pipeline/request-transformer.ts b/src/glmt/pipeline/request-transformer.ts new file mode 100644 index 00000000..98a08ea9 --- /dev/null +++ b/src/glmt/pipeline/request-transformer.ts @@ -0,0 +1,161 @@ +/** + * RequestTransformer - Handle Anthropic → OpenAI request transformation + * + * Responsibilities: + * - Transform Anthropic request format to OpenAI format + * - Inject locale and reasoning instructions + * - Map models and configure parameters + */ + +import { LocaleEnforcer } from '../locale-enforcer'; +import { ReasoningEnforcer } from '../reasoning-enforcer'; +import { ContentTransformer } from './content-transformer'; +import type { + Message, + AnthropicRequest, + OpenAIRequest, + ThinkingConfig, + TransformResult, +} from './types'; + +export interface RequestTransformerConfig { + defaultThinking?: boolean; + verbose?: boolean; + explicitReasoning?: boolean; + log?: (message: string) => void; +} + +export class RequestTransformer { + private localeEnforcer: LocaleEnforcer; + private reasoningEnforcer: ReasoningEnforcer; + private contentTransformer: ContentTransformer; + private log: (message: string) => void; + private modelMaxTokens: Record; + + constructor(config: RequestTransformerConfig = {}) { + const defaultThinking = config.defaultThinking ?? true; + this.log = config.log || (() => {}); + + this.localeEnforcer = new LocaleEnforcer(); + this.reasoningEnforcer = new ReasoningEnforcer({ + enabled: config.explicitReasoning ?? true, + }); + this.contentTransformer = new ContentTransformer(defaultThinking); + + this.modelMaxTokens = { + 'GLM-4.6': 128000, + 'GLM-4.5': 96000, + 'GLM-4.5-air': 16000, + }; + } + + /** + * Transform Anthropic request to OpenAI format + */ + transform(anthropicRequest: AnthropicRequest): TransformResult { + try { + const messages = anthropicRequest.messages || []; + + // 1. Extract thinking control from messages + const thinkingConfig = this.contentTransformer.extractThinkingControl(messages); + const hasControlTags = this.contentTransformer.hasThinkingTags(messages); + + // 2. Detect "think" keywords in user prompts + const keywordConfig = this.contentTransformer.detectThinkKeywords(messages); + if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) { + thinkingConfig.thinking = keywordConfig.thinking; + thinkingConfig.effort = keywordConfig.effort; + this.log( + `Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}` + ); + } + + // 3. Check anthropicRequest.thinking parameter (takes precedence) + if (anthropicRequest.thinking) { + if (anthropicRequest.thinking.type === 'enabled') { + thinkingConfig.thinking = true; + this.log('Claude CLI explicitly enabled thinking'); + } else if (anthropicRequest.thinking.type === 'disabled') { + thinkingConfig.thinking = false; + this.log('Claude CLI explicitly disabled thinking'); + } + } + + this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`); + + // 4. Map model + const glmModel = this.mapModel(anthropicRequest.model); + + // 5. Inject locale instruction + const messagesWithLocale = this.localeEnforcer.injectInstruction( + messages as Parameters[0] + ) as unknown as Message[]; + + // 6. Inject reasoning instruction + const messagesWithReasoning = this.reasoningEnforcer.injectInstruction( + messagesWithLocale as unknown as Parameters< + typeof this.reasoningEnforcer.injectInstruction + >[0], + thinkingConfig + ) as unknown as Message[]; + + // 7. Build OpenAI request + const openaiRequest: OpenAIRequest = { + model: glmModel, + messages: this.contentTransformer.sanitizeMessages(messagesWithReasoning), + max_tokens: this.getMaxTokens(glmModel), + stream: anthropicRequest.stream ?? false, + }; + + // 8. Transform tools if present + if (anthropicRequest.tools && anthropicRequest.tools.length > 0) { + openaiRequest.tools = this.contentTransformer.transformTools(anthropicRequest.tools); + openaiRequest.tool_choice = 'auto'; + this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`); + } + + // 9. Preserve optional parameters + if (anthropicRequest.temperature !== undefined) { + openaiRequest.temperature = anthropicRequest.temperature; + } + if (anthropicRequest.top_p !== undefined) { + openaiRequest.top_p = anthropicRequest.top_p; + } + if (anthropicRequest.stream !== undefined) { + openaiRequest.stream = anthropicRequest.stream; + } + + // 10. Inject reasoning parameters + this.injectReasoningParams(openaiRequest, thinkingConfig); + + return { openaiRequest, thinkingConfig }; + } catch (error) { + const err = error as Error; + console.error('[RequestTransformer] Transformation error:', err); + return { + openaiRequest: anthropicRequest, + thinkingConfig: { thinking: false, effort: 'medium' }, + error: err.message, + }; + } + } + + private injectReasoningParams( + openaiRequest: OpenAIRequest, + thinkingConfig: ThinkingConfig + ): void { + openaiRequest.do_sample = true; + if (thinkingConfig.thinking) { + openaiRequest.reasoning = true; + openaiRequest.reasoning_effort = thinkingConfig.effort; + } + } + + private mapModel(_anthropicModel: string): string { + return 'GLM-4.6'; + } + + private getMaxTokens(model: string): number { + return this.modelMaxTokens[model] || 128000; + } +} diff --git a/src/glmt/pipeline/response-builder.ts b/src/glmt/pipeline/response-builder.ts new file mode 100644 index 00000000..416a034f --- /dev/null +++ b/src/glmt/pipeline/response-builder.ts @@ -0,0 +1,202 @@ +/** + * ResponseBuilder - Create SSE events for Anthropic streaming format + * + * Responsibilities: + * - Create message_start, message_delta, message_stop events + * - Create content_block_start, content_block_delta, content_block_stop events + * - Generate thinking signatures + * - Map stop reasons between formats + */ + +import * as crypto from 'crypto'; +import type { DeltaAccumulator } from '../delta-accumulator'; +import type { AccumulatorBlock, AnthropicSSEEvent, ThinkingSignature } from './types'; + +export class ResponseBuilder { + private verbose: boolean; + + constructor(verbose = false) { + this.verbose = verbose; + } + + /** + * Create message_start event + */ + createMessageStartEvent(accumulator: DeltaAccumulator): AnthropicSSEEvent { + return { + event: 'message_start', + data: { + type: 'message_start', + message: { + id: accumulator.getMessageId(), + type: 'message', + role: accumulator.getRole(), + content: [], + model: accumulator.getModel() || 'glm-4.6', + stop_reason: null, + usage: { + input_tokens: accumulator.getInputTokens(), + output_tokens: 0, + }, + }, + }, + }; + } + + /** + * Create content_block_start event + */ + createContentBlockStartEvent(block: AccumulatorBlock): AnthropicSSEEvent { + return { + event: 'content_block_start', + data: { + type: 'content_block_start', + index: block.index, + content_block: { + type: block.type, + [block.type]: '', + }, + }, + }; + } + + /** + * Create thinking_delta event + */ + createThinkingDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent { + return { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: block.index, + delta: { + type: 'thinking_delta', + thinking: delta, + }, + }, + }; + } + + /** + * Create text_delta event + */ + createTextDeltaEvent(block: AccumulatorBlock, delta: string): AnthropicSSEEvent { + return { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: block.index, + delta: { + type: 'text_delta', + text: delta, + }, + }, + }; + } + + /** + * Create thinking signature delta event + */ + createSignatureDeltaEvent(block: AccumulatorBlock): AnthropicSSEEvent | null { + // FIX: Guard against empty content (signature timing race) + if (!block.content || block.content.length === 0) { + if (this.verbose) { + console.error( + `[ResponseBuilder] WARNING: Skipping signature for empty thinking block ${block.index}` + ); + console.error( + `This indicates a race condition - signature requested before content accumulated` + ); + } + return null; + } + + const signature = this.generateThinkingSignature(block.content); + + if (this.verbose) { + console.error( + `[ResponseBuilder] Generating signature for block ${block.index}: ${block.content.length} chars` + ); + } + + return { + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: block.index, + delta: { + type: 'thinking_signature_delta', + signature: signature, + }, + }, + }; + } + + /** + * Create content_block_stop event + */ + createContentBlockStopEvent(block: AccumulatorBlock): AnthropicSSEEvent { + return { + event: 'content_block_stop', + data: { + type: 'content_block_stop', + index: block.index, + }, + }; + } + + /** + * Generate finalization events (message_delta + message_stop) + */ + createFinalizationEvents(accumulator: DeltaAccumulator, stopReason: string): AnthropicSSEEvent[] { + return [ + { + event: 'message_delta', + data: { + type: 'message_delta', + delta: { + stop_reason: stopReason, + }, + usage: { + input_tokens: accumulator.getInputTokens(), + output_tokens: accumulator.getOutputTokens(), + }, + }, + }, + { + event: 'message_stop', + data: { + type: 'message_stop', + }, + }, + ]; + } + + /** + * Generate thinking signature for Claude Code UI + */ + generateThinkingSignature(thinking: string): ThinkingSignature { + // Generate signature hash + const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16); + + return { + type: 'thinking_signature', + hash: hash, + length: thinking.length, + timestamp: Date.now(), + }; + } + + /** + * Map OpenAI stop reason to Anthropic stop reason + */ + mapStopReason(openaiReason: string): string { + const mapping: Record = { + stop: 'end_turn', + length: 'max_tokens', + tool_calls: 'tool_use', + content_filter: 'stop_sequence', + }; + return mapping[openaiReason] || 'end_turn'; + } +} diff --git a/src/glmt/pipeline/stream-parser.ts b/src/glmt/pipeline/stream-parser.ts new file mode 100644 index 00000000..c0b6fc71 --- /dev/null +++ b/src/glmt/pipeline/stream-parser.ts @@ -0,0 +1,308 @@ +/** + * StreamParser - Transform OpenAI streaming deltas to Anthropic SSE events + * + * Responsibilities: + * - Process streaming deltas (reasoning_content, content, tool_calls) + * - Coordinate with accumulator for state tracking + * - Detect and handle planning loops + * - Generate appropriate Anthropic SSE events + */ + +import type { DeltaAccumulator } from '../delta-accumulator'; +import type { SSEEvent, AnthropicSSEEvent, AccumulatorBlock } from './types'; +import { ResponseBuilder } from './response-builder'; +import { ToolCallHandler } from './tool-call-handler'; + +export interface StreamParserConfig { + verbose?: boolean; + debugMode?: boolean; + debugLog?: boolean; + writeDebugLog?: (type: string, data: unknown) => void; +} + +export class StreamParser { + private verbose: boolean; + private debugMode: boolean; + private debugLog: boolean; + private responseBuilder: ResponseBuilder; + private toolCallHandler: ToolCallHandler; + private writeDebugLog: (type: string, data: unknown) => void; + + constructor(config: StreamParserConfig = {}) { + this.verbose = config.verbose || false; + this.debugMode = config.debugMode || false; + this.debugLog = config.debugLog || false; + this.responseBuilder = new ResponseBuilder(this.verbose); + this.toolCallHandler = new ToolCallHandler(); + this.writeDebugLog = config.writeDebugLog || (() => {}); + } + + /** + * Transform OpenAI streaming delta to Anthropic events + */ + transformDelta(openaiEvent: SSEEvent, accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + + // Debug logging for streaming deltas + if (this.debugLog && openaiEvent.data) { + this.writeDebugLog('delta-openai', openaiEvent.data); + } + + // Handle [DONE] marker + if (openaiEvent.event === 'done') { + if (!accumulator.isFinalized()) { + return this.finalizeDelta(accumulator); + } + return []; // Already finalized + } + + // Usage update (appears in final chunk, may be before choice data) + if (openaiEvent.data?.usage) { + accumulator.updateUsage(openaiEvent.data.usage); + + // If we have both usage AND finish_reason, finalize immediately + if (accumulator.getFinishReason()) { + events.push(...this.finalizeDelta(accumulator)); + return events; + } + } + + const choice = openaiEvent.data?.choices?.[0]; + if (!choice) return events; + + const delta = choice.delta; + if (!delta) return events; + + // Message start + if (!accumulator.isMessageStarted()) { + if (openaiEvent.data?.model) { + accumulator.setModel(openaiEvent.data.model); + } + events.push(this.responseBuilder.createMessageStartEvent(accumulator)); + accumulator.setMessageStarted(true); + } + + // Role + if (delta.role) { + accumulator.setRole(delta.role); + } + + // Reasoning content delta + if (delta.reasoning_content) { + events.push(...this.handleReasoningDelta(delta.reasoning_content, accumulator)); + } + + // Text content delta + if (delta.content) { + events.push(...this.handleContentDelta(delta.content, accumulator)); + } + + // Check for planning loop + if (accumulator.checkForLoop()) { + this.log( + 'WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls' + ); + this.log('Forcing early finalization to prevent unbounded planning'); + events.push(...this.forceFinalization(accumulator)); + return events; + } + + // Tool calls deltas + if (delta.tool_calls && delta.tool_calls.length > 0) { + events.push(...this.handleToolCallDeltas(delta.tool_calls, accumulator)); + } + + // Finish reason + if (choice.finish_reason) { + accumulator.setFinishReason(choice.finish_reason); + + // If we have both finish_reason AND usage, finalize immediately + if (accumulator.hasUsageReceived()) { + events.push(...this.finalizeDelta(accumulator)); + } + } + + // Debug logging for generated events + if (this.debugLog && events.length > 0) { + this.writeDebugLog('delta-anthropic-events', { + events, + accumulator: accumulator.getSummary(), + }); + } + + return events; + } + + /** + * Handle reasoning content delta + */ + private handleReasoningDelta( + reasoningContent: string, + accumulator: DeltaAccumulator + ): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + const currentBlock = accumulator.getCurrentBlock(); + + if (this.debugMode) { + console.error(`[StreamParser] Reasoning delta: ${reasoningContent.length} chars`); + console.error( + `[StreamParser] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}` + ); + } + + if (!currentBlock || currentBlock.type !== 'thinking') { + // Start thinking block + const block = accumulator.startBlock('thinking'); + events.push(this.responseBuilder.createContentBlockStartEvent(block)); + + if (this.debugMode) { + console.error(`[StreamParser] Started new thinking block ${block.index}`); + } + } + + accumulator.addDelta(reasoningContent); + const currentThinkingBlock = accumulator.getCurrentBlock(); + if (currentThinkingBlock) { + events.push( + this.responseBuilder.createThinkingDeltaEvent(currentThinkingBlock, reasoningContent) + ); + } + + return events; + } + + /** + * Handle content delta + */ + private handleContentDelta(content: string, accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + const currentBlock = accumulator.getCurrentBlock(); + + // Close thinking block if transitioning from thinking to text + if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) { + events.push(...this.closeThinkingBlock(currentBlock, accumulator)); + } + + if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock()?.type !== 'text') { + // Start text block + const block = accumulator.startBlock('text'); + events.push(this.responseBuilder.createContentBlockStartEvent(block)); + } + + accumulator.addDelta(content); + const currentTextBlock = accumulator.getCurrentBlock(); + if (currentTextBlock) { + events.push(this.responseBuilder.createTextDeltaEvent(currentTextBlock, content)); + } + + return events; + } + + /** + * Handle tool call deltas + */ + private handleToolCallDeltas( + toolCallDeltas: import('./types').OpenAIToolCallDelta[], + accumulator: DeltaAccumulator + ): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + + if (!toolCallDeltas) return events; + + // Close current content block ONCE before processing any tool calls + const currentBlock = accumulator.getCurrentBlock(); + if (currentBlock && !currentBlock.stopped) { + if (currentBlock.type === 'thinking') { + events.push(...this.closeThinkingBlock(currentBlock, accumulator)); + } else { + events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock)); + accumulator.stopCurrentBlock(); + } + } + + // Process tool call deltas + events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator)); + + return events; + } + + /** + * Close thinking block with signature + */ + private closeThinkingBlock( + block: AccumulatorBlock, + accumulator: DeltaAccumulator + ): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + + const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block); + if (signatureEvent) { + events.push(signatureEvent); + } + events.push(this.responseBuilder.createContentBlockStopEvent(block)); + accumulator.stopCurrentBlock(); + + return events; + } + + /** + * Force finalization due to loop detection + */ + private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + + // Close current block if any + const currentBlock = accumulator.getCurrentBlock(); + if (currentBlock && !currentBlock.stopped) { + if (currentBlock.type === 'thinking') { + events.push(...this.closeThinkingBlock(currentBlock, accumulator)); + } else { + events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock)); + accumulator.stopCurrentBlock(); + } + } + + // Force finalization + events.push(...this.finalizeDelta(accumulator)); + return events; + } + + /** + * Finalize streaming and generate closing events + */ + finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] { + if (accumulator.isFinalized()) { + return []; // Already finalized + } + + const events: AnthropicSSEEvent[] = []; + + // Close current content block if any + const currentBlock = accumulator.getCurrentBlock(); + if (currentBlock && !currentBlock.stopped) { + if (currentBlock.type === 'thinking') { + events.push(...this.closeThinkingBlock(currentBlock, accumulator)); + } else { + events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock)); + accumulator.stopCurrentBlock(); + } + } + + // Message delta (stop reason + usage) and message stop + const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop'); + events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason)); + + accumulator.setFinalized(true); + return events; + } + + /** + * Log message if verbose + */ + private log(message: string): void { + if (this.verbose) { + const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS + console.error(`[StreamParser] [${timestamp}] ${message}`); + } + } +} diff --git a/src/glmt/pipeline/tool-call-handler.ts b/src/glmt/pipeline/tool-call-handler.ts new file mode 100644 index 00000000..ffe0d670 --- /dev/null +++ b/src/glmt/pipeline/tool-call-handler.ts @@ -0,0 +1,97 @@ +/** + * ToolCallHandler - Handle tool call processing for streaming responses + * + * Responsibilities: + * - Process tool call deltas from OpenAI + * - Generate tool_use content blocks for Anthropic format + * - Handle input_json_delta events + */ + +import type { DeltaAccumulator } from '../delta-accumulator'; +import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types'; + +export class ToolCallHandler { + /** + * Process tool calls from non-streaming response + */ + processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] { + const content: ContentBlock[] = []; + + for (const toolCall of toolCalls) { + let parsedInput: Record; + try { + parsedInput = JSON.parse(toolCall.function.arguments || '{}'); + } catch (parseError) { + const err = parseError as Error; + console.error(`[ToolCallHandler] Invalid JSON in tool arguments: ${err.message}`); + parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments }; + } + + content.push({ + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parsedInput, + }); + } + + return content; + } + + /** + * Process tool call deltas during streaming + * Returns events for tool use blocks and input_json deltas + */ + processToolCallDeltas( + toolCallDeltas: OpenAIToolCallDelta[], + accumulator: DeltaAccumulator + ): AnthropicSSEEvent[] { + const events: AnthropicSSEEvent[] = []; + + for (const toolCallDelta of toolCallDeltas) { + // Track tool call state + const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index); + accumulator.addToolCallDelta(toolCallDelta); + + // Emit tool use events (start + input_json deltas) + if (isNewToolCall) { + // Start new tool_use block in accumulator + const block = accumulator.startBlock('tool_use'); + const toolCall = accumulator.getToolCall(toolCallDelta.index); + + events.push({ + event: 'content_block_start', + data: { + type: 'content_block_start', + index: block.index, + content_block: { + type: 'tool_use', + id: toolCall?.id || `tool_${toolCallDelta.index}`, + name: toolCall?.function?.name || '', + }, + }, + }); + } + + // Emit input_json delta if arguments present + if (toolCallDelta.function?.arguments) { + const currentToolBlock = accumulator.getCurrentBlock(); + if (currentToolBlock && currentToolBlock.type === 'tool_use') { + events.push({ + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index: currentToolBlock.index, + delta: { + type: 'input_json_delta', + partial_json: toolCallDelta.function.arguments, + }, + }, + }); + } + } + } + + return events; + } +} diff --git a/src/glmt/pipeline/types.ts b/src/glmt/pipeline/types.ts new file mode 100644 index 00000000..27f68749 --- /dev/null +++ b/src/glmt/pipeline/types.ts @@ -0,0 +1,185 @@ +/** + * Pipeline Types - Shared types for GLMT transformation pipeline + */ + +// Content block types +export interface ContentBlock { + type: string; + text?: string; + thinking?: string; + signature?: ThinkingSignature; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: string | unknown; +} + +export interface Message { + role: string; + content: string | ContentBlock[]; +} + +// Tool types +export interface AnthropicTool { + name: string; + description: string; + input_schema: Record; +} + +export interface OpenAITool { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface OpenAIToolCall { + id: string; + type: string; + function: { + name: string; + arguments: string; + }; +} + +export interface OpenAIToolCallDelta { + index: number; + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +} + +// Request/Response types +export interface AnthropicRequest { + model: string; + messages: Message[]; + max_tokens?: number; + temperature?: number; + top_p?: number; + tools?: AnthropicTool[]; + stream?: boolean; + thinking?: { + type: 'enabled' | 'disabled'; + budget_tokens?: number; + }; +} + +export interface OpenAIRequest { + model: string; + messages: Message[]; + max_tokens?: number; + temperature?: number; + top_p?: number; + tools?: OpenAITool[]; + tool_choice?: string; + stream?: boolean; + do_sample?: boolean; + reasoning?: boolean; + reasoning_effort?: string; +} + +export interface ThinkingConfig { + thinking: boolean; + effort: string; +} + +export interface TransformResult { + openaiRequest: OpenAIRequest | AnthropicRequest; + thinkingConfig: ThinkingConfig; + error?: string; +} + +export interface ThinkingSignature { + type: string; + hash: string; + length: number; + timestamp: number; +} + +// OpenAI response types +export interface OpenAIChoice { + message: { + role?: string; + content?: string | null; + reasoning_content?: string; + tool_calls?: OpenAIToolCall[]; + }; + delta?: { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: OpenAIToolCallDelta[]; + }; + finish_reason?: string; +} + +export interface OpenAIResponse { + id?: string; + model?: string; + choices?: OpenAIChoice[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + }; +} + +export interface AnthropicResponse { + id: string; + type: string; + role: string; + content: ContentBlock[]; + model: string; + stop_reason: string; + usage: { + input_tokens: number; + output_tokens: number; + }; +} + +// SSE event types +export interface SSEEvent { + event?: string; + data?: OpenAIResponse & { + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + }; + }; +} + +export interface AnthropicSSEEvent { + event: string; + data: Record; +} + +// Accumulator block type +export interface AccumulatorBlock { + index: number; + type: string; + content: string; + stopped: boolean; +} + +// Validation result +export interface ValidationResult { + checks: Record; + passed: number; + total: number; + valid: boolean; +} + +// Config types +export interface GlmtTransformerConfig { + defaultThinking?: boolean; + verbose?: boolean; + debugLog?: boolean; + debugMode?: boolean; + debugLogDir?: string; + explicitReasoning?: boolean; +} diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts new file mode 100644 index 00000000..2ad57d1d --- /dev/null +++ b/src/management/checks/cliproxy-check.ts @@ -0,0 +1,199 @@ +/** + * CLIProxy Health Checks - Binary, config, auth, and port status + */ + +import * as fs from 'fs'; +import { ok, warn, info } from '../../utils/ui'; +import { + isCLIProxyInstalled, + getCLIProxyPath, + getAllAuthStatus, + getConfigPath, + getInstalledCliproxyVersion, + CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check CLIProxy binary installation + */ +export class CLIProxyBinaryChecker implements IHealthChecker { + name = 'CLIProxy Binary'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CLIProxy binary').start(); + + if (isCLIProxyInstalled()) { + const binaryPath = getCLIProxyPath(); + const installedVersion = getInstalledCliproxyVersion(); + spinner.succeed(); + console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`); + results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { + status: 'OK', + info: `v${installedVersion} (${binaryPath})`, + }); + } else { + spinner.info(); + console.log( + ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)` + ); + results.addCheck( + 'CLIProxy Binary', + 'success', + 'Not installed yet', + 'Run: ccs gemini "test" (will download automatically)', + { status: 'OK', info: 'Not installed (downloads on first use)' } + ); + } + } +} + +/** + * Check CLIProxy config file + */ +export class CLIProxyConfigChecker implements IHealthChecker { + name = 'CLIProxy Config'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CLIProxy config').start(); + const configPath = getConfigPath(); + + if (fs.existsSync(configPath)) { + // Check if config needs regeneration (version mismatch or missing features) + if (configNeedsRegeneration()) { + spinner.warn(); + console.log( + ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` + ); + + // Regenerate config with new features + regenerateConfig(); + + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}` + ); + results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, + }); + } else { + spinner.succeed(); + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})` + ); + results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, + }); + } + } else { + spinner.info(); + console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); + results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { + status: 'OK', + info: 'Generated on first use', + }); + } + } +} + +/** + * Check OAuth status for all providers + */ +export class CLIProxyAuthChecker implements IHealthChecker { + name = 'CLIProxy Auth'; + + run(results: HealthCheck): void { + const authStatuses = getAllAuthStatus(); + for (const status of authStatuses) { + const spinner = ora(`Checking ${status.provider} auth`).start(); + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; + spinner.succeed(); + console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`); + results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { + status: 'OK', + info: `Authenticated${lastAuth}`, + }); + } else { + spinner.info(); + console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`); + results.addCheck( + `${providerName} Auth`, + 'success', + 'Not authenticated', + `Run: ccs ${status.provider} --auth`, + { status: 'OK', info: 'Not authenticated (run ccs to login)' } + ); + } + } + } +} + +/** + * Check CLIProxy port status + */ +export class CLIProxyPortChecker implements IHealthChecker { + name = 'CLIProxy Port'; + + async run(results: HealthCheck): Promise { + const spinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + // Port is free + spinner.info(); + console.log( + ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)` + ); + results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `Port ${CLIPROXY_DEFAULT_PORT} free`, + }); + } else if (isCLIProxyProcess(portProcess)) { + // CLIProxy is running (expected) + spinner.succeed(); + console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`); + results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `CLIProxy running (PID ${portProcess.pid})`, + }); + } else { + // Port conflict - different process + spinner.warn(); + console.log( + ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}` + ); + results.addCheck( + 'CLIProxy Port', + 'warning', + `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`, + `Kill process: kill ${portProcess.pid} (or restart conflicting application)`, + { status: 'WARN', info: `Occupied by ${portProcess.processName}` } + ); + } + } +} + +/** + * Run all CLIProxy checks + */ +export async function runCLIProxyChecks(results: HealthCheck): Promise { + const binaryChecker = new CLIProxyBinaryChecker(); + const configChecker = new CLIProxyConfigChecker(); + const authChecker = new CLIProxyAuthChecker(); + const portChecker = new CLIProxyPortChecker(); + + binaryChecker.run(results); + configChecker.run(results); + authChecker.run(results); + await portChecker.run(results); +} diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts new file mode 100644 index 00000000..4a643814 --- /dev/null +++ b/src/management/checks/config-check.ts @@ -0,0 +1,168 @@ +/** + * Configuration Health Checks - Config files and Claude settings + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check CCS config files exist and are valid JSON + */ +export class ConfigFilesChecker implements IHealthChecker { + name = 'Config Files'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const files = [ + { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, + { + path: path.join(this.ccsDir, 'glm.settings.json'), + name: 'glm.settings.json', + key: 'GLM Settings', + profile: 'glm', + }, + { + path: path.join(this.ccsDir, 'kimi.settings.json'), + name: 'kimi.settings.json', + key: 'Kimi Settings', + profile: 'kimi', + }, + ]; + + const { DelegationValidator } = require('../../utils/delegation-validator'); + + for (const file of files) { + const spinner = ora(`Checking ${file.name}`).start(); + + if (!fs.existsSync(file.path)) { + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Not found`); + results.addCheck( + file.name, + 'error', + `${file.name} not found`, + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + continue; + } + + // Validate JSON + try { + const content = fs.readFileSync(file.path, 'utf8'); + JSON.parse(content); + + // Extract useful info based on file type + let fileInfo = 'Valid'; + let status: 'OK' | 'WARN' = 'OK'; + + if (file.profile) { + // For settings files, check if API key is configured + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + fileInfo = 'Key configured'; + status = 'OK'; + } else if (validation.error && validation.error.includes('placeholder')) { + fileInfo = 'Placeholder key'; + status = 'WARN'; + } else { + fileInfo = 'Valid JSON'; + status = 'OK'; + } + } + + if (status === 'WARN') { + spinner.warn(); + console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`); + } else { + spinner.succeed(); + console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`); + } + + results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', undefined, undefined, { + status: status, + info: fileInfo, + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`); + results.addCheck( + file.name, + 'error', + `Invalid JSON: ${(e as Error).message}`, + `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid JSON' } + ); + } + } + } +} + +/** + * Check Claude settings.json + */ +export class ClaudeSettingsChecker implements IHealthChecker { + name = 'Claude Settings'; + private readonly claudeDir: string; + + constructor() { + this.claudeDir = path.join(os.homedir(), '.claude'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking ~/.claude/settings.json').start(); + const settingsPath = path.join(this.claudeDir, 'settings.json'); + const settingsName = '~/.claude/settings.json'; + + if (!fs.existsSync(settingsPath)) { + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Not found`); + results.addCheck( + 'Claude Settings', + 'warning', + '~/.claude/settings.json not found', + 'Run: claude /login' + ); + return; + } + + // Validate JSON + try { + const content = fs.readFileSync(settingsPath, 'utf8'); + JSON.parse(content); + spinner.succeed(); + console.log(` ${ok(settingsName.padEnd(22))} Valid`); + results.addCheck('Claude Settings', 'success'); + } catch (e) { + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`); + results.addCheck( + 'Claude Settings', + 'warning', + `Invalid JSON: ${(e as Error).message}`, + 'Run: claude /login' + ); + } + } +} + +/** + * Run all config checks + */ +export function runConfigChecks(results: HealthCheck): void { + const configChecker = new ConfigFilesChecker(); + const claudeChecker = new ClaudeSettingsChecker(); + + configChecker.run(results); + claudeChecker.run(results); +} diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts new file mode 100644 index 00000000..c586b07a --- /dev/null +++ b/src/management/checks/env-check.ts @@ -0,0 +1,76 @@ +/** + * Environment Health Check - OAuth readiness diagnostics + */ + +import { getEnvironmentDiagnostics } from '../environment-diagnostics'; +import { ok, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check environment for OAuth readiness + * Helps diagnose Windows headless false positives + */ +export class EnvironmentChecker implements IHealthChecker { + name = 'Environment'; + + run(results: HealthCheck): void { + const spinner = ora('Checking environment').start(); + const diag = getEnvironmentDiagnostics(); + + // Determine overall environment health + let envStatus: 'OK' | 'WARN' = 'OK'; + let envMessage = 'Browser available'; + + // Check for potential issues + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + // Windows false positive - this is actually a warning + envStatus = 'WARN'; + envMessage = 'Headless detected (may be false positive on Windows)'; + } else if (diag.sshSession) { + envMessage = 'SSH session (headless mode)'; + } else { + envMessage = 'Headless environment'; + } + } + + if (envStatus === 'WARN') { + spinner.warn(); + console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); + } else { + spinner.succeed(); + console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); + } + + // Show key environment details + console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); + if (diag.sshSession) { + console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); + } + if (diag.ttyStatus === 'undefined') { + console.log(` ${''.padEnd(24)} TTY: undefined [!]`); + } + console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + + results.addCheck( + 'Environment', + envStatus === 'OK' ? 'success' : 'warning', + envMessage, + envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, + { + status: envStatus, + info: envMessage, + } + ); + } +} + +/** + * Run environment check + */ +export function runEnvironmentCheck(results: HealthCheck): void { + const checker = new EnvironmentChecker(); + checker.run(results); +} diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts new file mode 100644 index 00000000..1ea17e5f --- /dev/null +++ b/src/management/checks/index.ts @@ -0,0 +1,51 @@ +/** + * Health Check Registry - Central export for all checks + */ + +// Types and utilities +export { + HealthCheck, + HealthCheckDetails, + HealthCheckItem, + HealthIssue, + IHealthChecker, + Spinner, + createSpinner, +} from './types'; + +// System checks +export { ClaudeCliChecker, CcsDirectoryChecker, runSystemChecks } from './system-check'; + +// Environment checks +export { EnvironmentChecker, runEnvironmentCheck } from './env-check'; + +// Config checks +export { ConfigFilesChecker, ClaudeSettingsChecker, runConfigChecks } from './config-check'; + +// Profile checks +export { + ProfilesChecker, + InstancesChecker, + DelegationChecker, + runProfileChecks, +} from './profile-check'; + +// Symlink checks +export { + PermissionsChecker, + CcsSymlinksChecker, + SettingsSymlinksChecker, + runSymlinkChecks, +} from './symlink-check'; + +// CLIProxy checks +export { + CLIProxyBinaryChecker, + CLIProxyConfigChecker, + CLIProxyAuthChecker, + CLIProxyPortChecker, + runCLIProxyChecks, +} from './cliproxy-check'; + +// OAuth checks +export { OAuthPortsChecker, runOAuthChecks } from './oauth-check'; diff --git a/src/management/checks/oauth-check.ts b/src/management/checks/oauth-check.ts new file mode 100644 index 00000000..05ad54bf --- /dev/null +++ b/src/management/checks/oauth-check.ts @@ -0,0 +1,76 @@ +/** + * OAuth Port Health Checks - Pre-flight check for OAuth authentication + */ + +import { ok, warn, info } from '../../utils/ui'; +import { checkAuthCodePorts } from '../oauth-port-diagnostics'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check OAuth callback ports availability + */ +export class OAuthPortsChecker implements IHealthChecker { + name = 'OAuth Ports'; + + async run(results: HealthCheck): Promise { + const spinner = ora('Checking OAuth callback ports').start(); + const portDiagnostics = await checkAuthCodePorts(); + + // Count issues + const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); + + if (conflicts.length === 0) { + spinner.succeed(); + console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); + results.addCheck('OAuth Ports', 'success', undefined, undefined, { + status: 'OK', + info: 'All callback ports available', + }); + } else { + spinner.warn(); + console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); + results.addCheck( + 'OAuth Ports', + 'warning', + `${conflicts.length} port conflict(s)`, + 'Close conflicting applications before OAuth', + { status: 'WARN', info: `${conflicts.length} conflict(s)` } + ); + } + + // Show individual port status + for (const diag of portDiagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + case 'cliproxy': + statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); + break; + case 'occupied': + statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); + break; + default: + statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); + } + + console.log(` ${statusIcon} ${diag.message}`); + + if (diag.recommendation && diag.status === 'occupied') { + console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); + } + } + } +} + +/** + * Run OAuth port checks + */ +export async function runOAuthChecks(results: HealthCheck): Promise { + const checker = new OAuthPortsChecker(); + await checker.run(results); +} diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts new file mode 100644 index 00000000..cc449042 --- /dev/null +++ b/src/management/checks/profile-check.ts @@ -0,0 +1,190 @@ +/** + * Profile and Delegation Health Checks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn, info } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check profile configurations in config.json + */ +export class ProfilesChecker implements IHealthChecker { + name = 'Profiles'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking profiles').start(); + const configPath = path.join(this.ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + spinner.info(); + console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); + return; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); + results.addCheck( + 'Profiles', + 'error', + 'config.json missing profiles object', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Missing profiles object' } + ); + return; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + spinner.succeed(); + console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); + results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { + status: 'OK', + info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); + results.addCheck('Profiles', 'error', (e as Error).message, undefined, { + status: 'ERROR', + info: (e as Error).message, + }); + } + } +} + +/** + * Check instance directories (account-based profiles) + */ +export class InstancesChecker implements IHealthChecker { + name = 'Instances'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking instances').start(); + const instancesDir = path.join(this.ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); + results.addCheck('Instances', 'success', 'No account profiles configured'); + return; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); + results.addCheck('Instances', 'success', 'No account profiles'); + return; + } + + spinner.succeed(); + console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`); + results.addCheck('Instances', 'success', `${instances.length} account profiles`); + } +} + +/** + * Check delegation system (commands and ready profiles) + */ +export class DelegationChecker implements IHealthChecker { + name = 'Delegation'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking delegation').start(); + + // Check if delegation commands exist in ~/.ccs/.claude/commands/ + const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands'); + const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); + const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); + + if (!hasCcsCommand || !hasContinueCommand) { + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} Not installed`); + results.addCheck( + 'Delegation', + 'warning', + 'Delegation commands not found', + 'Install with: npm install -g @kaitranntt/ccs --force', + { status: 'WARN', info: 'Not installed' } + ); + return; + } + + // Check profile validity using DelegationValidator + const { DelegationValidator } = require('../../utils/delegation-validator'); + const readyProfiles: string[] = []; + + for (const profile of ['glm', 'kimi']) { + const validation = DelegationValidator.validate(profile); + if (validation.valid) { + readyProfiles.push(profile); + } + } + + if (readyProfiles.length === 0) { + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`); + results.addCheck( + 'Delegation', + 'warning', + 'Delegation installed but no profiles configured', + 'Configure profiles with valid API keys (not placeholders)', + { status: 'WARN', info: 'No profiles ready' } + ); + return; + } + + spinner.succeed(); + console.log( + ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` + ); + results.addCheck( + 'Delegation', + 'success', + `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`, + undefined, + { status: 'OK', info: `${readyProfiles.length} profiles ready` } + ); + } +} + +/** + * Run all profile checks + */ +export function runProfileChecks(results: HealthCheck): void { + const profilesChecker = new ProfilesChecker(); + const instancesChecker = new InstancesChecker(); + const delegationChecker = new DelegationChecker(); + + profilesChecker.run(results); + instancesChecker.run(results); + delegationChecker.run(results); +} diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts new file mode 100644 index 00000000..0b943295 --- /dev/null +++ b/src/management/checks/symlink-check.ts @@ -0,0 +1,218 @@ +/** + * Symlink and Permission Health Checks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); +const homedir = os.homedir(); +const ccsDir = path.join(homedir, '.ccs'); +const claudeDir = path.join(homedir, '.claude'); + +/** + * Check file permissions on ~/.ccs/ + */ +export class PermissionsChecker implements IHealthChecker { + name = 'Permissions'; + + run(results: HealthCheck): void { + const spinner = ora('Checking permissions').start(); + const testFile = path.join(ccsDir, '.permission-test'); + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + spinner.succeed(); + console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`); + results.addCheck('Permissions', 'success', undefined, undefined, { + status: 'OK', + info: 'Write access verified', + }); + } catch (_e) { + spinner.fail(); + console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`); + results.addCheck( + 'Permissions', + 'error', + 'Cannot write to ~/.ccs/', + 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + { status: 'ERROR', info: 'Cannot write to ~/.ccs/' } + ); + } + } +} + +/** + * Check CCS symlinks to ~/.claude/ + */ +export class CcsSymlinksChecker implements IHealthChecker { + name = 'CCS Symlinks'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CCS symlinks').start(); + + try { + const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const cnt = manager.ccsItems.length; + spinner.succeed(); + console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${cnt}/${cnt} items linked`); + results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', undefined, { + status: 'OK', + info: `${cnt}/${cnt} items synced`, + }); + } else { + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`); + results.addCheck('CCS Symlinks', 'warning', health.issues.join(', '), 'Run: ccs sync', { + status: 'WARN', + info: `${health.issues.length} issues`, + }); + } + } catch (e) { + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`); + results.addCheck( + 'CCS Symlinks', + 'warning', + 'Could not check CCS symlinks: ' + (e as Error).message, + 'Run: ccs sync', + { status: 'WARN', info: 'Could not check' } + ); + } + } +} + +/** Helper: Check if symlink points to expected target */ +function isValidSymlink(symlinkPath: string, expectedTarget: string): boolean { + if (!fs.existsSync(symlinkPath)) return false; + try { + const stats = fs.lstatSync(symlinkPath); + if (!stats.isSymbolicLink()) return false; + const target = fs.readlinkSync(symlinkPath); + const resolved = path.resolve(path.dirname(symlinkPath), target); + return resolved === expectedTarget; + } catch { + return false; + } +} + +/** + * Check settings.json symlinks for instances + */ +export class SettingsSymlinksChecker implements IHealthChecker { + name = 'Settings Symlinks'; + + run(results: HealthCheck): void { + const spinner = ora('Checking settings.json symlinks').start(); + const label = 'settings.json'; + const sharedSettings = path.join(ccsDir, 'shared', 'settings.json'); + const claudeSettings = path.join(claudeDir, 'settings.json'); + + try { + // Check shared settings symlink + if (!fs.existsSync(sharedSettings)) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Not found (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json not found', + 'Run: ccs sync' + ); + return; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Not a symlink (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json is not a symlink', + 'Run: ccs sync' + ); + return; + } + + if (!isValidSymlink(sharedSettings, claudeSettings)) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Wrong target (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared symlink points to wrong target', + 'Run: ccs sync' + ); + return; + } + + // Check instances + const instancesDir = path.join(ccsDir, 'instances'); + if (!fs.existsSync(instancesDir)) { + spinner.succeed(); + console.log(` ${ok(label.padEnd(22))} Shared symlink valid`); + results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, { + status: 'OK', + info: 'Shared symlink valid', + }); + return; + } + + const instances = fs + .readdirSync(instancesDir) + .filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory()); + + const broken = instances.filter((inst) => { + const instSettings = path.join(instancesDir, inst, 'settings.json'); + return !isValidSymlink(instSettings, sharedSettings); + }).length; + + if (broken > 0) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} ${broken} broken instance(s)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + `${broken} instance(s) have broken symlinks`, + 'Run: ccs sync', + { status: 'WARN', info: `${broken} broken instance(s)` } + ); + } else { + spinner.succeed(); + console.log(` ${ok(label.padEnd(22))} ${instances.length} instance(s) valid`); + results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', undefined, { + status: 'OK', + info: `${instances.length} instance(s) valid`, + }); + } + } catch (err) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Check failed`); + results.addCheck( + 'Settings Symlinks', + 'warning', + `Failed to check: ${(err as Error).message}`, + 'Run: ccs sync', + { status: 'WARN', info: 'Check failed' } + ); + } + } +} + +/** + * Run all symlink checks + */ +export function runSymlinkChecks(results: HealthCheck): void { + new PermissionsChecker().run(results); + new CcsSymlinksChecker().run(results); + new SettingsSymlinksChecker().run(results); +} diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts new file mode 100644 index 00000000..65b1b39e --- /dev/null +++ b/src/management/checks/system-check.ts @@ -0,0 +1,138 @@ +/** + * System Health Checks - Claude CLI and CCS Directory + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawn } from 'child_process'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import { escapeShellArg } from '../../utils/shell-executor'; +import { ok, fail } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check Claude CLI availability and version + */ +export class ClaudeCliChecker implements IHealthChecker { + name = 'Claude CLI'; + + async run(results: HealthCheck): Promise { + const spinner = ora('Checking Claude CLI').start(); + + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`); + results.addCheck( + 'Claude CLI', + 'error', + 'Claude CLI not found in PATH', + 'Install from: https://docs.claude.com/en/docs/claude-code/installation', + { status: 'ERROR', info: 'Not installed' } + ); + return; + } + + const { path: claudeCli, needsShell } = cliInfo; + + // Try to execute claude --version + try { + const result = await new Promise((resolve, reject) => { + // When shell is needed (Windows .cmd/.bat files), concatenate into string + // to avoid DEP0190 warning about passing args with shell: true + const child = needsShell + ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), { + stdio: 'pipe', + timeout: 5000, + shell: true, + }) + : spawn(claudeCli, ['--version'], { + stdio: 'pipe', + timeout: 5000, + }); + + let output = ''; + child.stdout?.on('data', (data: Buffer) => (output += data)); + child.stderr?.on('data', (data: Buffer) => (output += data)); + + child.on('close', (code: number | null) => { + if (code === 0) resolve(output); + else reject(new Error('Exit code ' + code)); + }); + + child.on('error', reject); + }); + + // Extract version from output + const versionMatch = result.match(/(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + spinner.succeed(); + console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`); + results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, { + status: 'OK', + info: `v${version} (${claudeCli})`, + }); + } catch (_err) { + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`); + results.addCheck( + 'Claude CLI', + 'error', + 'Claude CLI not found or not working', + 'Install from: https://docs.claude.com/en/docs/claude-code/installation', + { status: 'ERROR', info: 'Not installed' } + ); + } + } +} + +/** + * Check ~/.ccs/ directory exists + */ +export class CcsDirectoryChecker implements IHealthChecker { + name = 'CCS Directory'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking ~/.ccs/ directory').start(); + + if (fs.existsSync(this.ccsDir)) { + spinner.succeed(); + console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`); + results.addCheck('CCS Directory', 'success', undefined, undefined, { + status: 'OK', + info: '~/.ccs/', + }); + } else { + spinner.fail(); + console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`); + results.addCheck( + 'CCS Directory', + 'error', + '~/.ccs/ directory not found', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + } + } +} + +/** + * Run all system checks + */ +export async function runSystemChecks(results: HealthCheck): Promise { + const cliChecker = new ClaudeCliChecker(); + const dirChecker = new CcsDirectoryChecker(); + + await cliChecker.run(results); + dirChecker.run(results); +} diff --git a/src/management/checks/types.ts b/src/management/checks/types.ts new file mode 100644 index 00000000..4485575a --- /dev/null +++ b/src/management/checks/types.ts @@ -0,0 +1,114 @@ +/** + * Health Check Types and Interfaces + */ + +/** + * Spinner interface for ora or fallback + */ +export interface Spinner { + start(): { + succeed(msg?: string): void; + fail(msg?: string): void; + warn(msg?: string): void; + info(msg?: string): void; + text: string; + }; +} + +/** + * Details for individual health check + */ +export interface HealthCheckDetails { + status: 'OK' | 'ERROR' | 'WARN'; + info: string; +} + +/** + * Individual health check item + */ +export interface HealthCheckItem { + name: string; + status: 'success' | 'error' | 'warning'; + message?: string; + fix?: string; +} + +/** + * Health issue (error or warning) + */ +export interface HealthIssue { + name: string; + message: string; + fix?: string; +} + +/** + * Health check results container + */ +export class HealthCheck { + public checks: HealthCheckItem[] = []; + public warnings: HealthIssue[] = []; + public errors: HealthIssue[] = []; + public details: Record = {}; + + addCheck( + name: string, + status: 'success' | 'error' | 'warning', + message = '', + fix: string | undefined = undefined, + details: HealthCheckDetails | undefined = undefined + ): void { + this.checks.push({ name, status, message, fix }); + + if (status === 'error') this.errors.push({ name, message, fix }); + if (status === 'warning') this.warnings.push({ name, message, fix }); + + // Store details for summary table + if (details) { + this.details[name] = details; + } + } + + hasErrors(): boolean { + return this.errors.length > 0; + } + + hasWarnings(): boolean { + return this.warnings.length > 0; + } + + isHealthy(): boolean { + return !this.hasErrors(); + } +} + +/** + * Base interface for all health checks + */ +export interface IHealthChecker { + name: string; + run(results: HealthCheck): Promise | void; +} + +/** + * Create ora spinner with fallback for environments where ora is unavailable + */ +export function createSpinner(): (text: string) => Spinner { + try { + const oraModule = require('ora'); + return oraModule.default || oraModule; + } catch (_e) { + // ora not available, create fallback spinner that uses console.log + return function (text: string): Spinner { + return { + start: () => ({ + succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), + fail: (msg?: string) => console.log(msg || `[X] ${text}`), + warn: (msg?: string) => console.log(msg || `[!] ${text}`), + info: (msg?: string) => console.log(msg || `[i] ${text}`), + text: '', + }), + }; + }; + } +} diff --git a/src/management/doctor.ts b/src/management/doctor.ts index c26c7073..8667e405 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -1,134 +1,29 @@ /** - * CCS Health Check and Diagnostics + * CCS Health Check and Diagnostics - Main Orchestrator */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { spawn } from 'child_process'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { escapeShellArg } from '../utils/shell-executor'; -import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui'; +import { initUI, header, box, table, color, ok, fail, warn } from '../utils/ui'; import packageJson from '../../package.json'; import { - isCLIProxyInstalled, - getCLIProxyPath, - getAllAuthStatus, - getConfigPath, - getInstalledCliproxyVersion, - CLIPROXY_DEFAULT_PORT, - configNeedsRegeneration, - regenerateConfig, - CLIPROXY_CONFIG_VERSION, -} from '../cliproxy'; -import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; -import { getEnvironmentDiagnostics } from './environment-diagnostics'; -import { checkAuthCodePorts } from './oauth-port-diagnostics'; -import { killProcessOnPort, getPlatformName } from '../utils/platform-commands'; - -// Make ora optional (might not be available during npm install postinstall) -// ora v9+ is an ES module, need to use .default for CommonJS -interface Spinner { - start(): { - succeed(msg?: string): void; - fail(msg?: string): void; - warn(msg?: string): void; - info(msg?: string): void; - text: string; - }; -} - -let ora: (text: string) => Spinner; -try { - const oraModule = require('ora'); - ora = oraModule.default || oraModule; -} catch (_e) { - // ora not available, create fallback spinner that uses console.log - ora = function (text: string): Spinner { - return { - start: () => ({ - succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), - fail: (msg?: string) => console.log(msg || `[X] ${text}`), - warn: (msg?: string) => console.log(msg || `[!] ${text}`), - info: (msg?: string) => console.log(msg || `[i] ${text}`), - text: '', - }), - }; - }; -} - -interface HealthCheckDetails { - status: 'OK' | 'ERROR' | 'WARN'; - info: string; -} - -interface HealthCheckItem { - name: string; - status: 'success' | 'error' | 'warning'; - message?: string; - fix?: string; -} - -interface HealthIssue { - name: string; - message: string; - fix?: string; -} + HealthCheck, + runSystemChecks, + runEnvironmentCheck, + runConfigChecks, + runProfileChecks, + runSymlinkChecks, + runCLIProxyChecks, + runOAuthChecks, +} from './checks'; +import { runAutoRepair } from './repair'; /** - * Health check results - */ -class HealthCheck { - public checks: HealthCheckItem[] = []; - public warnings: HealthIssue[] = []; - public errors: HealthIssue[] = []; - public details: Record = {}; - - addCheck( - name: string, - status: 'success' | 'error' | 'warning', - message = '', - fix: string | undefined = undefined, - details: HealthCheckDetails | undefined = undefined - ): void { - this.checks.push({ name, status, message, fix }); - - if (status === 'error') this.errors.push({ name, message, fix }); - if (status === 'warning') this.warnings.push({ name, message, fix }); - - // Store details for summary table - if (details) { - this.details[name] = details; - } - } - - hasErrors(): boolean { - return this.errors.length > 0; - } - - hasWarnings(): boolean { - return this.warnings.length > 0; - } - - isHealthy(): boolean { - return !this.hasErrors(); - } -} - -/** - * Doctor Class + * Doctor Class - Orchestrates health checks */ class Doctor { - private readonly homedir: string; - private readonly ccsDir: string; - private readonly claudeDir: string; private readonly results: HealthCheck; private readonly ccsVersion: string; constructor() { - this.homedir = os.homedir(); - this.ccsDir = path.join(this.homedir, '.ccs'); - this.claudeDir = path.join(this.homedir, '.claude'); this.results = new HealthCheck(); this.ccsVersion = packageJson.version; } @@ -148,887 +43,43 @@ class Doctor { // Group 1: System console.log(header('SYSTEM')); - await this.checkClaudeCli(); - this.checkCcsDirectory(); + await runSystemChecks(this.results); console.log(''); - // Group 2: Environment (NEW - OAuth readiness diagnostics) + // Group 2: Environment (OAuth readiness diagnostics) console.log(header('ENVIRONMENT')); - this.checkEnvironment(); + runEnvironmentCheck(this.results); console.log(''); // Group 3: Configuration console.log(header('CONFIGURATION')); - this.checkConfigFiles(); - this.checkClaudeSettings(); + runConfigChecks(this.results); console.log(''); // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); - this.checkProfiles(); - this.checkInstances(); - this.checkDelegation(); + runProfileChecks(this.results); console.log(''); // Group 5: System Health console.log(header('SYSTEM HEALTH')); - this.checkPermissions(); - this.checkCcsSymlinks(); - this.checkSettingsSymlinks(); + runSymlinkChecks(this.results); console.log(''); // Group 6: CLIProxy (OAuth profiles) console.log(header('CLIPROXY (OAUTH PROFILES)')); - await this.checkCLIProxy(); + await runCLIProxyChecks(this.results); console.log(''); - // Group 7: OAuth Readiness (NEW - port availability) + // Group 7: OAuth Readiness (port availability) console.log(header('OAUTH READINESS')); - await this.checkOAuthPorts(); + await runOAuthChecks(this.results); console.log(''); this.showReport(); return this.results; } - /** - * Check 1: Claude CLI availability - */ - private async checkClaudeCli(): Promise { - const spinner = ora('Checking Claude CLI').start(); - - const cliInfo = getClaudeCliInfo(); - - if (!cliInfo) { - spinner.fail(); - console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`); - this.results.addCheck( - 'Claude CLI', - 'error', - 'Claude CLI not found in PATH', - 'Install from: https://docs.claude.com/en/docs/claude-code/installation', - { status: 'ERROR', info: 'Not installed' } - ); - return; - } - - const { path: claudeCli, needsShell } = cliInfo; - - // Try to execute claude --version - try { - const result = await new Promise((resolve, reject) => { - // When shell is needed (Windows .cmd/.bat files), concatenate into string - // to avoid DEP0190 warning about passing args with shell: true - const child = needsShell - ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), { - stdio: 'pipe', - timeout: 5000, - shell: true, - }) - : spawn(claudeCli, ['--version'], { - stdio: 'pipe', - timeout: 5000, - }); - - let output = ''; - child.stdout?.on('data', (data: Buffer) => (output += data)); - child.stderr?.on('data', (data: Buffer) => (output += data)); - - child.on('close', (code: number | null) => { - if (code === 0) resolve(output); - else reject(new Error('Exit code ' + code)); - }); - - child.on('error', reject); - }); - - // Extract version from output - const versionMatch = result.match(/(\d+\.\d+\.\d+)/); - const version = versionMatch ? versionMatch[1] : 'unknown'; - - spinner.succeed(); - console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`); - this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, { - status: 'OK', - info: `v${version} (${claudeCli})`, - }); - } catch (_err) { - spinner.fail(); - console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`); - this.results.addCheck( - 'Claude CLI', - 'error', - 'Claude CLI not found or not working', - 'Install from: https://docs.claude.com/en/docs/claude-code/installation', - { status: 'ERROR', info: 'Not installed' } - ); - } - } - - /** - * Check 2: ~/.ccs/ directory - */ - private checkCcsDirectory(): void { - const spinner = ora('Checking ~/.ccs/ directory').start(); - - if (fs.existsSync(this.ccsDir)) { - spinner.succeed(); - console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`); - this.results.addCheck('CCS Directory', 'success', undefined, undefined, { - status: 'OK', - info: '~/.ccs/', - }); - } else { - spinner.fail(); - console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`); - this.results.addCheck( - 'CCS Directory', - 'error', - '~/.ccs/ directory not found', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - } - } - - /** - * Check 3: Config files - */ - private checkConfigFiles(): void { - const files = [ - { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, - { - path: path.join(this.ccsDir, 'glm.settings.json'), - name: 'glm.settings.json', - key: 'GLM Settings', - profile: 'glm', - }, - { - path: path.join(this.ccsDir, 'kimi.settings.json'), - name: 'kimi.settings.json', - key: 'Kimi Settings', - profile: 'kimi', - }, - ]; - - const { DelegationValidator } = require('../utils/delegation-validator'); - - for (const file of files) { - const spinner = ora(`Checking ${file.name}`).start(); - - if (!fs.existsSync(file.path)) { - spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Not found`); - this.results.addCheck( - file.name, - 'error', - `${file.name} not found`, - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - continue; - } - - // Validate JSON - try { - const content = fs.readFileSync(file.path, 'utf8'); - JSON.parse(content); - - // Extract useful info based on file type - let fileInfo = 'Valid'; - let status: 'OK' | 'WARN' = 'OK'; - - if (file.profile) { - // For settings files, check if API key is configured - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - fileInfo = 'Key configured'; - status = 'OK'; - } else if (validation.error && validation.error.includes('placeholder')) { - fileInfo = 'Placeholder key'; - status = 'WARN'; - } else { - fileInfo = 'Valid JSON'; - status = 'OK'; - } - } - - if (status === 'WARN') { - spinner.warn(); - console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`); - } else { - spinner.succeed(); - console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`); - } - - this.results.addCheck( - file.name, - status === 'OK' ? 'success' : 'warning', - undefined, - undefined, - { - status: status, - info: fileInfo, - } - ); - } catch (e) { - spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`); - this.results.addCheck( - file.name, - 'error', - `Invalid JSON: ${(e as Error).message}`, - `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, - { status: 'ERROR', info: 'Invalid JSON' } - ); - } - } - } - - /** - * Check 4: Claude settings - */ - private checkClaudeSettings(): void { - const spinner = ora('Checking ~/.claude/settings.json').start(); - const settingsPath = path.join(this.claudeDir, 'settings.json'); - const settingsName = '~/.claude/settings.json'; - - if (!fs.existsSync(settingsPath)) { - spinner.warn(); - console.log(` ${warn(settingsName.padEnd(22))} Not found`); - this.results.addCheck( - 'Claude Settings', - 'warning', - '~/.claude/settings.json not found', - 'Run: claude /login' - ); - return; - } - - // Validate JSON - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - JSON.parse(content); - spinner.succeed(); - console.log(` ${ok(settingsName.padEnd(22))} Valid`); - this.results.addCheck('Claude Settings', 'success'); - } catch (e) { - spinner.warn(); - console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`); - this.results.addCheck( - 'Claude Settings', - 'warning', - `Invalid JSON: ${(e as Error).message}`, - 'Run: claude /login' - ); - } - } - - /** - * Check 5: Profile configurations - */ - private checkProfiles(): void { - const spinner = ora('Checking profiles').start(); - const configPath = path.join(this.ccsDir, 'config.json'); - - if (!fs.existsSync(configPath)) { - spinner.info(); - console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); - return; - } - - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); - this.results.addCheck( - 'Profiles', - 'error', - 'config.json missing profiles object', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Missing profiles object' } - ); - return; - } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - spinner.succeed(); - console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); - this.results.addCheck( - 'Profiles', - 'success', - `${profileCount} profiles configured`, - undefined, - { - status: 'OK', - info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, - } - ); - } catch (e) { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); - this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, { - status: 'ERROR', - info: (e as Error).message, - }); - } - } - - /** - * Check 6: Instance directories (account-based profiles) - */ - private checkInstances(): void { - const spinner = ora('Checking instances').start(); - const instancesDir = path.join(this.ccsDir, 'instances'); - - if (!fs.existsSync(instancesDir)) { - spinner.info(); - console.log(` ${info('Instances'.padEnd(22))} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles configured'); - return; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - if (instances.length === 0) { - spinner.info(); - console.log(` ${info('Instances'.padEnd(22))} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles'); - return; - } - - spinner.succeed(); - console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`); - this.results.addCheck('Instances', 'success', `${instances.length} account profiles`); - } - - /** - * Check 7: Delegation system - */ - private checkDelegation(): void { - const spinner = ora('Checking delegation').start(); - - // Check if delegation commands exist in ~/.ccs/.claude/commands/ - const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands'); - const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); - const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); - - if (!hasCcsCommand || !hasContinueCommand) { - spinner.warn(); - console.log(` ${warn('Delegation'.padEnd(22))} Not installed`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation commands not found', - 'Install with: npm install -g @kaitranntt/ccs --force', - { status: 'WARN', info: 'Not installed' } - ); - return; - } - - // Check profile validity using DelegationValidator - const { DelegationValidator } = require('../utils/delegation-validator'); - const readyProfiles: string[] = []; - - for (const profile of ['glm', 'kimi']) { - const validation = DelegationValidator.validate(profile); - if (validation.valid) { - readyProfiles.push(profile); - } - } - - if (readyProfiles.length === 0) { - spinner.warn(); - console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation installed but no profiles configured', - 'Configure profiles with valid API keys (not placeholders)', - { status: 'WARN', info: 'No profiles ready' } - ); - return; - } - - spinner.succeed(); - console.log( - ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` - ); - this.results.addCheck( - 'Delegation', - 'success', - `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`, - undefined, - { status: 'OK', info: `${readyProfiles.length} profiles ready` } - ); - } - - /** - * Check 8: File permissions - */ - private checkPermissions(): void { - const spinner = ora('Checking permissions').start(); - const testFile = path.join(this.ccsDir, '.permission-test'); - - try { - fs.writeFileSync(testFile, 'test', 'utf8'); - fs.unlinkSync(testFile); - spinner.succeed(); - console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`); - this.results.addCheck('Permissions', 'success', undefined, undefined, { - status: 'OK', - info: 'Write access verified', - }); - } catch (_e) { - spinner.fail(); - console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`); - this.results.addCheck( - 'Permissions', - 'error', - 'Cannot write to ~/.ccs/', - 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', - { status: 'ERROR', info: 'Cannot write to ~/.ccs/' } - ); - } - } - - /** - * Check 9: CCS symlinks to ~/.claude/ - */ - private checkCcsSymlinks(): void { - const spinner = ora('Checking CCS symlinks').start(); - - try { - const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - const health = manager.checkHealth(); - - if (health.healthy) { - const itemCount = manager.ccsItems.length; - spinner.succeed(); - console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${itemCount}/${itemCount} items linked`); - this.results.addCheck( - 'CCS Symlinks', - 'success', - 'All CCS items properly symlinked', - undefined, - { - status: 'OK', - info: `${itemCount}/${itemCount} items synced`, - } - ); - } else { - spinner.warn(); - console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - health.issues.join(', '), - 'Run: ccs sync', - { status: 'WARN', info: `${health.issues.length} issues` } - ); - } - } catch (e) { - spinner.warn(); - console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - 'Could not check CCS symlinks: ' + (e as Error).message, - 'Run: ccs sync', - { status: 'WARN', info: 'Could not check' } - ); - } - } - - /** - * Check 10: settings.json symlinks - */ - private checkSettingsSymlinks(): void { - const spinner = ora('Checking settings.json symlinks').start(); - const settingsLabel = 'settings.json'; - - try { - const sharedDir = path.join(this.homedir, '.ccs', 'shared'); - const sharedSettings = path.join(sharedDir, 'settings.json'); - const claudeSettings = path.join(this.claudeDir, 'settings.json'); - - // Check shared settings exists and points to ~/.claude/ - if (!fs.existsSync(sharedSettings)) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Not found (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json not found', - 'Run: ccs sync' - ); - return; - } - - const sharedStats = fs.lstatSync(sharedSettings); - if (!sharedStats.isSymbolicLink()) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Not a symlink (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json is not a symlink', - 'Run: ccs sync' - ); - return; - } - - const sharedTarget = fs.readlinkSync(sharedSettings); - const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); - - if (resolvedShared !== claudeSettings) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Wrong target (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Points to ${resolvedShared} instead of ${claudeSettings}`, - 'Run: ccs sync' - ); - return; - } - - // Check each instance - const instancesDir = path.join(this.ccsDir, 'instances'); - if (!fs.existsSync(instancesDir)) { - spinner.succeed(); - console.log(` ${ok(settingsLabel.padEnd(22))} Shared symlink valid`); - this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, { - status: 'OK', - info: 'Shared symlink valid', - }); - return; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - let broken = 0; - for (const instance of instances) { - const instancePath = path.join(instancesDir, instance); - const instanceSettings = path.join(instancePath, 'settings.json'); - - if (!fs.existsSync(instanceSettings)) { - broken++; - continue; - } - - try { - const stats = fs.lstatSync(instanceSettings); - if (!stats.isSymbolicLink()) { - broken++; - continue; - } - - const target = fs.readlinkSync(instanceSettings); - const resolved = path.resolve(path.dirname(instanceSettings), target); - - if (resolved !== sharedSettings) { - broken++; - } - } catch (_err) { - broken++; - } - } - - if (broken > 0) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} ${broken} broken instance(s)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `${broken} instance(s) have broken symlinks`, - 'Run: ccs sync', - { status: 'WARN', info: `${broken} broken instance(s)` } - ); - } else { - spinner.succeed(); - console.log(` ${ok(settingsLabel.padEnd(22))} ${instances.length} instance(s) valid`); - this.results.addCheck( - 'Settings Symlinks', - 'success', - 'All instance symlinks valid', - undefined, - { - status: 'OK', - info: `${instances.length} instance(s) valid`, - } - ); - } - } catch (err) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Check failed`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Failed to check: ${(err as Error).message}`, - 'Run: ccs sync', - { status: 'WARN', info: 'Check failed' } - ); - } - } - - /** - * Check 10.1: Environment detection (OAuth readiness) - * Helps diagnose Windows headless false positives - */ - private checkEnvironment(): void { - const spinner = ora('Checking environment').start(); - const diag = getEnvironmentDiagnostics(); - - // Determine overall environment health - let envStatus: 'OK' | 'WARN' = 'OK'; - let envMessage = 'Browser available'; - - // Check for potential issues - if (diag.detectedHeadless) { - if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { - // Windows false positive - this is actually a warning - envStatus = 'WARN'; - envMessage = 'Headless detected (may be false positive on Windows)'; - } else if (diag.sshSession) { - envMessage = 'SSH session (headless mode)'; - } else { - envMessage = 'Headless environment'; - } - } - - if (envStatus === 'WARN') { - spinner.warn(); - console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); - } else { - spinner.succeed(); - console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); - } - - // Show key environment details - console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); - if (diag.sshSession) { - console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); - } - if (diag.ttyStatus === 'undefined') { - console.log(` ${''.padEnd(24)} TTY: undefined [!]`); - } - console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); - - this.results.addCheck( - 'Environment', - envStatus === 'OK' ? 'success' : 'warning', - envMessage, - envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, - { - status: envStatus, - info: envMessage, - } - ); - } - - /** - * Check 10.2: OAuth callback ports availability - * Pre-flight check for OAuth authentication - */ - private async checkOAuthPorts(): Promise { - const spinner = ora('Checking OAuth callback ports').start(); - const portDiagnostics = await checkAuthCodePorts(); - - // Count issues - const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); - - if (conflicts.length === 0) { - spinner.succeed(); - console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); - this.results.addCheck('OAuth Ports', 'success', undefined, undefined, { - status: 'OK', - info: 'All callback ports available', - }); - } else { - spinner.warn(); - console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); - this.results.addCheck( - 'OAuth Ports', - 'warning', - `${conflicts.length} port conflict(s)`, - 'Close conflicting applications before OAuth', - { status: 'WARN', info: `${conflicts.length} conflict(s)` } - ); - } - - // Show individual port status - for (const diag of portDiagnostics) { - const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); - const portStr = diag.port !== null ? `(${diag.port})` : ''; - - let statusIcon: string; - switch (diag.status) { - case 'free': - case 'cliproxy': - statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); - break; - case 'occupied': - statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); - break; - default: - statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); - } - - console.log(` ${statusIcon} ${diag.message}`); - - if (diag.recommendation && diag.status === 'occupied') { - console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); - } - } - } - - /** - * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen) - */ - private async checkCLIProxy(): Promise { - // 1. Binary installed? - const binarySpinner = ora('Checking CLIProxy binary').start(); - - if (isCLIProxyInstalled()) { - const binaryPath = getCLIProxyPath(); - const installedVersion = getInstalledCliproxyVersion(); - binarySpinner.succeed(); - console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`); - this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { - status: 'OK', - info: `v${installedVersion} (${binaryPath})`, - }); - } else { - binarySpinner.info(); - console.log( - ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)` - ); - this.results.addCheck( - 'CLIProxy Binary', - 'success', - 'Not installed yet', - 'Run: ccs gemini "test" (will download automatically)', - { status: 'OK', info: 'Not installed (downloads on first use)' } - ); - } - - // 2. Config file exists and is up-to-date? - const configSpinner = ora('Checking CLIProxy config').start(); - const configPath = getConfigPath(); - - if (fs.existsSync(configPath)) { - // Check if config needs regeneration (version mismatch or missing features) - if (configNeedsRegeneration()) { - configSpinner.warn(); - console.log( - ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` - ); - - // Regenerate config with new features - regenerateConfig(); - - console.log( - ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}` - ); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, - }); - } else { - configSpinner.succeed(); - console.log( - ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})` - ); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, - }); - } - } else { - configSpinner.info(); - console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); - this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { - status: 'OK', - info: 'Generated on first use', - }); - } - - // 3. OAuth status for each provider - const authStatuses = getAllAuthStatus(); - for (const status of authStatuses) { - const authSpinner = ora(`Checking ${status.provider} auth`).start(); - const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); - - if (status.authenticated) { - const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; - authSpinner.succeed(); - console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`); - this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { - status: 'OK', - info: `Authenticated${lastAuth}`, - }); - } else { - authSpinner.info(); - console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`); - this.results.addCheck( - `${providerName} Auth`, - 'success', - 'Not authenticated', - `Run: ccs ${status.provider} --auth`, - { status: 'OK', info: 'Not authenticated (run ccs to login)' } - ); - } - } - - // 4. Port status (check if CLIProxy or other process) - const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - - if (!portProcess) { - // Port is free - portSpinner.info(); - console.log( - ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)` - ); - this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { - status: 'OK', - info: `Port ${CLIPROXY_DEFAULT_PORT} free`, - }); - } else if (isCLIProxyProcess(portProcess)) { - // CLIProxy is running (expected) - portSpinner.succeed(); - console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`); - this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { - status: 'OK', - info: `CLIProxy running (PID ${portProcess.pid})`, - }); - } else { - // Port conflict - different process - portSpinner.warn(); - console.log( - ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}` - ); - this.results.addCheck( - 'CLIProxy Port', - 'warning', - `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`, - `Kill process: kill ${portProcess.pid} (or restart conflicting application)`, - { status: 'WARN', info: `Occupied by ${portProcess.processName}` } - ); - } - } - /** * Show health check report */ @@ -1119,134 +170,9 @@ class Doctor { /** * Fix detected issues (--fix flag) - * Fixes: - * 1. Zombie CLIProxy processes blocking ports - * 2. Outdated CLIProxy config files - * 3. Shared symlinks broken by Claude CLI's atomic writes - * 4. OAuth callback ports blocked by CLIProxy */ async fixIssues(): Promise { - console.log(''); - console.log(header('AUTO-FIX MODE')); - console.log(''); - console.log(info(`Platform: ${getPlatformName()}`)); - console.log(''); - - let fixed = 0; - - // Fix 1: Kill zombie CLIProxy processes - const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); - try { - // Check main CLIProxy port - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - if (portProcess && isCLIProxyProcess(portProcess)) { - zombieSpinner.text = 'Killing zombie CLIProxy process...'; - const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); - if (killed) { - zombieSpinner.succeed( - `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` - ); - fixed++; - } else { - zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); - } - } else if (portProcess) { - zombieSpinner.info( - `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` - ); - } else { - zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); - } - } catch (err) { - zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); - } - - // Fix 2: Kill CLIProxy processes on OAuth callback ports - const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy - for (const port of oauthPorts) { - const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); - try { - const portProcess = await getPortProcess(port); - if (portProcess && isCLIProxyProcess(portProcess)) { - oauthSpinner.text = `Freeing OAuth port ${port}...`; - const killed = killProcessOnPort(port, true); - if (killed) { - oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); - fixed++; - } else { - oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); - } - } else if (portProcess) { - oauthSpinner.info( - `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` - ); - } else { - oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); - } - } catch (_err) { - oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); - } - } - - // Fix 3: Regenerate outdated CLIProxy config - const configSpinner = ora('Checking CLIProxy config version').start(); - try { - if (configNeedsRegeneration()) { - configSpinner.text = 'Upgrading CLIProxy config...'; - regenerateConfig(); - configSpinner.succeed( - `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` - ); - fixed++; - } else { - configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); - } - } catch (err) { - configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); - } - - // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) - const symlinkSpinner = ora('Checking shared settings.json symlink').start(); - const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); - try { - if (fs.existsSync(sharedSettings)) { - const stats = fs.lstatSync(sharedSettings); - if (!stats.isSymbolicLink()) { - symlinkSpinner.text = 'Restoring shared settings.json symlink...'; - const SharedManagerModule = await import('./shared-manager'); - const SharedManager = SharedManagerModule.default; - const sharedManager = new SharedManager(); - sharedManager.ensureSharedDirectories(); - symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); - fixed++; - } else { - symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); - } - } else { - symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); - } - } catch (err) { - symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); - } - - // Summary - console.log(''); - if (fixed > 0) { - console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); - console.log(''); - console.log(info('Try your command again. If issues persist, run:')); - console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); - } else { - console.log(ok('No issues found that needed fixing')); - console.log(''); - console.log(info('If you still have issues:')); - console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); - console.log( - ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` - ); - console.log(` 3. Restart your terminal/computer`); - } - console.log(''); + await runAutoRepair(); } /** diff --git a/src/management/index.ts b/src/management/index.ts new file mode 100644 index 00000000..7fe5a372 --- /dev/null +++ b/src/management/index.ts @@ -0,0 +1,43 @@ +/** + * Management Module Barrel Export + * + * Provides system management functionality including health checks, + * diagnostics, instance management, and repair utilities. + */ + +// Manager classes (default exports) +export { default as Doctor } from './doctor'; +export { default as InstanceManager } from './instance-manager'; +export { default as RecoveryManager } from './recovery-manager'; +export { default as SharedManager } from './shared-manager'; + +// Environment diagnostics +export { + type EnvironmentDiagnostics, + getEnvironmentDiagnostics, + shouldUseHeadlessAuth, + formatEnvironmentDiagnostics, +} from './environment-diagnostics'; + +// OAuth port diagnostics +export { + OAUTH_CALLBACK_PORTS, + OAUTH_FLOW_TYPES, + type OAuthFlowType, + type OAuthPortDiagnostic, + type EnhancedPreflightResult, + type PreflightCheck, + checkOAuthPort, + checkAllOAuthPorts, + checkAuthCodePorts, + getPortConflicts, + formatOAuthPortDiagnostics, + preflightOAuthCheck, + enhancedPreflightOAuthCheck, +} from './oauth-port-diagnostics'; + +// Health checks +export * from './checks'; + +// Repair utilities +export * from './repair'; diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts new file mode 100644 index 00000000..882bb3b1 --- /dev/null +++ b/src/management/repair/auto-repair.ts @@ -0,0 +1,153 @@ +/** + * Auto-Repair Module - Fix common issues automatically + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, warn, fail, info, header, color } from '../../utils/ui'; +import { + CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands'; +import { createSpinner } from '../checks/types'; + +const ora = createSpinner(); + +/** + * Fix detected issues automatically + * Fixes: + * 1. Zombie CLIProxy processes blocking ports + * 2. Outdated CLIProxy config files + * 3. Shared symlinks broken by Claude CLI's atomic writes + * 4. OAuth callback ports blocked by CLIProxy + */ +export async function runAutoRepair(): Promise { + const homedir = os.homedir(); + + console.log(''); + console.log(header('AUTO-FIX MODE')); + console.log(''); + console.log(info(`Platform: ${getPlatformName()}`)); + console.log(''); + + let fixed = 0; + + // Fix 1: Kill zombie CLIProxy processes + const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); + try { + // Check main CLIProxy port + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + if (portProcess && isCLIProxyProcess(portProcess)) { + zombieSpinner.text = 'Killing zombie CLIProxy process...'; + const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); + if (killed) { + zombieSpinner.succeed( + `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` + ); + fixed++; + } else { + zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); + } + } else if (portProcess) { + zombieSpinner.info( + `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` + ); + } else { + zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); + } + } catch (err) { + zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); + } + + // Fix 2: Kill CLIProxy processes on OAuth callback ports + const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy + for (const port of oauthPorts) { + const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); + try { + const portProcess = await getPortProcess(port); + if (portProcess && isCLIProxyProcess(portProcess)) { + oauthSpinner.text = `Freeing OAuth port ${port}...`; + const killed = killProcessOnPort(port, true); + if (killed) { + oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); + fixed++; + } else { + oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); + } + } else if (portProcess) { + oauthSpinner.info( + `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` + ); + } else { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); + } + } catch (_err) { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); + } + } + + // Fix 3: Regenerate outdated CLIProxy config + const configSpinner = ora('Checking CLIProxy config version').start(); + try { + if (configNeedsRegeneration()) { + configSpinner.text = 'Upgrading CLIProxy config...'; + regenerateConfig(); + configSpinner.succeed( + `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` + ); + fixed++; + } else { + configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); + } + } catch (err) { + configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); + } + + // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) + const symlinkSpinner = ora('Checking shared settings.json symlink').start(); + const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json'); + try { + if (fs.existsSync(sharedSettings)) { + const stats = fs.lstatSync(sharedSettings); + if (!stats.isSymbolicLink()) { + symlinkSpinner.text = 'Restoring shared settings.json symlink...'; + const SharedManagerModule = await import('../shared-manager'); + const SharedManager = SharedManagerModule.default; + const sharedManager = new SharedManager(); + sharedManager.ensureSharedDirectories(); + symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); + fixed++; + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); + } + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); + } + } catch (err) { + symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); + } + + // Summary + console.log(''); + if (fixed > 0) { + console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); + console.log(''); + console.log(info('Try your command again. If issues persist, run:')); + console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); + } else { + console.log(ok('No issues found that needed fixing')); + console.log(''); + console.log(info('If you still have issues:')); + console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); + console.log( + ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` + ); + console.log(` 3. Restart your terminal/computer`); + } + console.log(''); +} diff --git a/src/management/repair/index.ts b/src/management/repair/index.ts new file mode 100644 index 00000000..d495a41d --- /dev/null +++ b/src/management/repair/index.ts @@ -0,0 +1,5 @@ +/** + * Repair Module Registry + */ + +export { runAutoRepair } from './auto-repair'; diff --git a/src/types/delegation.ts b/src/types/delegation.ts index 25efaa79..257a2998 100644 --- a/src/types/delegation.ts +++ b/src/types/delegation.ts @@ -34,19 +34,8 @@ export interface DelegationSessionsRegistry { lastSessionId?: string; // Most recent session ID } -/** - * Execution result - */ -export interface ExecutionResult { - exitCode: number; - duration: number; // Seconds - workingDir: string; - sessionId: string; - profile: string; - model?: string; // Model name from settings - cost?: number; // Estimated cost (if available) - turns: number; -} +// Re-export ExecutionResult from canonical location for backward compatibility +export type { ExecutionResult } from '../delegation/executor/types'; /** * Real-time output event diff --git a/src/types/utils.ts b/src/types/utils.ts index d24651ac..a9320d53 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -42,6 +42,36 @@ export interface TerminalInfo { */ export type Result = { ok: true; value: T } | { ok: false; error: E }; +/** + * Generic operation result for success/failure patterns + * Use this instead of defining new *Result interfaces + */ +export interface OperationResult { + success: boolean; + message?: string; + error?: string; + data?: T; +} + +/** + * Generic component/tool status + * Used for CLI tools, services, etc. + */ +export interface ComponentStatus { + installed: boolean; + path?: string; + version?: string; +} + +/** + * Generic validation result + * Standardized on 'valid' property name + */ +export interface ValidationResult { + valid: boolean; + errors?: string[]; +} + // ============================================================================= // UI TYPES (Phase 1: CLI UI/UX Enhancement) // ============================================================================= diff --git a/src/utils/claude-spawner.ts b/src/utils/claude-spawner.ts new file mode 100644 index 00000000..e6e169bd --- /dev/null +++ b/src/utils/claude-spawner.ts @@ -0,0 +1,139 @@ +/** + * Claude Spawner Utilities + * + * Cross-platform Claude CLI spawn utilities for CCS. + * Handles Windows .cmd/.bat/.ps1 files properly. + */ + +import { spawn, ChildProcess, SpawnOptions } from 'child_process'; +import { escapeShellArg } from './shell-executor'; +import { getClaudeCliInfo } from './claude-detector'; +import { ErrorManager } from './error-manager'; + +export interface SpawnClaudeOptions { + /** Arguments to pass to Claude CLI */ + args?: string[]; + /** Environment variables to merge with process.env */ + env?: NodeJS.ProcessEnv; + /** Working directory */ + cwd?: string; + /** Stdio configuration (default: 'inherit') */ + stdio?: SpawnOptions['stdio']; +} + +export interface SpawnClaudeResult { + /** The spawned child process */ + child: ChildProcess; + /** Path to the Claude CLI executable */ + claudePath: string; +} + +/** + * Spawn Claude CLI with cross-platform support. + * + * Handles Windows .cmd/.bat/.ps1 wrappers automatically. + * Returns the ChildProcess for custom event handling. + * + * @throws Error if Claude CLI is not found + */ +export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult { + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + throw new Error('Claude CLI not found'); + } + + const { path: claudeCli, needsShell } = claudeInfo; + const { args = [], env, cwd, stdio = 'inherit' } = options; + + // Merge environment + const mergedEnv = env ? { ...process.env, ...env } : process.env; + + let child: ChildProcess; + if (needsShell) { + // Windows .cmd/.bat/.ps1: concatenate into string to avoid DEP0190 warning + const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio, + windowsHide: true, + shell: true, + env: mergedEnv, + cwd, + }); + } else { + // Unix or Windows native: use array form (faster, no shell overhead) + child = spawn(claudeCli, args, { + stdio, + windowsHide: true, + env: mergedEnv, + cwd, + }); + } + + return { child, claudePath: claudeCli }; +} + +/** + * Spawn Claude CLI and wait for exit. + * + * Convenience function that returns a promise resolving to the exit code. + */ +export function spawnClaudeSync(options: SpawnClaudeOptions = {}): Promise { + return new Promise((resolve, reject) => { + try { + const { child } = spawnClaude(options); + + child.on('exit', (code) => { + resolve(code ?? 0); + }); + + child.on('error', (error) => { + reject(error); + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Spawn Claude CLI with automatic exit handling. + * + * Exits the current process when Claude exits, preserving exit code/signal. + * Used for simple pass-through scenarios. + */ +export function execClaudeWithExitHandling(options: SpawnClaudeOptions = {}): void { + let result: SpawnClaudeResult; + + try { + result = spawnClaude(options); + } catch { + // Claude not found - show error and exit + void ErrorManager.showClaudeNotFound(); + process.exit(1); + } + + const { child } = result; + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal as NodeJS.Signals); + } else { + process.exit(code ?? 0); + } + }); + + child.on('error', async () => { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + }); +} + +/** + * Check if shell execution is needed for a given path. + * + * Returns true for Windows .cmd/.bat/.ps1 files. + */ +export function needsShellExecution(executablePath: string): boolean { + const isWindows = process.platform === 'win32'; + return isWindows && /\.(cmd|bat|ps1)$/i.test(executablePath); +} diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 4d882673..cec57e3f 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -4,9 +4,13 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { Settings } from '../types'; +import { ValidationResult } from '../types/utils'; -interface ValidationResult { - valid: boolean; +/** + * Extended validation result for delegation profiles + * Extends base ValidationResult with domain-specific fields + */ +interface DelegationValidationResult extends ValidationResult { error?: string; suggestion?: string; settingsPath?: string; @@ -23,7 +27,7 @@ export class DelegationValidator { * @param profileName - Name of profile to validate (e.g., 'glm', 'kimi') * @returns Validation result { valid: boolean, error?: string, settingsPath?: string } */ - static validate(profileName: string): ValidationResult { + static validate(profileName: string): DelegationValidationResult { const homeDir = os.homedir(); const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`); @@ -110,7 +114,7 @@ export class DelegationValidator { * @param result - Validation result from validate() * @returns Formatted error message */ - static formatError(result: ValidationResult): string { + static formatError(result: DelegationValidationResult): string { if (result.valid) { return ''; } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index a30d466c..5f1f9031 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,57 +1,5 @@ import * as path from 'path'; import * as os from 'os'; -import { ColorName, TerminalInfo } from '../types'; - -// ============================================================================= -// DEPRECATION NOTICE -// ============================================================================= -// The color functions in this file are deprecated in favor of the new UI layer. -// Please use `import { ui } from './ui'` instead for: -// - ui.color(text, semantic) - semantic colors -// - ui.ok(), ui.fail(), ui.warn(), ui.info() - status indicators -// - ui.box(), ui.table() - containers -// - ui.header(), ui.subheader() - section headers -// ============================================================================= - -/** - * TTY-aware color detection (matches lib/ccs bash logic) - * @deprecated Use `import { ui } from './ui'` instead - */ -function getColors(): Record { - const forcedColors = process.env.FORCE_COLOR; - const noColor = process.env.NO_COLOR; - const isTTY = process.stdout.isTTY === true; // Must be explicitly true - - const useColors = !!forcedColors || (isTTY && !noColor); - - if (useColors) { - return { - red: '\x1b[0;31m', - yellow: '\x1b[1;33m', - cyan: '\x1b[0;36m', - green: '\x1b[0;32m', - blue: '\x1b[0;34m', - bold: '\x1b[1m', - cyanBold: '\x1b[1;36m', - reset: '\x1b[0m', - }; - } - - return { red: '', yellow: '', cyan: '', green: '', blue: '', bold: '', cyanBold: '', reset: '' }; -} - -// Colors object (dynamic) -export const colors = getColors(); - -/** - * Helper: Apply color to text (returns plain text if colors disabled) - * @deprecated Use `import { ui } from './ui'` and `ui.color()` instead - */ -export function colored(text: string, colorName: ColorName = 'reset'): string { - const currentColors = getColors(); - const color = currentColors[colorName] || ''; - return color ? `${color}${text}${currentColors.reset}` : text; -} /** * Simple error formatting @@ -83,17 +31,6 @@ export function expandPath(pathStr: string): string { return path.normalize(pathStr); } -/** - * Detect terminal capabilities - */ -export function getTerminalInfo(): TerminalInfo { - return { - isTTY: process.stdout.isTTY ?? false, - supportsColor: (process.stdout.isTTY ?? false) && !process.env.NO_COLOR, - noColorEnv: !!process.env.NO_COLOR, - }; -} - /** * Calculate Levenshtein distance between two strings */ diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..8ecc7329 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,26 @@ +/** + * Utils module barrel export + * Selective exports of commonly used utilities + */ + +// UI utilities (main export) +export * from './ui'; + +// Time utilities +export * from './time'; + +// Shell execution +export { execClaude, escapeShellArg } from './shell-executor'; + +// Claude detection and management +export { getClaudeCliInfo } from './claude-detector'; +export { ClaudeSymlinkManager } from './claude-symlink-manager'; +export { ClaudeDirInstaller } from './claude-dir-installer'; + +// Utilities +export { ProgressIndicator } from './progress-indicator'; +export { getVersion } from './version'; +export { ErrorManager } from './error-manager'; + +// Platform utilities +export { default as getPlatformCommands } from './platform-commands'; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 00000000..a15af9a3 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,22 @@ +/** + * Time Formatting Utilities + * + * Centralized date/time formatting functions. + */ + +/** + * Format relative time (e.g., "2h ago", "1d ago") + */ +export function 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'; +} diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 4b1569af..104a0414 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -12,605 +12,10 @@ * @module utils/ui */ -import type { - BoxOptions, - TableOptions, - SpinnerOptions, - SemanticColor, - SpinnerController, -} from '../types/utils'; - -// ============================================================================= -// ESM MODULE TYPES & LAZY LOADING -// ============================================================================= - -// Type definitions for dynamically imported modules (CJS-compatible versions) -// chalk@4.x, boxen@5.x are CJS, ora@5.x is CJS, listr2@3.x is CJS, gradient-string@2.x is CJS -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ChalkInstance = any; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type BoxenFunction = any; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type GradientStringInstance = any; -// ora v9 is ESM-only, imported dynamically at runtime -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type OraModule = any; -// listr2 v9 is ESM-only, imported dynamically at runtime -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ListrClass = any; - -// Module cache for lazy loading -let chalkModule: ChalkInstance | null = null; -let boxenModule: BoxenFunction | null = null; -let gradientModule: GradientStringInstance | null = null; -let oraModule: OraModule | null = null; -let listrModule: ListrClass | null = null; - -// Initialization state -let initialized = false; - -// ============================================================================= -// COLOR PALETTE (Professional cyan-to-blue) -// ============================================================================= - -const COLORS = { - primary: '#00ECFA', // Bright cyan - secondary: '#0099FF', // Sky blue - neutral: '#808080', // Gray -} as const; - -// ============================================================================= -// INITIALIZATION -// ============================================================================= - -/** - * Initialize UI dependencies (call once at startup) - * Uses dynamic imports for ESM packages in CommonJS project - */ -export async function initUI(): Promise { - if (initialized) return; - - try { - // Dynamic import for ESM-only packages - const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([ - import('chalk'), - import('boxen'), - import('gradient-string'), - import('ora'), - import('listr2'), - ]); - - // CJS modules: use .default if available (ESM interop), otherwise use module directly - chalkModule = chalkImport.default || chalkImport; - boxenModule = boxenImport.default || boxenImport; - gradientModule = gradientImport.default || gradientImport; - oraModule = oraImport.default || oraImport; - listrModule = listrImport.Listr || listrImport.default?.Listr; - initialized = true; - } catch (_e) { - // Fallback: UI works without colors if imports fail - console.error('[!] UI initialization failed, using plain text mode'); - initialized = true; - } -} - -// ============================================================================= -// TTY & COLOR DETECTION -// ============================================================================= - -/** - * Check if colors should be used - * Respects NO_COLOR and FORCE_COLOR environment variables - */ -function useColors(): boolean { - // FORCE_COLOR overrides all checks - if (process.env.FORCE_COLOR) return true; - // NO_COLOR disables colors - if (process.env.NO_COLOR) return false; - // Otherwise, check if TTY - return !!process.stdout.isTTY; -} - -/** - * Check if interactive mode (TTY + not CI) - */ -export function isInteractive(): boolean { - return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR; -} - -// ============================================================================= -// COLOR SYSTEM -// ============================================================================= - -/** - * Apply semantic color to text - */ -export function color(text: string, semantic: SemanticColor): string { - if (!chalkModule || !useColors()) return text; - - switch (semantic) { - case 'success': - return chalkModule.green.bold(text); - case 'error': - return chalkModule.red.bold(text); - case 'warning': - return chalkModule.yellow(text); - case 'info': - return chalkModule.cyan(text); - case 'dim': - return chalkModule.gray(text); - case 'primary': - return chalkModule.hex(COLORS.primary).bold(text); - case 'secondary': - return chalkModule.hex(COLORS.secondary)(text); - case 'command': - return chalkModule.yellow.bold(text); - case 'path': - return chalkModule.cyan.underline(text); - default: - return text; - } -} - -/** - * Apply gradient to text (for headers) - * Uses cyan-to-blue gradient for professional look - */ -export function gradientText(text: string): string { - if (!gradientModule || !useColors()) return text; - return gradientModule([COLORS.primary, COLORS.secondary])(text); -} - -/** - * Bold text - */ -export function bold(text: string): string { - if (!chalkModule || !useColors()) return text; - return chalkModule.bold(text); -} - -/** - * Dim text - */ -export function dim(text: string): string { - if (!chalkModule || !useColors()) return text; - return chalkModule.dim(text); -} - -// ============================================================================= -// STATUS INDICATORS (ASCII only - NO EMOJIS) -// ============================================================================= - -/** - * Success indicator: [OK] - */ -export function ok(message: string): string { - return `${color('[OK]', 'success')} ${message}`; -} - -/** - * Error indicator: [X] - */ -export function fail(message: string): string { - return `${color('[X]', 'error')} ${message}`; -} - -/** - * Warning indicator: [!] - */ -export function warn(message: string): string { - return `${color('[!]', 'warning')} ${message}`; -} - -/** - * Info indicator: [i] - */ -export function info(message: string): string { - return `${color('[i]', 'info')} ${message}`; -} - -// ============================================================================= -// BOX RENDERING -// ============================================================================= - -/** - * Fallback ASCII box renderer (when boxen not available) - */ -function renderAsciiBox(content: string, options: BoxOptions): string { - const lines = content.split('\n'); - const maxLen = Math.max(...lines.map((l) => l.length), (options.title?.length || 0) + 4); - const width = maxLen + 4; - - let result = ''; - const padding = options.padding ?? 1; - - // Top border with optional title - if (options.title) { - const titlePad = Math.floor((width - options.title.length - 4) / 2); - result += - '+' + - '-'.repeat(titlePad) + - ' ' + - options.title + - ' ' + - '-'.repeat(width - titlePad - options.title.length - 4) + - '+\n'; - } else { - result += '+' + '-'.repeat(width - 2) + '+\n'; - } - - // Padding top - for (let i = 0; i < padding; i++) { - result += '|' + ' '.repeat(width - 2) + '|\n'; - } - - // Content - for (const line of lines) { - const pad = width - line.length - 4; - result += '| ' + line + ' '.repeat(Math.max(0, pad)) + ' |\n'; - } - - // Padding bottom - for (let i = 0; i < padding; i++) { - result += '|' + ' '.repeat(width - 2) + '|\n'; - } - - // Bottom border - result += '+' + '-'.repeat(width - 2) + '+'; - - return result; -} - -/** - * Render content in a styled box - */ -export function box(content: string, options: BoxOptions = {}): string { - if (!boxenModule) { - return renderAsciiBox(content, options); - } - - const borderColor = useColors() ? options.borderColor || COLORS.primary : undefined; - - return boxenModule(content, { - padding: options.padding ?? 1, - margin: options.margin ?? 0, - borderStyle: options.borderStyle || 'round', - borderColor, - title: options.title, - titleAlignment: options.titleAlignment || 'center', - }); -} - -/** - * Render error box (red border) - */ -export function errorBox(content: string, title = 'ERROR'): string { - return box(content, { - title, - borderColor: 'red', - borderStyle: 'round', - padding: 1, - margin: 1, - }); -} - -/** - * Render info box (primary color border) - */ -export function infoBox(content: string, title?: string): string { - return box(content, { - title, - borderColor: COLORS.primary, - borderStyle: 'round', - padding: 1, - }); -} - -/** - * Render warning box (yellow border) - */ -export function warnBox(content: string, title = 'WARNING'): string { - return box(content, { - title, - borderColor: 'yellow', - borderStyle: 'round', - padding: 1, - }); -} - -// ============================================================================= -// TABLE RENDERING -// ============================================================================= - -// cli-table3 is CommonJS, use dynamic import pattern - -const Table = require('cli-table3'); - -/** - * Create styled table - */ -export function table(rows: string[][], options: TableOptions = {}): string { - // Build table configuration - const tableConfig: Record = { - wordWrap: options.wordWrap ?? true, - chars: - options.style === 'ascii' - ? { - top: '-', - 'top-mid': '+', - 'top-left': '+', - 'top-right': '+', - bottom: '-', - 'bottom-mid': '+', - 'bottom-left': '+', - 'bottom-right': '+', - left: '|', - 'left-mid': '+', - mid: '-', - 'mid-mid': '+', - right: '|', - 'right-mid': '+', - middle: '|', - } - : { - top: '─', - 'top-mid': '┬', - 'top-left': '┌', - 'top-right': '┐', - bottom: '─', - 'bottom-mid': '┴', - 'bottom-left': '└', - 'bottom-right': '┘', - left: '│', - 'left-mid': '├', - mid: '─', - 'mid-mid': '┼', - right: '│', - 'right-mid': '┤', - middle: '│', - }, - }; - - // Only add head if provided (cli-table3 requires head length to match rows) - if (options.head && options.head.length > 0) { - tableConfig.head = options.head.map((h: string) => color(h, 'primary')); - } - - // Only add colWidths if provided - if (options.colWidths) { - tableConfig.colWidths = options.colWidths; - } - - const tableInstance = new Table(tableConfig); - - rows.forEach((row) => tableInstance.push(row)); - return tableInstance.toString(); -} - -// ============================================================================= -// SPINNER / PROGRESS -// ============================================================================= - -/** - * Create and start a spinner - * Falls back to plain text output in non-TTY environments - */ -export async function spinner(options: SpinnerOptions | string): Promise { - const opts = typeof options === 'string' ? { text: options } : options; - const isEnabled = isInteractive(); - - // Lazy load ora if not already loaded - if (!oraModule && isEnabled) { - try { - oraModule = (await import('ora')).default; - } catch (_e) { - // Fallback to plain text - } - } - - if (oraModule && isEnabled) { - const s = oraModule({ - text: opts.text, - color: 'cyan', - prefixText: opts.prefixText, - isEnabled, - }).start(); - - return { - succeed: (msg?: string) => s.succeed(msg || `${color('[OK]', 'success')} ${opts.text}`), - fail: (msg?: string) => s.fail(msg || `${color('[X]', 'error')} ${opts.text}`), - warn: (msg?: string) => s.warn(msg || `${color('[!]', 'warning')} ${opts.text}`), - info: (msg?: string) => s.info(msg || `${color('[i]', 'info')} ${opts.text}`), - update: (text: string) => { - s.text = text; - }, - stop: () => s.stop(), - }; - } - - // Fallback: plain text (non-TTY) - console.log(`[i] ${opts.text}...`); - return { - succeed: (msg?: string) => console.log(ok(msg || opts.text)), - fail: (msg?: string) => console.log(fail(msg || opts.text)), - warn: (msg?: string) => console.log(warn(msg || opts.text)), - info: (msg?: string) => console.log(info(msg || opts.text)), - update: (_text: string) => { - /* no-op in non-TTY */ - }, - stop: () => { - /* no-op */ - }, - }; -} - -// ============================================================================= -// SECTION HEADERS -// ============================================================================= - -/** - * Print section header with optional gradient - */ -export function header(text: string, useGradient = true): string { - if (useGradient && useColors()) { - return gradientText(text); - } - return bold(text); -} - -/** - * Print subsection header - */ -export function subheader(text: string): string { - return color(text, 'primary'); -} - -/** - * Print horizontal rule - */ -export function hr(char = '─', width = 60): string { - if (!useColors()) { - return '-'.repeat(width); - } - return dim(char.repeat(width)); -} - -/** - * Print section header with ═══ borders - * Format: ═══ Title ═══ - */ -export function sectionHeader(title: string): string { - const border = '═══'; - const headerText = `${border} ${title} ${border}`; - // Use gradient + bold for visual appeal - if (gradientModule && chalkModule && useColors()) { - return chalkModule.bold(gradientModule([COLORS.primary, COLORS.secondary])(headerText)); - } - // Fallback to bold primary color - if (useColors() && chalkModule) { - return chalkModule.hex(COLORS.primary).bold(headerText); - } - return headerText; -} - -// ============================================================================= -// TASK LISTS (Listr2 Integration) -// ============================================================================= - -/** - * Detect if running inside Claude Code tool context - * - * Heuristics: - * - No TTY (stdout captured) - * - CI-like environment - * - CLAUDE_CODE env var set - */ -export function isClaudeCodeContext(): boolean { - return ( - !process.stdout.isTTY || - !!process.env.CI || - !!process.env.CLAUDE_CODE || - process.env.TERM === 'dumb' - ); -} - -/** - * Task list item interface - */ -export interface TaskItem { - title: string; - task: (ctx: T) => Promise | void; - skip?: () => boolean | string; -} - -/** - * Task list options - */ -export interface TaskListOptions { - concurrent?: boolean; -} - -/** - * Create a task list for progress display - * Uses Listr2 in TTY mode, falls back to spinners in non-TTY - */ -export async function taskList(tasks: TaskItem[], options: TaskListOptions = {}): Promise { - // Lazy load Listr2 if not already loaded - if (!listrModule && isInteractive()) { - try { - const listr2 = await import('listr2'); - listrModule = listr2.Listr; - } catch (_e) { - // Fallback to sequential execution with spinners - return runTasksFallback(tasks); - } - } - - if (listrModule && isInteractive()) { - // Determine renderer based on context - // Use 'simple' in non-TTY, CI, or Claude Code context - const useSimple = isClaudeCodeContext(); - - const list = new listrModule( - tasks.map((t) => ({ - title: t.title, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task: async (ctx: any) => t.task(ctx), - skip: t.skip, - })), - { - concurrent: options.concurrent ?? false, - renderer: useSimple ? 'simple' : 'default', - rendererOptions: { - showSubtasks: true, - collapseSubtasks: false, - }, - } - ); - - return list.run({} as T); - } - - // Fallback: non-interactive or Listr2 not available - return runTasksFallback(tasks); -} - -/** - * Fallback task runner (no Listr2) - * Uses spinners for sequential task execution - */ -async function runTasksFallback(tasks: TaskItem[]): Promise { - const ctx = {} as T; - - for (const task of tasks) { - if (task.skip) { - const skipResult = task.skip(); - if (skipResult) { - console.log(info(`${task.title} [skipped]`)); - continue; - } - } - - const spin = await spinner(task.title); - try { - await task.task(ctx); - spin.succeed(); - } catch (e) { - spin.fail(`${task.title}: ${(e as Error).message}`); - throw e; - } - } - - return ctx; -} - -// ============================================================================= -// UNIFIED EXPORT OBJECT -// ============================================================================= - -export const ui = { +// Re-export everything from modular files +export { // Initialization - init: initUI, + initUI, isInteractive, isClaudeCodeContext, @@ -620,28 +25,39 @@ export const ui = { bold, dim, - // Status indicators (ASCII only) + // Status indicators ok, fail, warn, info, - // Containers + // Boxes box, errorBox, infoBox, warnBox, + + // Tables table, - // Progress + // Spinner spinner, + + // Tasks taskList, - // Headers + // Text formatting header, subheader, - sectionHeader, hr, -} as const; + sectionHeader, -export default ui; + // Unified object + ui, +} from './ui/index'; + +// Re-export types +export type { TaskItem, TaskListOptions } from './ui/index'; + +// Default export +export { default } from './ui/index'; diff --git a/src/utils/ui/boxes.ts b/src/utils/ui/boxes.ts new file mode 100644 index 00000000..724bca98 --- /dev/null +++ b/src/utils/ui/boxes.ts @@ -0,0 +1,115 @@ +/** + * Box Rendering + * + * Styled box containers with fallback ASCII rendering + * @module utils/ui/boxes + */ + +import type { BoxOptions } from '../../types/utils'; +import { moduleCache, COLORS } from './types'; +import { useColors } from './init'; + +/** + * Fallback ASCII box renderer (when boxen not available) + */ +function renderAsciiBox(content: string, options: BoxOptions): string { + const lines = content.split('\n'); + const maxLen = Math.max(...lines.map((l) => l.length), (options.title?.length || 0) + 4); + const width = maxLen + 4; + + let result = ''; + const padding = options.padding ?? 1; + + // Top border with optional title + if (options.title) { + const titlePad = Math.floor((width - options.title.length - 4) / 2); + result += + '+' + + '-'.repeat(titlePad) + + ' ' + + options.title + + ' ' + + '-'.repeat(width - titlePad - options.title.length - 4) + + '+\n'; + } else { + result += '+' + '-'.repeat(width - 2) + '+\n'; + } + + // Padding top + for (let i = 0; i < padding; i++) { + result += '|' + ' '.repeat(width - 2) + '|\n'; + } + + // Content + for (const line of lines) { + const pad = width - line.length - 4; + result += '| ' + line + ' '.repeat(Math.max(0, pad)) + ' |\n'; + } + + // Padding bottom + for (let i = 0; i < padding; i++) { + result += '|' + ' '.repeat(width - 2) + '|\n'; + } + + // Bottom border + result += '+' + '-'.repeat(width - 2) + '+'; + + return result; +} + +/** + * Render content in a styled box + */ +export function box(content: string, options: BoxOptions = {}): string { + if (!moduleCache.boxen) { + return renderAsciiBox(content, options); + } + + const borderColor = useColors() ? options.borderColor || COLORS.primary : undefined; + + return moduleCache.boxen(content, { + padding: options.padding ?? 1, + margin: options.margin ?? 0, + borderStyle: options.borderStyle || 'round', + borderColor, + title: options.title, + titleAlignment: options.titleAlignment || 'center', + }); +} + +/** + * Render error box (red border) + */ +export function errorBox(content: string, title = 'ERROR'): string { + return box(content, { + title, + borderColor: 'red', + borderStyle: 'round', + padding: 1, + margin: 1, + }); +} + +/** + * Render info box (primary color border) + */ +export function infoBox(content: string, title?: string): string { + return box(content, { + title, + borderColor: COLORS.primary, + borderStyle: 'round', + padding: 1, + }); +} + +/** + * Render warning box (yellow border) + */ +export function warnBox(content: string, title = 'WARNING'): string { + return box(content, { + title, + borderColor: 'yellow', + borderStyle: 'round', + padding: 1, + }); +} diff --git a/src/utils/ui/colors.ts b/src/utils/ui/colors.ts new file mode 100644 index 00000000..361cc3e6 --- /dev/null +++ b/src/utils/ui/colors.ts @@ -0,0 +1,65 @@ +/** + * Color System + * + * Semantic color functions with TTY awareness + * @module utils/ui/colors + */ + +import type { SemanticColor } from '../../types/utils'; +import { moduleCache, COLORS } from './types'; +import { useColors } from './init'; + +/** + * Apply semantic color to text + */ +export function color(text: string, semantic: SemanticColor): string { + if (!moduleCache.chalk || !useColors()) return text; + + switch (semantic) { + case 'success': + return moduleCache.chalk.green.bold(text); + case 'error': + return moduleCache.chalk.red.bold(text); + case 'warning': + return moduleCache.chalk.yellow(text); + case 'info': + return moduleCache.chalk.cyan(text); + case 'dim': + return moduleCache.chalk.gray(text); + case 'primary': + return moduleCache.chalk.hex(COLORS.primary).bold(text); + case 'secondary': + return moduleCache.chalk.hex(COLORS.secondary)(text); + case 'command': + return moduleCache.chalk.yellow.bold(text); + case 'path': + return moduleCache.chalk.cyan.underline(text); + default: + return text; + } +} + +/** + * Apply gradient to text (for headers) + * Uses cyan-to-blue gradient for professional look + */ +export function gradientText(text: string): string { + if (!moduleCache.gradient || !useColors()) return text; + return moduleCache.gradient([COLORS.primary, COLORS.secondary])(text); +} + +/** + * Bold text + */ +export function bold(text: string): string { + if (!moduleCache.chalk || !useColors()) return text; + return moduleCache.chalk.bold(text); +} + +/** + * Dim text + */ +export function dim(text: string): string { + if (!moduleCache.chalk || !useColors()) return text; + return moduleCache.chalk.dim(text); +} diff --git a/src/utils/ui/index.ts b/src/utils/ui/index.ts new file mode 100644 index 00000000..74a4c131 --- /dev/null +++ b/src/utils/ui/index.ts @@ -0,0 +1,90 @@ +/** + * UI Module Barrel Export + * + * Re-exports all UI components from modular files + * @module utils/ui + */ + +// Types and constants +export { COLORS, moduleCache, initialized, setInitialized } from './types'; +export type { + ChalkInstance, + BoxenFunction, + GradientStringInstance, + OraModule, + ListrClass, +} from './types'; + +// Initialization +export { initUI, useColors, isInteractive, isClaudeCodeContext } from './init'; + +// Colors +export { color, gradientText, bold, dim } from './colors'; + +// Status indicators +export { ok, fail, warn, info } from './indicators'; + +// Boxes +export { box, errorBox, infoBox, warnBox } from './boxes'; + +// Tables +export { table } from './tables'; + +// Text formatting +export { header, subheader, hr, sectionHeader } from './text'; + +// Spinner +export { spinner } from './spinner'; + +// Tasks +export { taskList } from './tasks'; +export type { TaskItem, TaskListOptions } from './tasks'; + +// Import all functions for the ui object +import { initUI, isInteractive, isClaudeCodeContext } from './init'; +import { color, gradientText, bold, dim } from './colors'; +import { ok, fail, warn, info } from './indicators'; +import { box, errorBox, infoBox, warnBox } from './boxes'; +import { table } from './tables'; +import { header, subheader, hr, sectionHeader } from './text'; +import { spinner } from './spinner'; +import { taskList } from './tasks'; + +// Unified UI object for convenient access +export const ui = { + // Initialization + init: initUI, + isInteractive, + isClaudeCodeContext, + + // Colors + color, + gradientText, + bold, + dim, + + // Status indicators (ASCII only) + ok, + fail, + warn, + info, + + // Containers + box, + errorBox, + infoBox, + warnBox, + table, + + // Progress + spinner, + taskList, + + // Headers + header, + subheader, + sectionHeader, + hr, +} as const; + +export default ui; diff --git a/src/utils/ui/indicators.ts b/src/utils/ui/indicators.ts new file mode 100644 index 00000000..c153855c --- /dev/null +++ b/src/utils/ui/indicators.ts @@ -0,0 +1,36 @@ +/** + * Status Indicators + * + * ASCII-only status markers (NO EMOJIS) + * @module utils/ui/indicators + */ + +import { color } from './colors'; + +/** + * Success indicator: [OK] + */ +export function ok(message: string): string { + return `${color('[OK]', 'success')} ${message}`; +} + +/** + * Error indicator: [X] + */ +export function fail(message: string): string { + return `${color('[X]', 'error')} ${message}`; +} + +/** + * Warning indicator: [!] + */ +export function warn(message: string): string { + return `${color('[!]', 'warning')} ${message}`; +} + +/** + * Info indicator: [i] + */ +export function info(message: string): string { + return `${color('[i]', 'info')} ${message}`; +} diff --git a/src/utils/ui/init.ts b/src/utils/ui/init.ts new file mode 100644 index 00000000..e2076cf8 --- /dev/null +++ b/src/utils/ui/init.ts @@ -0,0 +1,76 @@ +/** + * UI Initialization + * + * Handles lazy loading of ESM dependencies + * @module utils/ui/init + */ + +import { moduleCache, initialized, setInitialized } from './types'; + +/** + * Initialize UI dependencies (call once at startup) + * Uses dynamic imports for ESM packages in CommonJS project + */ +export async function initUI(): Promise { + if (initialized) return; + + try { + // Dynamic import for ESM-only packages + const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([ + import('chalk'), + import('boxen'), + import('gradient-string'), + import('ora'), + import('listr2'), + ]); + + // CJS modules: use .default if available (ESM interop), otherwise use module directly + moduleCache.chalk = chalkImport.default || chalkImport; + moduleCache.boxen = boxenImport.default || boxenImport; + moduleCache.gradient = gradientImport.default || gradientImport; + moduleCache.ora = oraImport.default || oraImport; + moduleCache.listr = listrImport.Listr || listrImport.default?.Listr; + setInitialized(true); + } catch (_e) { + // Fallback: UI works without colors if imports fail + console.error('[!] UI initialization failed, using plain text mode'); + setInitialized(true); + } +} + +/** + * Check if colors should be used + * Respects NO_COLOR and FORCE_COLOR environment variables + */ +export function useColors(): boolean { + // FORCE_COLOR overrides all checks + if (process.env.FORCE_COLOR) return true; + // NO_COLOR disables colors + if (process.env.NO_COLOR) return false; + // Otherwise, check if TTY + return !!process.stdout.isTTY; +} + +/** + * Check if interactive mode (TTY + not CI) + */ +export function isInteractive(): boolean { + return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR; +} + +/** + * Detect if running inside Claude Code tool context + * + * Heuristics: + * - No TTY (stdout captured) + * - CI-like environment + * - CLAUDE_CODE env var set + */ +export function isClaudeCodeContext(): boolean { + return ( + !process.stdout.isTTY || + !!process.env.CI || + !!process.env.CLAUDE_CODE || + process.env.TERM === 'dumb' + ); +} diff --git a/src/utils/ui/spinner.ts b/src/utils/ui/spinner.ts new file mode 100644 index 00000000..1af18c9f --- /dev/null +++ b/src/utils/ui/spinner.ts @@ -0,0 +1,67 @@ +/** + * Spinner / Progress + * + * Ora-based spinner with TTY fallback + * @module utils/ui/spinner + */ + +import type { SpinnerOptions, SpinnerController } from '../../types/utils'; +import { moduleCache } from './types'; +import { isInteractive } from './init'; +import { color } from './colors'; +import { ok, fail, warn, info } from './indicators'; + +/** + * Create and start a spinner + * Falls back to plain text output in non-TTY environments + */ +export async function spinner(options: SpinnerOptions | string): Promise { + const opts = typeof options === 'string' ? { text: options } : options; + const isEnabled = isInteractive(); + + // Lazy load ora if not already loaded + if (!moduleCache.ora && isEnabled) { + try { + moduleCache.ora = (await import('ora')).default; + } catch (_e) { + // Fallback to plain text + } + } + + if (moduleCache.ora && isEnabled) { + const s = moduleCache + .ora({ + text: opts.text, + color: 'cyan', + prefixText: opts.prefixText, + isEnabled, + }) + .start(); + + return { + succeed: (msg?: string) => s.succeed(msg || `${color('[OK]', 'success')} ${opts.text}`), + fail: (msg?: string) => s.fail(msg || `${color('[X]', 'error')} ${opts.text}`), + warn: (msg?: string) => s.warn(msg || `${color('[!]', 'warning')} ${opts.text}`), + info: (msg?: string) => s.info(msg || `${color('[i]', 'info')} ${opts.text}`), + update: (text: string) => { + s.text = text; + }, + stop: () => s.stop(), + }; + } + + // Fallback: plain text (non-TTY) + console.log(`[i] ${opts.text}...`); + return { + succeed: (msg?: string) => console.log(ok(msg || opts.text)), + fail: (msg?: string) => console.log(fail(msg || opts.text)), + warn: (msg?: string) => console.log(warn(msg || opts.text)), + info: (msg?: string) => console.log(info(msg || opts.text)), + update: (_text: string) => { + /* no-op in non-TTY */ + }, + stop: () => { + /* no-op */ + }, + }; +} diff --git a/src/utils/ui/tables.ts b/src/utils/ui/tables.ts new file mode 100644 index 00000000..f3a57f2a --- /dev/null +++ b/src/utils/ui/tables.ts @@ -0,0 +1,73 @@ +/** + * Table Rendering + * + * Styled table output with cli-table3 + * @module utils/ui/tables + */ + +import type { TableOptions } from '../../types/utils'; +import { color } from './colors'; + +// cli-table3 is CommonJS +const Table = require('cli-table3'); + +/** + * Create styled table + */ +export function table(rows: string[][], options: TableOptions = {}): string { + // Build table configuration + const tableConfig: Record = { + wordWrap: options.wordWrap ?? true, + chars: + options.style === 'ascii' + ? { + top: '-', + 'top-mid': '+', + 'top-left': '+', + 'top-right': '+', + bottom: '-', + 'bottom-mid': '+', + 'bottom-left': '+', + 'bottom-right': '+', + left: '|', + 'left-mid': '+', + mid: '-', + 'mid-mid': '+', + right: '|', + 'right-mid': '+', + middle: '|', + } + : { + top: '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + bottom: '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + left: '│', + 'left-mid': '├', + mid: '─', + 'mid-mid': '┼', + right: '│', + 'right-mid': '┤', + middle: '│', + }, + }; + + // Only add head if provided (cli-table3 requires head length to match rows) + if (options.head && options.head.length > 0) { + tableConfig.head = options.head.map((h: string) => color(h, 'primary')); + } + + // Only add colWidths if provided + if (options.colWidths) { + tableConfig.colWidths = options.colWidths; + } + + const tableInstance = new Table(tableConfig); + + rows.forEach((row) => tableInstance.push(row)); + return tableInstance.toString(); +} diff --git a/src/utils/ui/tasks.ts b/src/utils/ui/tasks.ts new file mode 100644 index 00000000..d91c7217 --- /dev/null +++ b/src/utils/ui/tasks.ts @@ -0,0 +1,101 @@ +/** + * Task Lists + * + * Listr2 integration with fallback task runner + * @module utils/ui/tasks + */ + +import { moduleCache } from './types'; +import { isInteractive, isClaudeCodeContext } from './init'; +import { info } from './indicators'; +import { spinner } from './spinner'; + +/** + * Task list item interface + */ +export interface TaskItem { + title: string; + task: (ctx: T) => Promise | void; + skip?: () => boolean | string; +} + +/** + * Task list options + */ +export interface TaskListOptions { + concurrent?: boolean; +} + +/** + * Fallback task runner (no Listr2) + * Uses spinners for sequential task execution + */ +async function runTasksFallback(tasks: TaskItem[]): Promise { + const ctx = {} as T; + + for (const task of tasks) { + if (task.skip) { + const skipResult = task.skip(); + if (skipResult) { + console.log(info(`${task.title} [skipped]`)); + continue; + } + } + + const spin = await spinner(task.title); + try { + await task.task(ctx); + spin.succeed(); + } catch (e) { + spin.fail(`${task.title}: ${(e as Error).message}`); + throw e; + } + } + + return ctx; +} + +/** + * Create a task list for progress display + * Uses Listr2 in TTY mode, falls back to spinners in non-TTY + */ +export async function taskList(tasks: TaskItem[], options: TaskListOptions = {}): Promise { + // Lazy load Listr2 if not already loaded + if (!moduleCache.listr && isInteractive()) { + try { + const listr2 = await import('listr2'); + moduleCache.listr = listr2.Listr; + } catch (_e) { + // Fallback to sequential execution with spinners + return runTasksFallback(tasks); + } + } + + if (moduleCache.listr && isInteractive()) { + // Determine renderer based on context + // Use 'simple' in non-TTY, CI, or Claude Code context + const useSimple = isClaudeCodeContext(); + + const list = new moduleCache.listr( + tasks.map((t) => ({ + title: t.title, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + task: async (ctx: any) => t.task(ctx), + skip: t.skip, + })), + { + concurrent: options.concurrent ?? false, + renderer: useSimple ? 'simple' : 'default', + rendererOptions: { + showSubtasks: true, + collapseSubtasks: false, + }, + } + ); + + return list.run({} as T); + } + + // Fallback: non-interactive or Listr2 not available + return runTasksFallback(tasks); +} diff --git a/src/utils/ui/text.ts b/src/utils/ui/text.ts new file mode 100644 index 00000000..d5ad9c13 --- /dev/null +++ b/src/utils/ui/text.ts @@ -0,0 +1,58 @@ +/** + * Text Formatting + * + * Headers, subheaders, and text utilities + * @module utils/ui/text + */ + +import { moduleCache, COLORS } from './types'; +import { useColors } from './init'; +import { gradientText, bold, dim } from './colors'; + +/** + * Print section header with optional gradient + */ +export function header(text: string, useGradient = true): string { + if (useGradient && useColors()) { + return gradientText(text); + } + return bold(text); +} + +/** + * Print subsection header + */ +export function subheader(text: string): string { + if (!moduleCache.chalk || !useColors()) return text; + return moduleCache.chalk.hex(COLORS.primary)(text); +} + +/** + * Print horizontal rule + */ +export function hr(char = '─', width = 60): string { + if (!useColors()) { + return '-'.repeat(width); + } + return dim(char.repeat(width)); +} + +/** + * Print section header with ═══ borders + * Format: ═══ Title ═══ + */ +export function sectionHeader(title: string): string { + const border = '═══'; + const headerText = `${border} ${title} ${border}`; + // Use gradient + bold for visual appeal + if (moduleCache.gradient && moduleCache.chalk && useColors()) { + return moduleCache.chalk.bold( + moduleCache.gradient([COLORS.primary, COLORS.secondary])(headerText) + ); + } + // Fallback to bold primary color + if (useColors() && moduleCache.chalk) { + return moduleCache.chalk.hex(COLORS.primary).bold(headerText); + } + return headerText; +} diff --git a/src/utils/ui/types.ts b/src/utils/ui/types.ts new file mode 100644 index 00000000..49021add --- /dev/null +++ b/src/utils/ui/types.ts @@ -0,0 +1,54 @@ +/** + * UI Module Internal Types + * + * Type definitions for dynamically imported ESM modules + * @module utils/ui/types + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ChalkInstance = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type BoxenFunction = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GradientStringInstance = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type OraModule = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ListrClass = any; + +/** + * Color palette (Professional cyan-to-blue) + */ +export const COLORS = { + primary: '#00ECFA', // Bright cyan + secondary: '#0099FF', // Sky blue + neutral: '#808080', // Gray +} as const; + +/** + * Module cache for lazy loading + */ +export interface ModuleCache { + chalk: ChalkInstance | null; + boxen: BoxenFunction | null; + gradient: GradientStringInstance | null; + ora: OraModule | null; + listr: ListrClass | null; +} + +/** + * Shared module state + */ +export const moduleCache: ModuleCache = { + chalk: null, + boxen: null, + gradient: null, + ora: null, + listr: null, +}; + +export let initialized = false; + +export function setInitialized(value: boolean): void { + initialized = value; +} diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 7ae85d89..ad1f0777 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -13,855 +13,70 @@ * @module utils/websearch-manager */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { execSync } from 'child_process'; -import { ok, info, warn, fail } from './ui'; -import { getWebSearchConfig } from '../config/unified-config-loader'; +// Re-export types +export type { + GeminiCliStatus, + GrokCliStatus, + OpenCodeCliStatus, + WebSearchReadiness, + WebSearchStatus, + WebSearchCliInfo, +} from './websearch/types'; -// CCS hooks directory -const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); +// Re-export CLI detection functions +export { + getGeminiCliStatus, + hasGeminiCli, + isGeminiAuthenticated, + clearGeminiCliCache, +} from './websearch/gemini-cli'; -// Hook file name -const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; +export { getGrokCliStatus, hasGrokCli, clearGrokCliCache } from './websearch/grok-cli'; -// Buffer time added to max provider timeout for hook timeout (seconds) -const HOOK_TIMEOUT_BUFFER = 30; +export { + getOpenCodeCliStatus, + hasOpenCodeCli, + clearOpenCodeCliCache, +} from './websearch/opencode-cli'; -// Minimum hook timeout in seconds (fallback if no providers configured) -const MIN_HOOK_TIMEOUT = 60; +// Re-export hook management functions +export { + getHookPath, + hasWebSearchHook, + getWebSearchHookConfig, + installWebSearchHook, + uninstallWebSearchHook, +} from './websearch/hook-installer'; -// Path to Claude settings.json -const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +// Re-export hook environment +export { getWebSearchHookEnv } from './websearch/hook-env'; -// ========== Gemini CLI Detection ========== +// Re-export status and readiness functions +export { + getWebSearchCliProviders, + hasAnyWebSearchCli, + getCliInstallHints, + getWebSearchReadiness, + displayWebSearchStatus, +} from './websearch/status'; -/** - * Gemini CLI installation status - */ -export interface GeminiCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -// Cache for Gemini CLI status (per process) -let geminiCliCache: GeminiCliStatus | null = null; - -/** - * Check if Gemini CLI is installed globally - * - * Requires global install: `npm install -g @google/gemini-cli` - * No npx fallback - must be in PATH - * - * @returns Gemini CLI status with path and version - */ -export function getGeminiCliStatus(): GeminiCliStatus { - // Return cached result if available - if (geminiCliCache) { - return geminiCliCache; - } - - const result: GeminiCliStatus = { - installed: false, - path: null, - version: null, - }; - - try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where gemini' : 'which gemini'; - - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows - - if (geminiPath) { - result.installed = true; - result.path = geminiPath; - - // Try to get version - try { - const versionResult = execSync('gemini --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - result.version = versionResult.trim(); - } catch { - // Version check failed, but CLI is installed - result.version = 'unknown'; - } - } - } catch { - // Command not found - Gemini CLI not installed - } - - // Cache result - geminiCliCache = result; - return result; -} - -/** - * Check if Gemini CLI is available (quick boolean check) - */ -export function hasGeminiCli(): boolean { - return getGeminiCliStatus().installed; -} - -/** - * Check if Gemini CLI is authenticated - * - * Gemini CLI stores OAuth credentials in ~/.gemini/oauth_creds.json - * Authentication is done by running `gemini` which opens browser for OAuth. - * Note: There is NO `gemini auth login` command - just run `gemini` to authenticate. - * - * @returns true if oauth_creds.json exists with access_token - */ -export function isGeminiAuthenticated(): boolean { - const oauthPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); - - if (!fs.existsSync(oauthPath)) { - return false; - } - - try { - const content = fs.readFileSync(oauthPath, 'utf8'); - const creds = JSON.parse(content); - // Check if access_token exists (doesn't validate expiry - Gemini CLI handles refresh) - return !!creds.access_token; - } catch { - return false; - } -} - -/** - * Clear Gemini CLI cache (for testing or after installation) - */ -export function clearGeminiCliCache(): void { - geminiCliCache = null; -} - -// ========== Grok CLI Detection ========== - -/** - * Grok CLI installation status - */ -export interface GrokCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -// Cache for Grok CLI status (per process) -let grokCliCache: GrokCliStatus | null = null; - -/** - * Check if Grok CLI is installed globally - * - * Grok CLI (grok-4-cli by lalomorales22) provides web search + X search. - * Requires: `npm install -g grok-cli` and XAI_API_KEY env var. - * - * @returns Grok CLI status with path and version - */ -export function getGrokCliStatus(): GrokCliStatus { - // Return cached result if available - if (grokCliCache) { - return grokCliCache; - } - - const result: GrokCliStatus = { - installed: false, - path: null, - version: null, - }; - - try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where grok' : 'which grok'; - - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows - - if (grokPath) { - result.installed = true; - result.path = grokPath; - - // Try to get version - try { - const versionResult = execSync('grok --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - result.version = versionResult.trim(); - } catch { - // Version check failed, but CLI is installed - result.version = 'unknown'; - } - } - } catch { - // Command not found - Grok CLI not installed - } - - // Cache result - grokCliCache = result; - return result; -} - -/** - * Check if Grok CLI is available (quick boolean check) - */ -export function hasGrokCli(): boolean { - return getGrokCliStatus().installed; -} - -/** - * Clear Grok CLI cache (for testing or after installation) - */ -export function clearGrokCliCache(): void { - grokCliCache = null; -} - -// ========== OpenCode CLI Detection ========== - -/** - * OpenCode CLI installation status - */ -export interface OpenCodeCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -// Cache for OpenCode CLI status (per process) -let opencodeCliCache: OpenCodeCliStatus | null = null; - -/** - * Check if OpenCode CLI is installed globally - * - * OpenCode provides built-in web search via opencode/grok-code model. - * Install: curl -fsSL https://opencode.ai/install | bash - * - * @returns OpenCode CLI status with path and version - */ -export function getOpenCodeCliStatus(): OpenCodeCliStatus { - // Return cached result if available - if (opencodeCliCache) { - return opencodeCliCache; - } - - const result: OpenCodeCliStatus = { - installed: false, - path: null, - version: null, - }; - - try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where opencode' : 'which opencode'; - - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows - - if (opencodePath) { - result.installed = true; - result.path = opencodePath; - - // Try to get version - try { - const versionResult = execSync('opencode --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - result.version = versionResult.trim(); - } catch { - // Version check failed, but CLI is installed - result.version = 'unknown'; - } - } - } catch { - // Command not found - OpenCode CLI not installed - } - - // Cache result - opencodeCliCache = result; - return result; -} - -/** - * Check if OpenCode CLI is available (quick boolean check) - */ -export function hasOpenCodeCli(): boolean { - return getOpenCodeCliStatus().installed; -} - -/** - * Clear OpenCode CLI cache (for testing or after installation) - */ -export function clearOpenCodeCliCache(): void { - opencodeCliCache = null; -} +// Import for local use +import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch'; /** * Clear all CLI caches */ export function clearAllCliCaches(): void { - geminiCliCache = null; - grokCliCache = null; - opencodeCliCache = null; -} - -// ========== CLI Provider Info ========== - -/** - * WebSearch CLI provider information for health checks and UI - */ -export interface WebSearchCliInfo { - /** Provider ID */ - id: 'gemini' | 'grok' | 'opencode'; - /** Display name */ - name: string; - /** CLI command name */ - command: string; - /** Whether CLI is installed */ - installed: boolean; - /** CLI version if installed */ - version: string | null; - /** Install command */ - installCommand: string; - /** Docs URL */ - docsUrl: string; - /** Whether this provider requires an API key */ - requiresApiKey: boolean; - /** API key environment variable name */ - apiKeyEnvVar?: string; - /** Brief description */ - description: string; - /** Free tier available? */ - freeTier: boolean; -} - -/** - * Get all WebSearch CLI providers with their status - */ -export function getWebSearchCliProviders(): WebSearchCliInfo[] { - const geminiStatus = getGeminiCliStatus(); - const grokStatus = getGrokCliStatus(); - const opencodeStatus = getOpenCodeCliStatus(); - - return [ - { - id: 'gemini', - name: 'Gemini CLI', - command: 'gemini', - installed: geminiStatus.installed, - version: geminiStatus.version, - installCommand: 'npm install -g @google/gemini-cli', - docsUrl: 'https://github.com/google-gemini/gemini-cli', - requiresApiKey: false, - description: 'Google Gemini with web search (FREE tier: 1000 req/day)', - freeTier: true, - }, - { - id: 'opencode', - name: 'OpenCode', - command: 'opencode', - installed: opencodeStatus.installed, - version: opencodeStatus.version, - installCommand: 'curl -fsSL https://opencode.ai/install | bash', - docsUrl: 'https://github.com/sst/opencode', - requiresApiKey: false, - description: 'OpenCode with built-in web search (FREE via Zen)', - freeTier: true, - }, - { - id: 'grok', - name: 'Grok CLI', - command: 'grok', - installed: grokStatus.installed, - version: grokStatus.version, - installCommand: 'npm install -g @vibe-kit/grok-cli', - docsUrl: 'https://github.com/superagent-ai/grok-cli', - requiresApiKey: true, - apiKeyEnvVar: 'GROK_API_KEY', - description: 'xAI Grok CLI with AI coding agent capabilities', - freeTier: false, - }, - ]; -} - -/** - * Check if any WebSearch CLI is available - */ -export function hasAnyWebSearchCli(): boolean { - return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); -} - -/** - * Get install hints for CLI-only users when no WebSearch CLI is installed - */ -export function getCliInstallHints(): string[] { - if (hasAnyWebSearchCli()) { - return []; - } - - return [ - '[i] WebSearch: No CLI tools installed', - ' Gemini CLI (FREE): npm i -g @google/gemini-cli', - ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', - ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', - ]; -} - -// ========== Hook Management ========== - -/** - * Install WebSearch hook to ~/.ccs/hooks/ - * - * This hook intercepts WebSearch and executes via Gemini CLI. - * - * @returns true if hook installed successfully - */ -export function installWebSearchHook(): boolean { - try { - const wsConfig = getWebSearchConfig(); - - // Skip if disabled - if (!wsConfig.enabled) { - if (process.env.CCS_DEBUG) { - console.error(info('WebSearch disabled - skipping hook install')); - } - return false; - } - - // Ensure hooks directory exists - if (!fs.existsSync(CCS_HOOKS_DIR)) { - fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); - } - - const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - - // Find the bundled hook script - // In npm package: node_modules/ccs/lib/hooks/ - // In development: lib/hooks/ - const possiblePaths = [ - path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK), - path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK), - ]; - - let sourcePath: string | null = null; - for (const p of possiblePaths) { - if (fs.existsSync(p)) { - sourcePath = p; - break; - } - } - - if (!sourcePath) { - if (process.env.CCS_DEBUG) { - console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`)); - } - return false; - } - - // Copy hook to ~/.ccs/hooks/ - fs.copyFileSync(sourcePath, hookPath); - fs.chmodSync(hookPath, 0o755); - - if (process.env.CCS_DEBUG) { - console.error(info(`Installed WebSearch hook: ${hookPath}`)); - } - - // Ensure hook is configured in settings.json - ensureHookConfig(); - - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); - } - return false; - } -} - -/** - * Check if WebSearch hook is installed - */ -export function hasWebSearchHook(): boolean { - const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - return fs.existsSync(hookPath); -} - -/** - * Get WebSearch hook configuration for settings.json - * Timeout is computed from max provider timeout in config.yaml + buffer - */ -export function getWebSearchHookConfig(): Record { - const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - const wsConfig = getWebSearchConfig(); - - // Compute max timeout from enabled providers - const timeouts: number[] = []; - if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { - timeouts.push(wsConfig.providers.gemini.timeout); - } - if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) { - timeouts.push(wsConfig.providers.opencode.timeout); - } - if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) { - timeouts.push(wsConfig.providers.grok.timeout); - } - - // Hook timeout = max provider timeout + buffer (or minimum if none configured) - const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT; - const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER; - - return { - PreToolUse: [ - { - matcher: 'WebSearch', - hooks: [ - { - type: 'command', - command: `node "${hookPath}"`, - timeout: hookTimeout, - }, - ], - }, - ], - }; -} - -/** - * Ensure WebSearch hook is configured in ~/.claude/settings.json - */ -function ensureHookConfig(): boolean { - try { - const wsConfig = getWebSearchConfig(); - - if (!wsConfig.enabled) { - return false; - } - - // Read existing settings or start fresh - let settings: Record = {}; - if (fs.existsSync(CLAUDE_SETTINGS_PATH)) { - try { - const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'); - settings = JSON.parse(content); - } catch { - if (process.env.CCS_DEBUG) { - console.error(warn('Malformed settings.json - will merge carefully')); - } - } - } - - // Check if WebSearch hook already configured - const hooks = settings.hooks as Record | undefined; - const expectedHookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - const expectedCommand = `node "${expectedHookPath}"`; - - if (hooks?.PreToolUse) { - const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { - const hook = h as Record; - return hook.matcher === 'WebSearch'; - }); - - if (webSearchHookIndex !== -1) { - // Hook exists - check if it needs updating (different command path or timeout) - const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; - const existingHooks = existingHook.hooks as Array>; - const currentHookConfig = getWebSearchHookConfig(); - const expectedHooks = (currentHookConfig.PreToolUse as Array>)[0] - .hooks as Array>; - const expectedTimeout = expectedHooks[0].timeout as number; - - let needsUpdate = false; - - if (existingHooks?.[0]?.command !== expectedCommand) { - existingHooks[0].command = expectedCommand; - needsUpdate = true; - } - - if (existingHooks?.[0]?.timeout !== expectedTimeout) { - existingHooks[0].timeout = expectedTimeout; - needsUpdate = true; - } - - if (needsUpdate) { - fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); - if (process.env.CCS_DEBUG) { - console.error(info('Updated WebSearch hook config in settings.json')); - } - } - return true; - } - } - - // Get hook config - const hookConfig = getWebSearchHookConfig(); - - // Merge hook config into settings - if (!settings.hooks) { - settings.hooks = {}; - } - - const settingsHooks = settings.hooks as Record; - if (!settingsHooks.PreToolUse) { - settingsHooks.PreToolUse = []; - } - - // Add our hook config - const preToolUseHooks = hookConfig.PreToolUse as unknown[]; - settingsHooks.PreToolUse.push(...preToolUseHooks); - - // Ensure ~/.claude directory exists - const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH); - if (!fs.existsSync(claudeDir)) { - fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); - } - - // Write updated settings - fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); - - if (process.env.CCS_DEBUG) { - console.error(info('Added WebSearch hook to settings.json')); - } - - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`)); - } - return false; - } -} - -// ========== Environment Variables for Hook ========== - -/** - * Get environment variables for WebSearch hook configuration. - * - * Simple env vars - hook reads these to control behavior. - * - * @returns Record of environment variables to set before spawning Claude - */ -export function getWebSearchHookEnv(): Record { - const wsConfig = getWebSearchConfig(); - const env: Record = {}; - - // Skip hook entirely if disabled - if (!wsConfig.enabled) { - env.CCS_WEBSEARCH_SKIP = '1'; - return env; - } - - // Pass master switch - env.CCS_WEBSEARCH_ENABLED = '1'; - - // Pass individual provider enabled states - // Hook will only use providers that are BOTH enabled AND installed - if (wsConfig.providers?.gemini?.enabled) { - env.CCS_WEBSEARCH_GEMINI = '1'; - if (wsConfig.providers.gemini.model) { - env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; - } - env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); - } - - if (wsConfig.providers?.opencode?.enabled) { - env.CCS_WEBSEARCH_OPENCODE = '1'; - if (wsConfig.providers.opencode.model) { - env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model; - } - // Use opencode timeout if no gemini timeout set - if (!env.CCS_WEBSEARCH_TIMEOUT) { - env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 90); - } - } - - if (wsConfig.providers?.grok?.enabled) { - env.CCS_WEBSEARCH_GROK = '1'; - // Use grok timeout if no other timeout set - if (!env.CCS_WEBSEARCH_TIMEOUT) { - env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55); - } - } - - // Default timeout if none set - if (!env.CCS_WEBSEARCH_TIMEOUT) { - env.CCS_WEBSEARCH_TIMEOUT = '55'; - } - - return env; -} - -// ========== WebSearch Readiness Status ========== - -/** - * WebSearch availability status for third-party profiles - */ -export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; - -/** - * WebSearch status for display - */ -export interface WebSearchStatus { - readiness: WebSearchReadiness; - geminiCli: boolean; - geminiAuthenticated: boolean; - grokCli: boolean; - opencodeCli: boolean; - message: string; -} - -/** - * Get WebSearch readiness status for display - * - * Called on third-party profile startup to inform user. - * Checks both installation AND authentication status for Gemini CLI. - */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - - // Check if WebSearch is disabled entirely - if (!wsConfig.enabled) { - return { - readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, - message: 'Disabled in config', - }; - } - - // Check all CLIs - const geminiInstalled = hasGeminiCli(); - const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); - const grokInstalled = hasGrokCli(); - const opencodeInstalled = hasOpenCodeCli(); - - // Build message based on installed + authenticated CLIs - const readyClis: string[] = []; - const needsAuthClis: string[] = []; - - // Gemini requires auth check - if (geminiInstalled) { - if (geminiAuthed) { - readyClis.push('Gemini'); - } else { - needsAuthClis.push('Gemini'); - } - } - - // Other CLIs don't require auth check (for now) - if (grokInstalled) readyClis.push('Grok'); - if (opencodeInstalled) readyClis.push('OpenCode'); - - // Determine overall status - if (readyClis.length > 0) { - // At least one CLI is ready - return { - readiness: 'ready', - geminiCli: geminiInstalled, - geminiAuthenticated: geminiAuthed, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Ready (${readyClis.join(' + ')})`, - }; - } - - if (needsAuthClis.length > 0) { - // CLIs installed but need auth - return { - readiness: 'needs_auth', - geminiCli: geminiInstalled, - geminiAuthenticated: false, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Gemini: run 'gemini' to login`, - }; - } - - return { - readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, - message: 'Install: npm i -g @google/gemini-cli', - }; -} - -/** - * Display WebSearch status (single line, equilibrium UX) - * - * Only call for third-party profiles. - * Shows detailed install hints when no CLI is installed. - */ -export function displayWebSearchStatus(): void { - const status = getWebSearchReadiness(); - - switch (status.readiness) { - case 'ready': - console.error(ok(`WebSearch: ${status.message}`)); - break; - case 'needs_auth': - // CLI installed but not authenticated - show warning - console.error(warn(`WebSearch: ${status.message}`)); - break; - case 'unavailable': - console.error(fail(`WebSearch: ${status.message}`)); - // Show install hints for CLI-only users - const hints = getCliInstallHints(); - if (hints.length > 0) { - for (const hint of hints) { - console.error(info(hint)); - } - } - break; - } + clearGeminiCliCache(); + clearGrokCliCache(); + clearOpenCodeCliCache(); } // ========== Backward Compatibility Exports ========== -// These are kept for imports that haven't been updated yet /** * @deprecated Use installWebSearchHook instead - MCP is no longer used */ export function ensureMcpWebSearch(): boolean { - // No-op - MCP is no longer used return false; } - -/** - * @deprecated MCP is no longer used - */ -export function hasMcpWebSearch(): boolean { - return false; -} - -/** - * @deprecated MCP is no longer used - */ -export function getMcpConfigPath(): string { - return path.join(os.homedir(), '.claude', '.mcp.json'); -} diff --git a/src/utils/websearch/gemini-cli.ts b/src/utils/websearch/gemini-cli.ts new file mode 100644 index 00000000..1069355e --- /dev/null +++ b/src/utils/websearch/gemini-cli.ts @@ -0,0 +1,114 @@ +/** + * Gemini CLI Detection + * + * Detects and manages Gemini CLI installation status. + * + * @module utils/websearch/gemini-cli + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import type { GeminiCliStatus } from './types'; + +// Cache for Gemini CLI status (per process) +let geminiCliCache: GeminiCliStatus | null = null; + +/** + * Check if Gemini CLI is installed globally + * + * Requires global install: `npm install -g @google/gemini-cli` + * No npx fallback - must be in PATH + * + * @returns Gemini CLI status with path and version + */ +export function getGeminiCliStatus(): GeminiCliStatus { + // Return cached result if available + if (geminiCliCache) { + return geminiCliCache; + } + + const result: GeminiCliStatus = { + installed: false, + path: undefined, + version: undefined, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where gemini' : 'which gemini'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (geminiPath) { + result.installed = true; + result.path = geminiPath; + + // Try to get version + try { + const versionResult = execSync('gemini --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - Gemini CLI not installed + } + + // Cache result + geminiCliCache = result; + return result; +} + +/** + * Check if Gemini CLI is available (quick boolean check) + */ +export function hasGeminiCli(): boolean { + return getGeminiCliStatus().installed; +} + +/** + * Check if Gemini CLI is authenticated + * + * Gemini CLI stores OAuth credentials in ~/.gemini/oauth_creds.json + * Authentication is done by running `gemini` which opens browser for OAuth. + * Note: There is NO `gemini auth login` command - just run `gemini` to authenticate. + * + * @returns true if oauth_creds.json exists with access_token + */ +export function isGeminiAuthenticated(): boolean { + const oauthPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + + if (!fs.existsSync(oauthPath)) { + return false; + } + + try { + const content = fs.readFileSync(oauthPath, 'utf8'); + const creds = JSON.parse(content); + // Check if access_token exists (doesn't validate expiry - Gemini CLI handles refresh) + return !!creds.access_token; + } catch { + return false; + } +} + +/** + * Clear Gemini CLI cache (for testing or after installation) + */ +export function clearGeminiCliCache(): void { + geminiCliCache = null; +} diff --git a/src/utils/websearch/grok-cli.ts b/src/utils/websearch/grok-cli.ts new file mode 100644 index 00000000..3115e6bb --- /dev/null +++ b/src/utils/websearch/grok-cli.ts @@ -0,0 +1,85 @@ +/** + * Grok CLI Detection + * + * Detects and manages Grok CLI installation status. + * + * @module utils/websearch/grok-cli + */ + +import { execSync } from 'child_process'; +import type { GrokCliStatus } from './types'; + +// Cache for Grok CLI status (per process) +let grokCliCache: GrokCliStatus | null = null; + +/** + * Check if Grok CLI is installed globally + * + * Grok CLI (grok-4-cli by lalomorales22) provides web search + X search. + * Requires: `npm install -g grok-cli` and XAI_API_KEY env var. + * + * @returns Grok CLI status with path and version + */ +export function getGrokCliStatus(): GrokCliStatus { + // Return cached result if available + if (grokCliCache) { + return grokCliCache; + } + + const result: GrokCliStatus = { + installed: false, + path: undefined, + version: undefined, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where grok' : 'which grok'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (grokPath) { + result.installed = true; + result.path = grokPath; + + // Try to get version + try { + const versionResult = execSync('grok --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - Grok CLI not installed + } + + // Cache result + grokCliCache = result; + return result; +} + +/** + * Check if Grok CLI is available (quick boolean check) + */ +export function hasGrokCli(): boolean { + return getGrokCliStatus().installed; +} + +/** + * Clear Grok CLI cache (for testing or after installation) + */ +export function clearGrokCliCache(): void { + grokCliCache = null; +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts new file mode 100644 index 00000000..5a806783 --- /dev/null +++ b/src/utils/websearch/hook-config.ts @@ -0,0 +1,180 @@ +/** + * WebSearch Hook Configuration + * + * Manages hook configuration in Claude settings.json. + * + * @module utils/websearch/hook-config + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; + +// Path to Claude settings.json +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); + +// Hook file name +const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; + +// Buffer time added to max provider timeout for hook timeout (seconds) +const HOOK_TIMEOUT_BUFFER = 30; + +// Minimum hook timeout in seconds (fallback if no providers configured) +const MIN_HOOK_TIMEOUT = 60; + +/** + * Get path to WebSearch hook + */ +export function getHookPath(): string { + return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); +} + +/** + * Get WebSearch hook configuration for settings.json + * Timeout is computed from max provider timeout in config.yaml + buffer + */ +export function getWebSearchHookConfig(): Record { + const hookPath = getHookPath(); + const wsConfig = getWebSearchConfig(); + + // Compute max timeout from enabled providers + const timeouts: number[] = []; + if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { + timeouts.push(wsConfig.providers.gemini.timeout); + } + if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) { + timeouts.push(wsConfig.providers.opencode.timeout); + } + if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) { + timeouts.push(wsConfig.providers.grok.timeout); + } + + // Hook timeout = max provider timeout + buffer (or minimum if none configured) + const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT; + const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER; + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: hookTimeout, + }, + ], + }, + ], + }; +} + +/** + * Ensure WebSearch hook is configured in ~/.claude/settings.json + */ +export function ensureHookConfig(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + if (!wsConfig.enabled) { + return false; + } + + // Read existing settings or start fresh + let settings: Record = {}; + if (fs.existsSync(CLAUDE_SETTINGS_PATH)) { + try { + const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'); + settings = JSON.parse(content); + } catch { + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed settings.json - will merge carefully')); + } + } + } + + // Check if WebSearch hook already configured + const hooks = settings.hooks as Record | undefined; + const expectedHookPath = getHookPath(); + const expectedCommand = `node "${expectedHookPath}"`; + + if (hooks?.PreToolUse) { + const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { + const hook = h as Record; + return hook.matcher === 'WebSearch'; + }); + + if (webSearchHookIndex !== -1) { + // Hook exists - check if it needs updating + const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; + const existingHooks = existingHook.hooks as Array>; + const currentHookConfig = getWebSearchHookConfig(); + const expectedHooks = (currentHookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + if (existingHooks?.[0]?.command !== expectedCommand) { + existingHooks[0].command = expectedCommand; + needsUpdate = true; + } + + if (existingHooks?.[0]?.timeout !== expectedTimeout) { + existingHooks[0].timeout = expectedTimeout; + needsUpdate = true; + } + + if (needsUpdate) { + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error(info('Updated WebSearch hook config in settings.json')); + } + } + return true; + } + } + + // Get hook config + const hookConfig = getWebSearchHookConfig(); + + // Merge hook config into settings + if (!settings.hooks) { + settings.hooks = {}; + } + + const settingsHooks = settings.hooks as Record; + if (!settingsHooks.PreToolUse) { + settingsHooks.PreToolUse = []; + } + + // Add our hook config + const preToolUseHooks = hookConfig.PreToolUse as unknown[]; + settingsHooks.PreToolUse.push(...preToolUseHooks); + + // Ensure ~/.claude directory exists + const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + // Write updated settings + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + + if (process.env.CCS_DEBUG) { + console.error(info('Added WebSearch hook to settings.json')); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts new file mode 100644 index 00000000..ee3cf2b9 --- /dev/null +++ b/src/utils/websearch/hook-env.ts @@ -0,0 +1,66 @@ +/** + * WebSearch Hook Environment Variables + * + * Provides environment variables for WebSearch hook configuration. + * + * @module utils/websearch/hook-env + */ + +import { getWebSearchConfig } from '../../config/unified-config-loader'; + +/** + * Get environment variables for WebSearch hook configuration. + * + * Simple env vars - hook reads these to control behavior. + * + * @returns Record of environment variables to set before spawning Claude + */ +export function getWebSearchHookEnv(): Record { + const wsConfig = getWebSearchConfig(); + const env: Record = {}; + + // Skip hook entirely if disabled + if (!wsConfig.enabled) { + env.CCS_WEBSEARCH_SKIP = '1'; + return env; + } + + // Pass master switch + env.CCS_WEBSEARCH_ENABLED = '1'; + + // Pass individual provider enabled states + // Hook will only use providers that are BOTH enabled AND installed + if (wsConfig.providers?.gemini?.enabled) { + env.CCS_WEBSEARCH_GEMINI = '1'; + if (wsConfig.providers.gemini.model) { + env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; + } + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); + } + + if (wsConfig.providers?.opencode?.enabled) { + env.CCS_WEBSEARCH_OPENCODE = '1'; + if (wsConfig.providers.opencode.model) { + env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model; + } + // Use opencode timeout if no gemini timeout set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 90); + } + } + + if (wsConfig.providers?.grok?.enabled) { + env.CCS_WEBSEARCH_GROK = '1'; + // Use grok timeout if no other timeout set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55); + } + } + + // Default timeout if none set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = '55'; + } + + return env; +} diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts new file mode 100644 index 00000000..4fc4d66b --- /dev/null +++ b/src/utils/websearch/hook-installer.ts @@ -0,0 +1,127 @@ +/** + * WebSearch Hook Installer + * + * Manages installation and uninstallation of the WebSearch hook. + * + * @module utils/websearch/hook-installer + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getHookPath, ensureHookConfig } from './hook-config'; + +// Re-export from hook-config for backward compatibility +export { getHookPath, getWebSearchHookConfig } from './hook-config'; + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); + +// Hook file name +const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; + +/** + * Check if WebSearch hook is installed + */ +export function hasWebSearchHook(): boolean { + return fs.existsSync(getHookPath()); +} + +/** + * Install WebSearch hook to ~/.ccs/hooks/ + * + * This hook intercepts WebSearch and executes via Gemini CLI. + * + * @returns true if hook installed successfully + */ +export function installWebSearchHook(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + // Skip if disabled + if (!wsConfig.enabled) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch disabled - skipping hook install')); + } + return false; + } + + // Ensure hooks directory exists + if (!fs.existsSync(CCS_HOOKS_DIR)) { + fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); + } + + const hookPath = getHookPath(); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK), + path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`)); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch hook: ${hookPath}`)); + } + + // Ensure hook is configured in settings.json + ensureHookConfig(); + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Uninstall WebSearch hook from ~/.ccs/hooks/ + * + * @returns true if hook uninstalled successfully + */ +export function uninstallWebSearchHook(): boolean { + try { + const hookPath = getHookPath(); + + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled WebSearch hook: ${hookPath}`)); + } + } + + // TODO: Optionally remove from settings.json + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to uninstall WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts new file mode 100644 index 00000000..8c8826f1 --- /dev/null +++ b/src/utils/websearch/index.ts @@ -0,0 +1,54 @@ +/** + * WebSearch Module Barrel Export + * + * Re-exports all WebSearch functionality from submodules. + * + * @module utils/websearch + */ + +// Types +export type { + GeminiCliStatus, + GrokCliStatus, + OpenCodeCliStatus, + WebSearchReadiness, + WebSearchStatus, + WebSearchCliInfo, + WebSearchProviderConfig, + WebSearchConfig, +} from './types'; + +// Gemini CLI +export { + getGeminiCliStatus, + hasGeminiCli, + isGeminiAuthenticated, + clearGeminiCliCache, +} from './gemini-cli'; + +// Grok CLI +export { getGrokCliStatus, hasGrokCli, clearGrokCliCache } from './grok-cli'; + +// OpenCode CLI +export { getOpenCodeCliStatus, hasOpenCodeCli, clearOpenCodeCliCache } from './opencode-cli'; + +// Hook Installation +export { + getHookPath, + hasWebSearchHook, + getWebSearchHookConfig, + installWebSearchHook, + uninstallWebSearchHook, +} from './hook-installer'; + +// Hook Environment +export { getWebSearchHookEnv } from './hook-env'; + +// Status and Readiness +export { + getWebSearchCliProviders, + hasAnyWebSearchCli, + getCliInstallHints, + getWebSearchReadiness, + displayWebSearchStatus, +} from './status'; diff --git a/src/utils/websearch/opencode-cli.ts b/src/utils/websearch/opencode-cli.ts new file mode 100644 index 00000000..36a04cfc --- /dev/null +++ b/src/utils/websearch/opencode-cli.ts @@ -0,0 +1,85 @@ +/** + * OpenCode CLI Detection + * + * Detects and manages OpenCode CLI installation status. + * + * @module utils/websearch/opencode-cli + */ + +import { execSync } from 'child_process'; +import type { OpenCodeCliStatus } from './types'; + +// Cache for OpenCode CLI status (per process) +let opencodeCliCache: OpenCodeCliStatus | null = null; + +/** + * Check if OpenCode CLI is installed globally + * + * OpenCode provides built-in web search via opencode/grok-code model. + * Install: curl -fsSL https://opencode.ai/install | bash + * + * @returns OpenCode CLI status with path and version + */ +export function getOpenCodeCliStatus(): OpenCodeCliStatus { + // Return cached result if available + if (opencodeCliCache) { + return opencodeCliCache; + } + + const result: OpenCodeCliStatus = { + installed: false, + path: undefined, + version: undefined, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where opencode' : 'which opencode'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (opencodePath) { + result.installed = true; + result.path = opencodePath; + + // Try to get version + try { + const versionResult = execSync('opencode --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - OpenCode CLI not installed + } + + // Cache result + opencodeCliCache = result; + return result; +} + +/** + * Check if OpenCode CLI is available (quick boolean check) + */ +export function hasOpenCodeCli(): boolean { + return getOpenCodeCliStatus().installed; +} + +/** + * Clear OpenCode CLI cache (for testing or after installation) + */ +export function clearOpenCodeCliCache(): void { + opencodeCliCache = null; +} diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts new file mode 100644 index 00000000..7c47462e --- /dev/null +++ b/src/utils/websearch/status.ts @@ -0,0 +1,191 @@ +/** + * WebSearch Status and Readiness + * + * Provides status checking and display functions for WebSearch. + * + * @module utils/websearch/status + */ + +import { ok, warn, fail, info } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getGeminiCliStatus, hasGeminiCli, isGeminiAuthenticated } from './gemini-cli'; +import { getGrokCliStatus, hasGrokCli } from './grok-cli'; +import { getOpenCodeCliStatus, hasOpenCodeCli } from './opencode-cli'; +import type { WebSearchCliInfo, WebSearchStatus } from './types'; + +/** + * Get all WebSearch CLI providers with their status + */ +export function getWebSearchCliProviders(): WebSearchCliInfo[] { + const geminiStatus = getGeminiCliStatus(); + const grokStatus = getGrokCliStatus(); + const opencodeStatus = getOpenCodeCliStatus(); + + return [ + { + id: 'gemini', + name: 'Gemini CLI', + command: 'gemini', + installed: geminiStatus.installed, + version: geminiStatus.version ?? null, + installCommand: 'npm install -g @google/gemini-cli', + docsUrl: 'https://github.com/google-gemini/gemini-cli', + requiresApiKey: false, + description: 'Google Gemini with web search (FREE tier: 1000 req/day)', + freeTier: true, + }, + { + id: 'opencode', + name: 'OpenCode', + command: 'opencode', + installed: opencodeStatus.installed, + version: opencodeStatus.version ?? null, + installCommand: 'curl -fsSL https://opencode.ai/install | bash', + docsUrl: 'https://github.com/sst/opencode', + requiresApiKey: false, + description: 'OpenCode with built-in web search (FREE via Zen)', + freeTier: true, + }, + { + id: 'grok', + name: 'Grok CLI', + command: 'grok', + installed: grokStatus.installed, + version: grokStatus.version ?? null, + installCommand: 'npm install -g @vibe-kit/grok-cli', + docsUrl: 'https://github.com/superagent-ai/grok-cli', + requiresApiKey: true, + apiKeyEnvVar: 'GROK_API_KEY', + description: 'xAI Grok CLI with AI coding agent capabilities', + freeTier: false, + }, + ]; +} + +/** + * Check if any WebSearch CLI is available + */ +export function hasAnyWebSearchCli(): boolean { + return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); +} + +/** + * Get install hints for CLI-only users when no WebSearch CLI is installed + */ +export function getCliInstallHints(): string[] { + if (hasAnyWebSearchCli()) { + return []; + } + + return [ + '[i] WebSearch: No CLI tools installed', + ' Gemini CLI (FREE): npm i -g @google/gemini-cli', + ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', + ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', + ]; +} + +/** + * Get WebSearch readiness status for display + * + * Called on third-party profile startup to inform user. + * Checks both installation AND authentication status for Gemini CLI. + */ +export function getWebSearchReadiness(): WebSearchStatus { + const wsConfig = getWebSearchConfig(); + + // Check if WebSearch is disabled entirely + if (!wsConfig.enabled) { + return { + readiness: 'unavailable', + geminiCli: false, + geminiAuthenticated: false, + grokCli: false, + opencodeCli: false, + message: 'Disabled in config', + }; + } + + // Check all CLIs + const geminiInstalled = hasGeminiCli(); + const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); + const grokInstalled = hasGrokCli(); + const opencodeInstalled = hasOpenCodeCli(); + + // Build message based on installed + authenticated CLIs + const readyClis: string[] = []; + const needsAuthClis: string[] = []; + + // Gemini requires auth check + if (geminiInstalled) { + if (geminiAuthed) { + readyClis.push('Gemini'); + } else { + needsAuthClis.push('Gemini'); + } + } + + // Other CLIs don't require auth check (for now) + if (grokInstalled) readyClis.push('Grok'); + if (opencodeInstalled) readyClis.push('OpenCode'); + + // Determine overall status + if (readyClis.length > 0) { + return { + readiness: 'ready', + geminiCli: geminiInstalled, + geminiAuthenticated: geminiAuthed, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Ready (${readyClis.join(' + ')})`, + }; + } + + if (needsAuthClis.length > 0) { + return { + readiness: 'needs_auth', + geminiCli: geminiInstalled, + geminiAuthenticated: false, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Gemini: run 'gemini' to login`, + }; + } + + return { + readiness: 'unavailable', + geminiCli: false, + geminiAuthenticated: false, + grokCli: false, + opencodeCli: false, + message: 'Install: npm i -g @google/gemini-cli', + }; +} + +/** + * Display WebSearch status (single line, equilibrium UX) + * + * Only call for third-party profiles. + * Shows detailed install hints when no CLI is installed. + */ +export function displayWebSearchStatus(): void { + const status = getWebSearchReadiness(); + + switch (status.readiness) { + case 'ready': + console.error(ok(`WebSearch: ${status.message}`)); + break; + case 'needs_auth': + console.error(warn(`WebSearch: ${status.message}`)); + break; + case 'unavailable': + console.error(fail(`WebSearch: ${status.message}`)); + const hints = getCliInstallHints(); + if (hints.length > 0) { + for (const hint of hints) { + console.error(info(hint)); + } + } + break; + } +} diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts new file mode 100644 index 00000000..b8ff474c --- /dev/null +++ b/src/utils/websearch/types.ts @@ -0,0 +1,93 @@ +/** + * WebSearch Type Definitions + * + * Contains all type definitions for WebSearch CLI providers and status. + * + * @module utils/websearch/types + */ + +import type { ComponentStatus } from '../../types/utils'; + +/** + * Gemini CLI installation status + * @deprecated Use ComponentStatus directly + */ +export type GeminiCliStatus = ComponentStatus; + +/** + * Grok CLI installation status + * @deprecated Use ComponentStatus directly + */ +export type GrokCliStatus = ComponentStatus; + +/** + * OpenCode CLI installation status + * @deprecated Use ComponentStatus directly + */ +export type OpenCodeCliStatus = ComponentStatus; + +/** + * WebSearch availability status for third-party profiles + */ +export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; + +/** + * WebSearch status for display + */ +export interface WebSearchStatus { + readiness: WebSearchReadiness; + geminiCli: boolean; + geminiAuthenticated: boolean; + grokCli: boolean; + opencodeCli: boolean; + message: string; +} + +/** + * WebSearch CLI provider information for health checks and UI + */ +export interface WebSearchCliInfo { + /** Provider ID */ + id: 'gemini' | 'grok' | 'opencode'; + /** Display name */ + name: string; + /** CLI command name */ + command: string; + /** Whether CLI is installed */ + installed: boolean; + /** CLI version if installed */ + version: string | null; + /** Install command */ + installCommand: string; + /** Docs URL */ + docsUrl: string; + /** Whether this provider requires an API key */ + requiresApiKey: boolean; + /** API key environment variable name */ + apiKeyEnvVar?: string; + /** Brief description */ + description: string; + /** Free tier available? */ + freeTier: boolean; +} + +/** + * WebSearch provider configuration from config.yaml + */ +export interface WebSearchProviderConfig { + enabled?: boolean; + model?: string; + timeout?: number; +} + +/** + * WebSearch configuration from config.yaml + */ +export interface WebSearchConfig { + enabled: boolean; + providers?: { + gemini?: WebSearchProviderConfig; + opencode?: WebSearchProviderConfig; + grok?: WebSearchProviderConfig; + }; +} diff --git a/src/web-server/data-aggregator.ts b/src/web-server/data-aggregator.ts index 0ebd882f..2d8c26b2 100644 --- a/src/web-server/data-aggregator.ts +++ b/src/web-server/data-aggregator.ts @@ -1,516 +1,7 @@ /** - * Data Aggregator for Claude Code Usage Analytics + * Data Aggregator - Re-export from modularized location * - * Aggregates raw JSONL entries into daily, monthly, and session summaries. - * Uses model-pricing.ts for cost calculations. + * @deprecated Import from './usage/data-aggregator' instead */ -import { type RawUsageEntry } from './jsonl-parser'; -import { calculateCost } from './model-pricing'; -import { - type ModelBreakdown, - type DailyUsage, - type HourlyUsage, - type MonthlyUsage, - type SessionUsage, -} from './usage-types'; - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** Extract YYYY-MM-DD from ISO timestamp */ -function extractDate(timestamp: string): string { - return timestamp.slice(0, 10); -} - -/** Extract YYYY-MM from ISO timestamp */ -function extractMonth(timestamp: string): string { - return timestamp.slice(0, 7); -} - -/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ -function extractHour(timestamp: string): string { - const date = timestamp.slice(0, 10); - const hour = timestamp.slice(11, 13) || '00'; - return `${date} ${hour}:00`; -} - -/** Create model breakdown from accumulated data */ -function createModelBreakdown( - modelName: string, - inputTokens: number, - outputTokens: number, - cacheCreationTokens: number, - cacheReadTokens: number -): ModelBreakdown { - const cost = calculateCost( - { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, - modelName - ); - - return { - modelName, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - cost, - }; -} - -/** Accumulator for per-model token counts */ -interface ModelAccumulator { - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; -} - -// ============================================================================ -// DAILY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into daily usage summaries - * Groups by date (YYYY-MM-DD), calculates costs per model - */ -export function aggregateDailyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): DailyUsage[] { - // Group entries by date - const byDate = new Map(); - - for (const entry of entries) { - const date = extractDate(entry.timestamp); - const existing = byDate.get(date) || []; - existing.push(entry); - byDate.set(date, existing); - } - - // Build daily summaries - const dailyUsage: DailyUsage[] = []; - - for (const [date, dateEntries] of byDate) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of dateEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - dailyUsage.push({ - date, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by date descending (most recent first) - dailyUsage.sort((a, b) => b.date.localeCompare(a.date)); - - return dailyUsage; -} - -// ============================================================================ -// HOURLY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into hourly usage summaries - * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model - */ -export function aggregateHourlyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): HourlyUsage[] { - // Group entries by hour - const byHour = new Map(); - - for (const entry of entries) { - const hour = extractHour(entry.timestamp); - const existing = byHour.get(hour) || []; - existing.push(entry); - byHour.set(hour, existing); - } - - // Build hourly summaries - const hourlyUsage: HourlyUsage[] = []; - - for (const [hour, hourEntries] of byHour) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of hourEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - hourlyUsage.push({ - hour, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by hour descending (most recent first) - hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); - - return hourlyUsage; -} - -// ============================================================================ -// MONTHLY AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into monthly usage summaries - * Groups by month (YYYY-MM), calculates costs per model - */ -export function aggregateMonthlyUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): MonthlyUsage[] { - // Group entries by month - const byMonth = new Map(); - - for (const entry of entries) { - const month = extractMonth(entry.timestamp); - const existing = byMonth.get(month) || []; - existing.push(entry); - byMonth.set(month, existing); - } - - // Build monthly summaries - const monthlyUsage: MonthlyUsage[] = []; - - for (const [month, monthEntries] of byMonth) { - // Aggregate by model - const modelMap = new Map(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - - for (const entry of monthEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - monthlyUsage.push({ - month, - source, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - totalCost, - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - }); - } - - // Sort by month descending (most recent first) - monthlyUsage.sort((a, b) => b.month.localeCompare(a.month)); - - return monthlyUsage; -} - -// ============================================================================ -// SESSION AGGREGATION -// ============================================================================ - -/** - * Aggregate raw entries into session usage summaries - * Groups by sessionId, tracks last activity and versions - */ -export function aggregateSessionUsage( - entries: RawUsageEntry[], - source = 'custom-parser' -): SessionUsage[] { - // Group entries by sessionId - const bySession = new Map(); - - for (const entry of entries) { - if (!entry.sessionId) continue; - const existing = bySession.get(entry.sessionId) || []; - existing.push(entry); - bySession.set(entry.sessionId, existing); - } - - // Build session summaries - const sessionUsage: SessionUsage[] = []; - - for (const [sessionId, sessionEntries] of bySession) { - // Aggregate by model - const modelMap = new Map(); - const versions = new Set(); - let totalInput = 0; - let totalOutput = 0; - let totalCacheCreation = 0; - let totalCacheRead = 0; - let lastActivity = ''; - let projectPath = ''; - - for (const entry of sessionEntries) { - const model = entry.model; - const acc = modelMap.get(model) || { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - }; - - acc.inputTokens += entry.inputTokens; - acc.outputTokens += entry.outputTokens; - acc.cacheCreationTokens += entry.cacheCreationTokens; - acc.cacheReadTokens += entry.cacheReadTokens; - modelMap.set(model, acc); - - totalInput += entry.inputTokens; - totalOutput += entry.outputTokens; - totalCacheCreation += entry.cacheCreationTokens; - totalCacheRead += entry.cacheReadTokens; - - // Track latest timestamp - if (entry.timestamp > lastActivity) { - lastActivity = entry.timestamp; - } - - // Track versions - if (entry.version) { - versions.add(entry.version); - } - - // Use project path from entry - if (entry.projectPath) { - projectPath = entry.projectPath; - } - } - - // Build model breakdowns - const modelBreakdowns: ModelBreakdown[] = []; - let totalCost = 0; - - for (const [modelName, acc] of modelMap) { - const breakdown = createModelBreakdown( - modelName, - acc.inputTokens, - acc.outputTokens, - acc.cacheCreationTokens, - acc.cacheReadTokens - ); - modelBreakdowns.push(breakdown); - totalCost += breakdown.cost; - } - - // Sort breakdowns by cost descending - modelBreakdowns.sort((a, b) => b.cost - a.cost); - - sessionUsage.push({ - sessionId, - projectPath, - inputTokens: totalInput, - outputTokens: totalOutput, - cacheCreationTokens: totalCacheCreation, - cacheReadTokens: totalCacheRead, - cost: totalCost, - totalCost, - lastActivity, - versions: Array.from(versions), - modelsUsed: Array.from(modelMap.keys()), - modelBreakdowns, - source, - }); - } - - // Sort by last activity descending (most recent first) - sessionUsage.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); - - return sessionUsage; -} - -// ============================================================================ -// MAIN DATA LOADER (drop-in replacement for better-ccusage) -// ============================================================================ - -import { scanProjectsDirectory, type ParserOptions } from './jsonl-parser'; - -/** - * Load daily usage data (replaces better-ccusage loadDailyUsageData) - */ -export async function loadDailyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateDailyUsage(entries); -} - -/** - * Load hourly usage data for today's chart - */ -export async function loadHourlyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateHourlyUsage(entries); -} - -/** - * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) - */ -export async function loadMonthlyUsageData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateMonthlyUsage(entries); -} - -/** - * Load session data (replaces better-ccusage loadSessionData) - */ -export async function loadSessionData(options?: ParserOptions): Promise { - const entries = await scanProjectsDirectory(options); - return aggregateSessionUsage(entries); -} - -/** - * Load all usage data in a single pass (more efficient) - */ -export async function loadAllUsageData(options?: ParserOptions): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - const entries = await scanProjectsDirectory(options); - return { - daily: aggregateDailyUsage(entries), - hourly: aggregateHourlyUsage(entries), - monthly: aggregateMonthlyUsage(entries), - session: aggregateSessionUsage(entries), - }; -} +export * from './usage/data-aggregator'; diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 8403dcaa..25696d85 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -1,60 +1,43 @@ /** - * Health Check Service (Phase 06) + * Health Check Service * - * Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output. + * Orchestrates comprehensive health checks for CCS dashboard matching `ccs doctor` output. * Groups: System, Environment, Configuration, Profiles & Delegation, System Health, CLIProxy, OAuth Readiness */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; import { getCcsDir, getConfigPath } from '../utils/config-manager'; -import { - isCLIProxyInstalled, - getInstalledCliproxyVersion, - getCLIProxyPath, - getConfigPath as getCliproxyConfigPath, - getAllAuthStatus, - CLIPROXY_DEFAULT_PORT, -} from '../cliproxy'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import packageJson from '../../package.json'; -import { getEnvironmentDiagnostics } from '../management/environment-diagnostics'; -import { checkAuthCodePorts } from '../management/oauth-port-diagnostics'; -import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager'; -export interface HealthCheck { - id: string; - name: string; - status: 'ok' | 'warning' | 'error' | 'info'; - message: string; - details?: string; - fix?: string; - fixable?: boolean; -} +// Import all check functions from modular components +import { + type HealthCheck, + type HealthGroup, + type HealthReport, + checkClaudeCli, + checkCcsDirectory, + checkPermissions, + checkCcsSymlinks, + checkSettingsSymlinks, + checkEnvironment, + checkConfigFile, + checkSettingsFiles, + checkClaudeSettings, + checkProfiles, + checkInstances, + checkDelegation, + checkCliproxyBinary, + checkCliproxyConfig, + checkOAuthProviders, + checkCliproxyPort, + checkOAuthPortsForDashboard, + checkWebSearchClis, +} from './health'; -export interface HealthGroup { - id: string; - name: string; - icon: string; - checks: HealthCheck[]; -} - -export interface HealthReport { - timestamp: number; - version: string; - groups: HealthGroup[]; - checks: HealthCheck[]; // Flat list for backward compatibility - summary: { - total: number; - passed: number; - warnings: number; - errors: number; - info: number; - }; -} +// Re-export types for external consumers +export type { HealthCheck, HealthGroup, HealthReport }; /** * Run all health checks and return report @@ -73,7 +56,7 @@ export async function runHealthChecks(): Promise { systemChecks.push(checkCcsDirectory(ccsDir)); groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks }); - // Group 2: Environment (OAuth readiness diagnostics) + // Group 2: Environment const envChecks: HealthCheck[] = []; envChecks.push(checkEnvironment()); groups.push({ id: 'environment', name: 'Environment', icon: 'Laptop', checks: envChecks }); @@ -107,12 +90,7 @@ export async function runHealthChecks(): Promise { healthChecks.push(checkPermissions(ccsDir)); healthChecks.push(checkCcsSymlinks()); healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); - groups.push({ - id: 'system-health', - name: 'System Health', - icon: 'Shield', - checks: healthChecks, - }); + groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks }); // Group 6: CLIProxy const cliproxyChecks: HealthCheck[] = []; @@ -120,14 +98,9 @@ export async function runHealthChecks(): Promise { cliproxyChecks.push(checkCliproxyConfig()); cliproxyChecks.push(...checkOAuthProviders()); cliproxyChecks.push(await checkCliproxyPort()); - groups.push({ - id: 'cliproxy', - name: 'CLIProxy (OAuth)', - icon: 'Zap', - checks: cliproxyChecks, - }); + groups.push({ id: 'cliproxy', name: 'CLIProxy (OAuth)', icon: 'Zap', checks: cliproxyChecks }); - // Group 7: OAuth Readiness (port availability) + // Group 7: OAuth Readiness const oauthReadinessChecks: HealthCheck[] = []; oauthReadinessChecks.push(...(await checkOAuthPortsForDashboard())); groups.push({ @@ -140,12 +113,7 @@ export async function runHealthChecks(): Promise { // Group 8: WebSearch CLI Providers const websearchChecks: HealthCheck[] = []; websearchChecks.push(...checkWebSearchClis()); - groups.push({ - id: 'websearch', - name: 'WebSearch', - icon: 'Search', - checks: websearchChecks, - }); + groups.push({ id: 'websearch', name: 'WebSearch', icon: 'Search', checks: websearchChecks }); // Flatten all checks for backward compatibility const allChecks = groups.flatMap((g) => g.checks); @@ -159,715 +127,7 @@ export async function runHealthChecks(): Promise { info: allChecks.filter((c) => c.status === 'info').length, }; - return { - timestamp: Date.now(), - version, - groups, - checks: allChecks, - summary, - }; -} - -// Check 1: Claude CLI -async function checkClaudeCli(): Promise { - const cliInfo = getClaudeCliInfo(); - - if (!cliInfo) { - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'error', - message: 'Not found in PATH', - fix: 'Install: npm install -g @anthropic-ai/claude-code', - }; - } - - try { - const version = execSync('claude --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - - const versionMatch = version.match(/(\d+\.\d+\.\d+)/); - const versionStr = versionMatch ? versionMatch[1] : 'unknown'; - - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'ok', - message: `v${versionStr}`, - details: cliInfo.path, - }; - } catch { - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'error', - message: 'Not working', - details: cliInfo.path, - fix: 'Reinstall Claude CLI', - }; - } -} - -// Check 2: CCS Directory -function checkCcsDirectory(ccsDir: string): HealthCheck { - if (fs.existsSync(ccsDir)) { - return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'ok', - message: 'Exists', - details: '~/.ccs/', - }; - } - - return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'error', - message: 'Not found', - details: ccsDir, - fix: 'Run: npm install -g @kaitranntt/ccs --force', - fixable: true, - }; -} - -// Check 3: Config file -function checkConfigFile(): HealthCheck { - const configPath = getConfigPath(); - - if (!fs.existsSync(configPath)) { - return { - id: 'config-file', - name: 'config.json', - status: 'warning', - message: 'Not found', - details: configPath, - fixable: true, - }; - } - - try { - const content = fs.readFileSync(configPath, 'utf8'); - JSON.parse(content); - return { - id: 'config-file', - name: 'config.json', - status: 'ok', - message: 'Valid', - details: configPath, - }; - } catch { - return { - id: 'config-file', - name: 'config.json', - status: 'error', - message: 'Invalid JSON', - details: configPath, - }; - } -} - -// Check 4: Settings files (glm, kimi) -function checkSettingsFiles(ccsDir: string): HealthCheck[] { - const checks: HealthCheck[] = []; - const files = [ - { name: 'glm.settings.json', profile: 'glm' }, - { name: 'kimi.settings.json', profile: 'kimi' }, - ]; - - const { DelegationValidator } = require('../utils/delegation-validator'); - - for (const file of files) { - const filePath = path.join(ccsDir, file.name); - - if (!fs.existsSync(filePath)) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'info', - message: 'Not configured', - details: filePath, - }); - continue; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - JSON.parse(content); - - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Key configured', - details: filePath, - }); - } else if (validation.error && validation.error.includes('placeholder')) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'warning', - message: 'Placeholder key', - details: filePath, - }); - } else { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Valid JSON', - details: filePath, - }); - } - } catch { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'error', - message: 'Invalid JSON', - details: filePath, - }); - } - } - - return checks; -} - -// Check 5: Claude settings -function checkClaudeSettings(claudeDir: string): HealthCheck { - const settingsPath = path.join(claudeDir, 'settings.json'); - - if (!fs.existsSync(settingsPath)) { - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'warning', - message: 'Not found', - fix: 'Run: claude /login', - }; - } - - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - JSON.parse(content); - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'ok', - message: 'Valid', - }; - } catch { - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'warning', - message: 'Invalid JSON', - fix: 'Run: claude /login', - }; - } -} - -// Check 6: Profiles -function checkProfiles(ccsDir: string): HealthCheck { - const configPath = path.join(ccsDir, 'config.json'); - - if (!fs.existsSync(configPath)) { - return { - id: 'profiles', - name: 'Profiles', - status: 'info', - message: 'config.json not found', - }; - } - - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { - return { - id: 'profiles', - name: 'Profiles', - status: 'error', - message: 'Missing profiles object', - fix: 'Run: npm install -g @kaitranntt/ccs --force', - }; - } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - return { - id: 'profiles', - name: 'Profiles', - status: 'ok', - message: `${profileCount} configured`, - details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, - }; - } catch (e) { - return { - id: 'profiles', - name: 'Profiles', - status: 'error', - message: (e as Error).message, - }; - } -} - -// Check 7: Instances -function checkInstances(ccsDir: string): HealthCheck { - const instancesDir = path.join(ccsDir, 'instances'); - - if (!fs.existsSync(instancesDir)) { - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: 'No account profiles', - }; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - if (instances.length === 0) { - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: 'No account profiles', - }; - } - - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, - }; -} - -// Check 8: Delegation -function checkDelegation(ccsDir: string): HealthCheck { - const ccsClaudeCommandsDir = path.join(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) { - return { - id: 'delegation', - name: 'Delegation', - status: 'warning', - message: 'Not installed', - fix: 'Run: npm install -g @kaitranntt/ccs --force', - }; - } - - 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) { - return { - id: 'delegation', - name: 'Delegation', - status: 'warning', - message: 'No profiles ready', - fix: 'Configure profiles with valid API keys', - }; - } - - return { - id: 'delegation', - name: 'Delegation', - status: 'ok', - message: `${readyProfiles.length} profiles ready`, - details: readyProfiles.join(', '), - }; -} - -// Check 9: Permissions -function checkPermissions(ccsDir: string): HealthCheck { - const testFile = path.join(ccsDir, '.permission-test'); - - try { - fs.writeFileSync(testFile, 'test', 'utf8'); - fs.unlinkSync(testFile); - return { - id: 'permissions', - name: 'Permissions', - status: 'ok', - message: 'Write access verified', - }; - } catch { - return { - id: 'permissions', - name: 'Permissions', - status: 'error', - message: 'Cannot write to ~/.ccs/', - fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', - }; - } -} - -// Check 10: CCS Symlinks -function checkCcsSymlinks(): HealthCheck { - try { - const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - const health = manager.checkHealth(); - - if (health.healthy) { - const itemCount = manager.ccsItems.length; - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'ok', - message: `${itemCount}/${itemCount} items linked`, - }; - } - - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'warning', - message: `${health.issues.length} issues found`, - fix: 'Run: ccs sync', - }; - } catch (e) { - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'warning', - message: 'Could not check', - details: (e as Error).message, - fix: 'Run: ccs sync', - }; - } -} - -// Check 11: Settings Symlinks -function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck { - try { - const sharedDir = path.join(homedir, '.ccs', 'shared'); - const sharedSettings = path.join(sharedDir, 'settings.json'); - const claudeSettings = path.join(claudeDir, 'settings.json'); - - if (!fs.existsSync(sharedSettings)) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Shared not found', - fix: 'Run: ccs sync', - }; - } - - const sharedStats = fs.lstatSync(sharedSettings); - if (!sharedStats.isSymbolicLink()) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Not a symlink', - fix: 'Run: ccs sync', - }; - } - - const sharedTarget = fs.readlinkSync(sharedSettings); - const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); - - if (resolvedShared !== claudeSettings) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Wrong target', - fix: 'Run: ccs sync', - }; - } - - // Check instances - const instancesDir = path.join(ccsDir, 'instances'); - if (!fs.existsSync(instancesDir)) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'ok', - message: 'Shared symlink valid', - }; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - let broken = 0; - for (const instance of instances) { - const instanceSettings = path.join(instancesDir, instance, 'settings.json'); - if (!fs.existsSync(instanceSettings)) { - broken++; - continue; - } - try { - const stats = fs.lstatSync(instanceSettings); - if (!stats.isSymbolicLink()) { - broken++; - continue; - } - const target = fs.readlinkSync(instanceSettings); - const resolved = path.resolve(path.dirname(instanceSettings), target); - if (resolved !== sharedSettings) { - broken++; - } - } catch { - broken++; - } - } - - if (broken > 0) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: `${broken} broken instance(s)`, - fix: 'Run: ccs sync', - }; - } - - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'ok', - message: `${instances.length} instance(s) valid`, - }; - } catch (e) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Check failed', - details: (e as Error).message, - fix: 'Run: ccs sync', - }; - } -} - -// Check 12: CLIProxy Binary -function checkCliproxyBinary(): HealthCheck { - if (isCLIProxyInstalled()) { - const version = getInstalledCliproxyVersion(); - const binaryPath = getCLIProxyPath(); - return { - id: 'cliproxy-binary', - name: 'CLIProxy Binary', - status: 'ok', - message: `v${version}`, - details: binaryPath, - }; - } - - return { - id: 'cliproxy-binary', - name: 'CLIProxy Binary', - status: 'info', - message: 'Not installed', - details: 'Downloads on first use', - }; -} - -// Check 13: CLIProxy Config -function checkCliproxyConfig(): HealthCheck { - const configPath = getCliproxyConfigPath(); - - if (fs.existsSync(configPath)) { - return { - id: 'cliproxy-config', - name: 'CLIProxy Config', - status: 'ok', - message: 'cliproxy/config.yaml', - }; - } - - return { - id: 'cliproxy-config', - name: 'CLIProxy Config', - status: 'info', - message: 'Not created', - details: 'Generated on first use', - }; -} - -// Check 14: OAuth Providers -function checkOAuthProviders(): HealthCheck[] { - const authStatuses = getAllAuthStatus(); - const checks: HealthCheck[] = []; - - for (const status of authStatuses) { - const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); - - if (status.authenticated) { - const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; - checks.push({ - id: `oauth-${status.provider}`, - name: `${providerName} Auth`, - status: 'ok', - message: 'Authenticated', - details: lastAuth, - }); - } else { - checks.push({ - id: `oauth-${status.provider}`, - name: `${providerName} Auth`, - status: 'info', - message: 'Not authenticated', - fix: `Run: ccs ${status.provider} --auth`, - }); - } - } - - return checks; -} - -// Check 15: CLIProxy Port -async function checkCliproxyPort(): Promise { - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - - if (!portProcess) { - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'info', - message: `${CLIPROXY_DEFAULT_PORT} free`, - details: 'Proxy not running', - }; - } - - if (isCLIProxyProcess(portProcess)) { - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'ok', - message: 'CLIProxy running', - details: `PID ${portProcess.pid}`, - }; - } - - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'warning', - message: `Occupied by ${portProcess.processName}`, - details: `PID ${portProcess.pid}`, - fix: `Kill process: kill ${portProcess.pid}`, - }; -} - -// Check 16: Environment (platform, SSH, TTY, browser capability) -function checkEnvironment(): HealthCheck { - const diag = getEnvironmentDiagnostics(); - - let status: 'ok' | 'warning' | 'info' = 'ok'; - let message = 'Browser available'; - - if (diag.detectedHeadless) { - if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { - status = 'warning'; - message = 'Possible headless false positive (Windows)'; - } else if (diag.sshSession) { - status = 'info'; - message = 'SSH session (headless mode)'; - } else { - status = 'info'; - message = 'Headless environment'; - } - } - - return { - id: 'environment', - name: 'Environment', - status, - message, - details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`, - }; -} - -// Check 17: OAuth Ports (port availability for Gemini, Codex, Agy) -async function checkOAuthPortsForDashboard(): Promise { - const portDiagnostics = await checkAuthCodePorts(); - - return portDiagnostics.map((diag) => { - const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); - const portStr = diag.port ? ` (${diag.port})` : ''; - - let status: 'ok' | 'warning' | 'info' = 'ok'; - if (diag.status === 'occupied') status = 'warning'; - if (diag.status === 'not_applicable') status = 'info'; - - return { - id: `oauth-port-${diag.provider}`, - name: `${providerName}${portStr}`, - status, - message: diag.message, - details: diag.process ? `PID ${diag.process.pid}` : undefined, - fix: diag.recommendation || undefined, - }; - }); -} - -// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI) -function checkWebSearchClis(): HealthCheck[] { - const providers = getWebSearchCliProviders(); - const checks: HealthCheck[] = []; - - for (const provider of providers) { - if (provider.installed) { - const freeTag = provider.freeTier ? ' (FREE)' : ''; - checks.push({ - id: `websearch-${provider.id}`, - name: provider.name, - status: 'ok', - message: `v${provider.version || 'unknown'}${freeTag}`, - details: provider.description, - }); - } else { - const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; - checks.push({ - id: `websearch-${provider.id}`, - name: provider.name, - status: 'info', - message: `Not installed${keyNote}`, - fix: provider.installCommand, - details: provider.description, - }); - } - } - - // Add summary check if no providers installed - if (!hasAnyWebSearchCli()) { - checks.push({ - id: 'websearch-summary', - name: 'WebSearch Status', - status: 'warning', - message: 'No CLI tools installed', - fix: 'npm install -g @google/gemini-cli (FREE)', - details: 'Install a WebSearch CLI for real-time web access', - }); - } - - return checks; + return { timestamp: Date.now(), version, groups, checks: allChecks, summary }; } /** diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts new file mode 100644 index 00000000..80f33e8e --- /dev/null +++ b/src/web-server/health/cliproxy-checks.ts @@ -0,0 +1,135 @@ +/** + * CLIProxy Health Checks + * + * Check CLIProxy binary, config, OAuth providers, and port. + */ + +import * as fs from 'fs'; +import { + isCLIProxyInstalled, + getInstalledCliproxyVersion, + getCLIProxyPath, + getConfigPath as getCliproxyConfigPath, + getAllAuthStatus, + CLIPROXY_DEFAULT_PORT, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import type { HealthCheck } from './types'; + +/** + * Check CLIProxy binary installation + */ +export function checkCliproxyBinary(): HealthCheck { + if (isCLIProxyInstalled()) { + const version = getInstalledCliproxyVersion(); + const binaryPath = getCLIProxyPath(); + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'ok', + message: `v${version}`, + details: binaryPath, + }; + } + + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'info', + message: 'Not installed', + details: 'Downloads on first use', + }; +} + +/** + * Check CLIProxy config file + */ +export function checkCliproxyConfig(): HealthCheck { + const configPath = getCliproxyConfigPath(); + + if (fs.existsSync(configPath)) { + return { + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'ok', + message: 'cliproxy/config.yaml', + }; + } + + return { + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'info', + message: 'Not created', + details: 'Generated on first use', + }; +} + +/** + * Check OAuth providers authentication status + */ +export function checkOAuthProviders(): HealthCheck[] { + const authStatuses = getAllAuthStatus(); + const checks: HealthCheck[] = []; + + for (const status of authStatuses) { + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'ok', + message: 'Authenticated', + details: lastAuth, + }); + } else { + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'info', + message: 'Not authenticated', + fix: `Run: ccs ${status.provider} --auth`, + }); + } + } + + return checks; +} + +/** + * Check CLIProxy port status + */ +export async function checkCliproxyPort(): Promise { + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'info', + message: `${CLIPROXY_DEFAULT_PORT} free`, + details: 'Proxy not running', + }; + } + + if (isCLIProxyProcess(portProcess)) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'ok', + message: 'CLIProxy running', + details: `PID ${portProcess.pid}`, + }; + } + + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'warning', + message: `Occupied by ${portProcess.processName}`, + details: `PID ${portProcess.pid}`, + fix: `Kill process: kill ${portProcess.pid}`, + }; +} diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts new file mode 100644 index 00000000..30cc881c --- /dev/null +++ b/src/web-server/health/config-checks.ts @@ -0,0 +1,155 @@ +/** + * Configuration Health Checks + * + * Check config.json, settings files, and Claude settings. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getConfigPath } from '../../utils/config-manager'; +import type { HealthCheck } from './types'; + +/** + * Check config.json file + */ +export function checkConfigFile(): HealthCheck { + const configPath = getConfigPath(); + + if (!fs.existsSync(configPath)) { + return { + id: 'config-file', + name: 'config.json', + status: 'warning', + message: 'Not found', + details: configPath, + fixable: true, + }; + } + + try { + const content = fs.readFileSync(configPath, 'utf8'); + JSON.parse(content); + return { + id: 'config-file', + name: 'config.json', + status: 'ok', + message: 'Valid', + details: configPath, + }; + } catch { + return { + id: 'config-file', + name: 'config.json', + status: 'error', + message: 'Invalid JSON', + details: configPath, + }; + } +} + +/** + * Check settings files (glm, kimi) + */ +export function checkSettingsFiles(ccsDir: string): HealthCheck[] { + const checks: HealthCheck[] = []; + const files = [ + { name: 'glm.settings.json', profile: 'glm' }, + { name: 'kimi.settings.json', profile: 'kimi' }, + ]; + + const { DelegationValidator } = require('../../utils/delegation-validator'); + + for (const file of files) { + const filePath = path.join(ccsDir, file.name); + + if (!fs.existsSync(filePath)) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'info', + message: 'Not configured', + details: filePath, + }); + continue; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + JSON.parse(content); + + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Key configured', + details: filePath, + }); + } else if (validation.error && validation.error.includes('placeholder')) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'warning', + message: 'Placeholder key', + details: filePath, + }); + } else { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Valid JSON', + details: filePath, + }); + } + } catch { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'error', + message: 'Invalid JSON', + details: filePath, + }); + } + } + + return checks; +} + +/** + * Check Claude settings file + */ +export function checkClaudeSettings(claudeDir: string): HealthCheck { + const settingsPath = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(settingsPath)) { + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', + message: 'Not found', + fix: 'Run: claude /login', + }; + } + + try { + const content = fs.readFileSync(settingsPath, 'utf8'); + JSON.parse(content); + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'ok', + message: 'Valid', + }; + } catch { + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', + message: 'Invalid JSON', + fix: 'Run: claude /login', + }; + } +} diff --git a/src/web-server/health/environment-checks.ts b/src/web-server/health/environment-checks.ts new file mode 100644 index 00000000..fa081baa --- /dev/null +++ b/src/web-server/health/environment-checks.ts @@ -0,0 +1,39 @@ +/** + * Environment Health Checks + * + * Check platform, SSH, TTY, and browser capability. + */ + +import { getEnvironmentDiagnostics } from '../../management/environment-diagnostics'; +import type { HealthCheck } from './types'; + +/** + * Check environment (platform, SSH, TTY, browser capability) + */ +export function checkEnvironment(): HealthCheck { + const diag = getEnvironmentDiagnostics(); + + let status: 'ok' | 'warning' | 'info' = 'ok'; + let message = 'Browser available'; + + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + status = 'warning'; + message = 'Possible headless false positive (Windows)'; + } else if (diag.sshSession) { + status = 'info'; + message = 'SSH session (headless mode)'; + } else { + status = 'info'; + message = 'Headless environment'; + } + } + + return { + id: 'environment', + name: 'Environment', + status, + message, + details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`, + }; +} diff --git a/src/web-server/health/index.ts b/src/web-server/health/index.ts new file mode 100644 index 00000000..8a38f8dd --- /dev/null +++ b/src/web-server/health/index.ts @@ -0,0 +1,35 @@ +/** + * Health Check Modules - Barrel Export + */ + +// Types +export type { HealthCheck, HealthGroup, HealthReport } from './types'; + +// System checks +export { checkClaudeCli, checkCcsDirectory, checkPermissions } from './system-checks'; + +// Symlink checks +export { checkCcsSymlinks, checkSettingsSymlinks } from './symlink-checks'; + +// Environment checks +export { checkEnvironment } from './environment-checks'; + +// Config checks +export { checkConfigFile, checkSettingsFiles, checkClaudeSettings } from './config-checks'; + +// Profile checks +export { checkProfiles, checkInstances, checkDelegation } from './profile-checks'; + +// CLIProxy checks +export { + checkCliproxyBinary, + checkCliproxyConfig, + checkOAuthProviders, + checkCliproxyPort, +} from './cliproxy-checks'; + +// OAuth checks +export { checkOAuthPortsForDashboard } from './oauth-checks'; + +// WebSearch checks +export { checkWebSearchClis } from './websearch-checks'; diff --git a/src/web-server/health/oauth-checks.ts b/src/web-server/health/oauth-checks.ts new file mode 100644 index 00000000..2a128b7b --- /dev/null +++ b/src/web-server/health/oauth-checks.ts @@ -0,0 +1,33 @@ +/** + * OAuth Readiness Health Checks + * + * Check OAuth port availability for providers. + */ + +import { checkAuthCodePorts } from '../../management/oauth-port-diagnostics'; +import type { HealthCheck } from './types'; + +/** + * Check OAuth ports for dashboard (Gemini, Codex, Agy) + */ +export async function checkOAuthPortsForDashboard(): Promise { + const portDiagnostics = await checkAuthCodePorts(); + + return portDiagnostics.map((diag) => { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port ? ` (${diag.port})` : ''; + + let status: 'ok' | 'warning' | 'info' = 'ok'; + if (diag.status === 'occupied') status = 'warning'; + if (diag.status === 'not_applicable') status = 'info'; + + return { + id: `oauth-port-${diag.provider}`, + name: `${providerName}${portStr}`, + status, + message: diag.message, + details: diag.process ? `PID ${diag.process.pid}` : undefined, + fix: diag.recommendation || undefined, + }; + }); +} diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts new file mode 100644 index 00000000..4c2a30e5 --- /dev/null +++ b/src/web-server/health/profile-checks.ts @@ -0,0 +1,140 @@ +/** + * Profile Health Checks + * + * Check profiles, instances, and delegation. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { HealthCheck } from './types'; + +/** + * Check profiles configuration + */ +export function checkProfiles(ccsDir: string): HealthCheck { + const configPath = path.join(ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.json not found', + }; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: 'Missing profiles object', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } catch (e) { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: (e as Error).message, + }; + } +} + +/** + * Check instances (account profiles) + */ +export function checkInstances(ccsDir: string): HealthCheck { + const instancesDir = path.join(ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, + }; +} + +/** + * Check delegation setup + */ +export function checkDelegation(ccsDir: string): HealthCheck { + const ccsClaudeCommandsDir = path.join(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) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'Not installed', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + 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) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'No profiles ready', + fix: 'Configure profiles with valid API keys', + }; + } + + return { + id: 'delegation', + name: 'Delegation', + status: 'ok', + message: `${readyProfiles.length} profiles ready`, + details: readyProfiles.join(', '), + }; +} diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts new file mode 100644 index 00000000..a5e4e0aa --- /dev/null +++ b/src/web-server/health/symlink-checks.ts @@ -0,0 +1,160 @@ +/** + * Symlink Health Checks + * + * Check CCS symlinks and settings symlinks. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { HealthCheck } from './types'; + +/** + * Check CCS symlinks health + */ +export function checkCcsSymlinks(): HealthCheck { + try { + const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const itemCount = manager.ccsItems.length; + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'ok', + message: `${itemCount}/${itemCount} items linked`, + }; + } + + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: `${health.issues.length} issues found`, + fix: 'Run: ccs sync', + }; + } catch (e) { + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: 'Could not check', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +/** + * Check settings symlinks + */ +export function checkSettingsSymlinks( + homedir: string, + ccsDir: string, + claudeDir: string +): HealthCheck { + try { + const sharedDir = `${homedir}/.ccs/shared`; + const sharedSettings = `${sharedDir}/settings.json`; + const claudeSettings = `${claudeDir}/settings.json`; + + if (!fs.existsSync(sharedSettings)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Shared not found', + fix: 'Run: ccs sync', + }; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Not a symlink', + fix: 'Run: ccs sync', + }; + } + + const sharedTarget = fs.readlinkSync(sharedSettings); + const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); + + if (resolvedShared !== claudeSettings) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Wrong target', + fix: 'Run: ccs sync', + }; + } + + // Check instances + const instancesDir = `${ccsDir}/instances`; + if (!fs.existsSync(instancesDir)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: 'Shared symlink valid', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(`${instancesDir}/${name}`).isDirectory(); + }); + + let broken = 0; + for (const instance of instances) { + const instanceSettings = `${instancesDir}/${instance}/settings.json`; + if (!fs.existsSync(instanceSettings)) { + broken++; + continue; + } + try { + const stats = fs.lstatSync(instanceSettings); + if (!stats.isSymbolicLink()) { + broken++; + continue; + } + const target = fs.readlinkSync(instanceSettings); + const resolved = path.resolve(path.dirname(instanceSettings), target); + if (resolved !== sharedSettings) { + broken++; + } + } catch { + broken++; + } + } + + if (broken > 0) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: `${broken} broken instance(s)`, + fix: 'Run: ccs sync', + }; + } + + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: `${instances.length} instance(s) valid`, + }; + } catch (e) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Check failed', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} diff --git a/src/web-server/health/system-checks.ts b/src/web-server/health/system-checks.ts new file mode 100644 index 00000000..c9ed979a --- /dev/null +++ b/src/web-server/health/system-checks.ts @@ -0,0 +1,106 @@ +/** + * System Health Checks + * + * Checks for Claude CLI, CCS directory, and permissions. + */ + +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import type { HealthCheck } from './types'; + +/** + * Check Claude CLI installation and version + */ +export async function checkClaudeCli(): Promise { + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not found in PATH', + fix: 'Install: npm install -g @anthropic-ai/claude-code', + }; + } + + try { + const version = execSync('claude --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + const versionMatch = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = versionMatch ? versionMatch[1] : 'unknown'; + + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'ok', + message: `v${versionStr}`, + details: cliInfo.path, + }; + } catch { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not working', + details: cliInfo.path, + fix: 'Reinstall Claude CLI', + }; + } +} + +/** + * Check CCS directory existence + */ +export function checkCcsDirectory(ccsDir: string): HealthCheck { + if (fs.existsSync(ccsDir)) { + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'ok', + message: 'Exists', + details: '~/.ccs/', + }; + } + + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'error', + message: 'Not found', + details: ccsDir, + fix: 'Run: npm install -g @kaitranntt/ccs --force', + fixable: true, + }; +} + +/** + * Check permissions on CCS directory + */ +export function checkPermissions(ccsDir: string): HealthCheck { + const testFile = `${ccsDir}/.permission-test`; + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + return { + id: 'permissions', + name: 'Permissions', + status: 'ok', + message: 'Write access verified', + }; + } catch { + return { + id: 'permissions', + name: 'Permissions', + status: 'error', + message: 'Cannot write to ~/.ccs/', + fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + }; + } +} diff --git a/src/web-server/health/types.ts b/src/web-server/health/types.ts new file mode 100644 index 00000000..96d018c1 --- /dev/null +++ b/src/web-server/health/types.ts @@ -0,0 +1,36 @@ +/** + * Health Check Types + * + * Core interfaces for the health check system. + */ + +export interface HealthCheck { + id: string; + name: string; + status: 'ok' | 'warning' | 'error' | 'info'; + message: string; + details?: string; + fix?: string; + fixable?: boolean; +} + +export interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + +export interface HealthReport { + timestamp: number; + version: string; + groups: HealthGroup[]; + checks: HealthCheck[]; // Flat list for backward compatibility + summary: { + total: number; + passed: number; + warnings: number; + errors: number; + info: number; + }; +} diff --git a/src/web-server/health/websearch-checks.ts b/src/web-server/health/websearch-checks.ts new file mode 100644 index 00000000..89a7c2cb --- /dev/null +++ b/src/web-server/health/websearch-checks.ts @@ -0,0 +1,53 @@ +/** + * WebSearch CLI Health Checks + * + * Check WebSearch CLI providers (Gemini CLI, Grok CLI). + */ + +import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../../utils/websearch-manager'; +import type { HealthCheck } from './types'; + +/** + * Check WebSearch CLI providers + */ +export function checkWebSearchClis(): HealthCheck[] { + const providers = getWebSearchCliProviders(); + const checks: HealthCheck[] = []; + + for (const provider of providers) { + if (provider.installed) { + const freeTag = provider.freeTier ? ' (FREE)' : ''; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'ok', + message: `v${provider.version || 'unknown'}${freeTag}`, + details: provider.description, + }); + } else { + const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'info', + message: `Not installed${keyNote}`, + fix: provider.installCommand, + details: provider.description, + }); + } + } + + // Add summary check if no providers installed + if (!hasAnyWebSearchCli()) { + checks.push({ + id: 'websearch-summary', + name: 'WebSearch Status', + status: 'warning', + message: 'No CLI tools installed', + fix: 'npm install -g @google/gemini-cli (FREE)', + details: 'Install a WebSearch CLI for real-time web access', + }); + } + + return checks; +} diff --git a/src/web-server/index.ts b/src/web-server/index.ts index a4de7f80..c5d4cfb2 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -35,8 +35,8 @@ export async function startServer(options: ServerOptions): Promise { - const config = readConfigSafe(); - const profiles = Object.entries(config.profiles).map(([name, settingsPath]) => ({ - name, - settingsPath, - configured: isConfigured(name, config), - })); - - res.json({ profiles }); -}); - -/** - * POST /api/profiles - Create new profile - */ -apiRoutes.post('/profiles', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; - - if (!name || !baseUrl || !apiKey) { - res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); - return; - } - - // Validate reserved names - if (isReservedName(name)) { - res.status(400).json({ - error: `Profile name '${name}' is reserved`, - reserved: RESERVED_PROFILE_NAMES, - }); - return; - } - - const config = readConfigSafe(); - - if (config.profiles[name]) { - res.status(409).json({ error: 'Profile already exists' }); - return; - } - - // Ensure .ccs directory exists - if (!fs.existsSync(getCcsDir())) { - fs.mkdirSync(getCcsDir(), { recursive: true }); - } - - // Create settings file with model mapping - const settingsPath = createSettingsFile(name, baseUrl, apiKey, { - model, - opusModel, - sonnetModel, - haikuModel, - }); - - // Update config - config.profiles[name] = settingsPath; - writeConfig(config); - - res.status(201).json({ name, settingsPath }); -}); - -/** - * PUT /api/profiles/:name - Update profile - */ -apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => { - const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; - - const config = readConfigSafe(); - - if (!config.profiles[name]) { - res.status(404).json({ error: 'Profile not found' }); - return; - } - - try { - updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); - res.json({ name, updated: true }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * DELETE /api/profiles/:name - Delete profile - */ -apiRoutes.delete('/profiles/:name', (req: Request, res: Response): void => { - const { name } = req.params; - - const config = readConfigSafe(); - - if (!config.profiles[name]) { - res.status(404).json({ error: 'Profile not found' }); - return; - } - - // Delete settings file - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); - } - - // Remove from config - delete config.profiles[name]; - writeConfig(config); - - res.json({ name, deleted: true }); -}); - -// ==================== CLIProxy CRUD ==================== - -/** - * GET /api/cliproxy - List cliproxy variants - */ -apiRoutes.get('/cliproxy', (_req: Request, res: Response) => { - const config = readConfigSafe(); - const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({ - name, - provider: variant.provider, - settings: variant.settings, - account: variant.account || 'default', // Include account field - })); - - res.json({ variants }); -}); - -/** - * POST /api/cliproxy - Create cliproxy variant - */ -apiRoutes.post('/cliproxy', (req: Request, res: Response): void => { - const { name, provider, model, account } = req.body; - - if (!name || !provider) { - res.status(400).json({ error: 'Missing required fields: name, provider' }); - return; - } - - // Reject reserved names as variant names (prevents collision with built-in providers) - if (isReservedName(name)) { - res.status(400).json({ - error: `Cannot use reserved name '${name}' as variant name`, - reserved: RESERVED_PROFILE_NAMES, - }); - return; - } - - const config = readConfigSafe(); - config.cliproxy = config.cliproxy || {}; - - if (config.cliproxy[name]) { - res.status(409).json({ error: 'Variant already exists' }); - return; - } - - // Ensure .ccs directory exists - if (!fs.existsSync(getCcsDir())) { - fs.mkdirSync(getCcsDir(), { recursive: true }); - } - - // Create settings file for variant - const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model); - - // Include account if specified (defaults to 'default' if not provided) - config.cliproxy[name] = { - provider, - settings: settingsPath, - ...(account && { account }), - }; - writeConfig(config); - - res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' }); -}); - -/** - * PUT /api/cliproxy/:name - Update cliproxy variant - */ -apiRoutes.put('/cliproxy/:name', (req: Request, res: Response): void => { - const { name } = req.params; - const { provider, account, model } = req.body; - - const config = readConfigSafe(); - - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - const variant = config.cliproxy[name]; - - // Update fields if provided - if (provider) { - variant.provider = provider; - } - if (account !== undefined) { - if (account) { - variant.account = account; - } else { - delete variant.account; // Remove account to use default - } - } - - // Update model in settings file if provided - if (model !== undefined) { - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - const settings = loadSettings(settingsPath); - if (model) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_MODEL = model; - } else if (settings.env) { - delete settings.env.ANTHROPIC_MODEL; - } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - } - } - - writeConfig(config); - - res.json({ - name, - provider: variant.provider, - account: variant.account || 'default', - settings: variant.settings, - updated: true, - }); -}); - -/** - * DELETE /api/cliproxy/:name - Delete cliproxy variant - */ -apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => { - const { name } = req.params; - - const config = readConfigSafe(); - - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - // Never delete settings files for reserved provider names (safety guard) - if (!isReservedName(name)) { - // Only delete settings file for non-reserved variant names - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); - } - } - - delete config.cliproxy[name]; - writeConfig(config); - - res.json({ name, deleted: true }); -}); - -/** - * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles - * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers - */ -apiRoutes.get('/cliproxy/auth', async (_req: Request, res: Response) => { - // Initialize accounts from existing tokens on first request - initializeAccounts(); - - // Fetch CLIProxyAPI usage stats to determine active providers - const stats = await fetchCliproxyStats(); - - // Map CLIProxyAPI provider names to our internal provider names - const statsProviderMap: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - }; - - // Update lastUsedAt for providers with recent activity - if (stats?.requestsByProvider) { - for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { - if (requestCount > 0) { - const provider = statsProviderMap[statsProvider.toLowerCase()]; - if (provider) { - // Touch the default account for this provider (or all accounts) - const accounts = getProviderAccounts(provider); - for (const account of accounts) { - // Only touch if this is the default account (most likely being used) - if (account.isDefault) { - touchAccount(provider, account.id); - } - } - } - } - } - } - - const statuses = getAllAuthStatus(); - - const authStatus = statuses.map((status) => { - const oauthConfig = getOAuthConfig(status.provider); - return { - provider: status.provider, - displayName: oauthConfig.displayName, - authenticated: status.authenticated, - lastAuth: status.lastAuth?.toISOString() || null, - tokenFiles: status.tokenFiles.length, - accounts: status.accounts, - defaultAccount: status.defaultAccount, - }; - }); - - res.json({ authStatus }); -}); - -// ==================== Account Management (Multi-Account Support) ==================== - -/** - * GET /api/cliproxy/accounts - Get all accounts across all providers - */ -apiRoutes.get('/cliproxy/accounts', (_req: Request, res: Response) => { - // Initialize accounts from existing tokens - initializeAccounts(); - - const accounts = getAllAccountsSummary(); - res.json({ accounts }); -}); - -/** - * GET /api/cliproxy/accounts/:provider - Get accounts for a specific provider - */ -apiRoutes.get('/cliproxy/accounts/:provider', (req: Request, res: Response): void => { - const { provider } = req.params; - - // Validate provider - const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; - if (!validProviders.includes(provider as CLIProxyProvider)) { - res.status(400).json({ error: `Invalid provider: ${provider}` }); - return; - } - - const accounts = getProviderAccounts(provider as CLIProxyProvider); - res.json({ provider, accounts }); -}); - -/** - * POST /api/cliproxy/accounts/:provider/default - Set default account for provider - */ -apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Response): void => { - const { provider } = req.params; - const { accountId } = req.body; - - if (!accountId) { - res.status(400).json({ error: 'Missing required field: accountId' }); - return; - } - - // Validate provider - const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; - if (!validProviders.includes(provider as CLIProxyProvider)) { - res.status(400).json({ error: `Invalid provider: ${provider}` }); - return; - } - - const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); - - if (success) { - res.json({ provider, defaultAccount: accountId }); - } else { - res.status(404).json({ error: 'Account not found' }); - } -}); - -/** - * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account - */ -apiRoutes.delete('/cliproxy/accounts/:provider/:accountId', (req: Request, res: Response): void => { - const { provider, accountId } = req.params; - - // Validate provider - const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; - if (!validProviders.includes(provider as CLIProxyProvider)) { - res.status(400).json({ error: `Invalid provider: ${provider}` }); - return; - } - - const success = removeAccountFn(provider as CLIProxyProvider, accountId); - - if (success) { - res.json({ provider, accountId, deleted: true }); - } else { - res.status(404).json({ error: 'Account not found' }); - } -}); - -/** - * POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider - * Opens browser for authentication and returns account info when complete - */ -apiRoutes.post( - '/cliproxy/auth/:provider/start', - async (req: Request, res: Response): Promise => { - const { provider } = req.params; - const { nickname } = req.body; - - // Validate provider - const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; - if (!validProviders.includes(provider as CLIProxyProvider)) { - res.status(400).json({ error: `Invalid provider: ${provider}` }); - return; - } - - try { - // Trigger OAuth flow - this opens browser and waits for completion - const account = await triggerOAuth(provider as CLIProxyProvider, { - add: true, // Always add mode from UI - headless: false, // Force interactive mode - nickname: nickname || undefined, - fromUI: true, // Enable project selection prompt in UI - }); - - if (account) { - res.json({ - success: true, - account: { - id: account.id, - email: account.email, - nickname: account.nickname, - provider: account.provider, - isDefault: account.isDefault, - }, - }); - } else { - res.status(400).json({ error: 'Authentication failed or was cancelled' }); - } - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } - } -); - -/** - * GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt - * Returns project list for user to select from during OAuth flow - */ -apiRoutes.get( - '/cliproxy/auth/project-selection/:sessionId', - (req: Request, res: Response): void => { - const { sessionId } = req.params; - - const pending = getPendingSelection(sessionId); - if (pending) { - res.json(pending); - } else { - res.status(404).json({ error: 'No pending project selection for this session' }); - } - } -); - -/** - * POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection - * Submits user's project choice during OAuth flow - */ -apiRoutes.post( - '/cliproxy/auth/project-selection/:sessionId', - (req: Request, res: Response): void => { - const { sessionId } = req.params; - const { selectedId } = req.body; - - if (!selectedId && selectedId !== '') { - res.status(400).json({ error: 'selectedId is required (use empty string for default)' }); - return; - } - - const success = submitProjectSelection({ sessionId, selectedId }); - if (success) { - res.json({ success: true }); - } else { - res.status(404).json({ error: 'No pending project selection for this session' }); - } - } -); - -// ==================== Settings (Phase 05) ==================== - -/** - * Helper: Mask API keys in settings - */ -function maskApiKeys(settings: Settings): Settings { - if (!settings.env) return settings; - - const masked = { ...settings, env: { ...settings.env } }; - - for (const key of Object.keys(masked.env)) { - if (isSensitiveKey(key)) { - masked.env[key] = maskSensitiveValue(masked.env[key]); - } - } - - return masked; -} - -/** - * GET /api/settings/:profile - Get settings with masked API keys - */ -apiRoutes.get('/settings/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; - } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - - // Mask API keys in response - const masked = maskApiKeys(settings); - - res.json({ - profile, - settings: masked, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); -}); - -/** - * GET /api/settings/:profile/raw - Get full settings (for editing) - */ -apiRoutes.get('/settings/:profile/raw', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; - } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - - res.json({ - profile, - settings, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); -}); - -/** - * PUT /api/settings/:profile - Update settings with conflict detection and backup - * Creates the settings file if it doesn't exist (upsert behavior) - */ -apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const { settings, expectedMtime } = req.body; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - const fileExists = fs.existsSync(settingsPath); - - // Only check conflict if file exists and expectedMtime was provided - if (fileExists && expectedMtime) { - const stat = fs.statSync(settingsPath); - if (stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; - } - } - - // Create backup only if file exists - let backupPath: string | undefined; - if (fileExists) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); - } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); - } - - // Ensure directory exists for new files - if (!fileExists) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - } - - // Write new settings atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - const newStat = fs.statSync(settingsPath); - res.json({ - profile, - mtime: newStat.mtime.getTime(), - backupPath, - created: !fileExists, - }); -}); - -// ==================== Presets ==================== - -/** - * GET /api/settings/:profile/presets - Get saved presets for a provider - */ -apiRoutes.get('/settings/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - if (!fs.existsSync(settingsPath)) { - res.json({ presets: [] }); - return; - } - - const settings = loadSettings(settingsPath); - res.json({ presets: settings.presets || [] }); -}); - -/** - * POST /api/settings/:profile/presets - Create a new preset - */ -apiRoutes.post('/settings/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const { name, default: defaultModel, opus, sonnet, haiku } = req.body; - - if (!name || !defaultModel) { - res.status(400).json({ error: 'Missing required fields: name, default' }); - return; - } - - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - // Create settings file if it doesn't exist - if (!fs.existsSync(settingsPath)) { - fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); - } - - const settings = loadSettings(settingsPath); - settings.presets = settings.presets || []; - - // Check for duplicate name - if (settings.presets.some((p) => p.name === name)) { - res.status(409).json({ error: 'Preset with this name already exists' }); - return; - } - - const preset = { - name, - default: defaultModel, - opus: opus || defaultModel, - sonnet: sonnet || defaultModel, - haiku: haiku || defaultModel, - }; - - settings.presets.push(preset); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.status(201).json({ preset }); -}); - -/** - * DELETE /api/settings/:profile/presets/:name - Delete a preset - */ -apiRoutes.delete('/settings/:profile/presets/:name', (req: Request, res: Response): void => { - const { profile, name } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; - } - - const settings = loadSettings(settingsPath); - if (!settings.presets || !settings.presets.some((p) => p.name === name)) { - res.status(404).json({ error: 'Preset not found' }); - return; - } - - settings.presets = settings.presets.filter((p) => p.name !== name); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.json({ success: true }); -}); - -// ==================== Accounts ==================== - -/** - * GET /api/accounts - List accounts from profiles.json - */ -apiRoutes.get('/accounts', (_req: Request, res: Response): void => { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - if (!fs.existsSync(profilesPath)) { - res.json({ accounts: [], default: null }); - return; - } - - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { - // Type-safe handling of metadata - const metadata = meta as Record; - return { - name, - ...metadata, - }; - }); - - res.json({ accounts, default: data.default || null }); -}); - -/** - * POST /api/accounts/default - Set default account - */ -apiRoutes.post('/accounts/default', (req: Request, res: Response): void => { - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Missing required field: name' }); - return; - } - - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - const data = fs.existsSync(profilesPath) - ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) - : { profiles: {} }; - - data.default = name; - fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); - - res.json({ default: name }); -}); - -// ==================== Health (Phase 06) ==================== - -/** - * GET /api/health - Run health checks - */ -apiRoutes.get('/health', async (_req: Request, res: Response) => { - const report = await runHealthChecks(); - res.json(report); -}); - -/** - * POST /api/health/fix/:checkId - Fix a health issue - */ -apiRoutes.post('/health/fix/:checkId', (req: Request, res: Response): void => { - const { checkId } = req.params; - const result = fixHealthIssue(checkId); - - if (result.success) { - res.json({ success: true, message: result.message }); - } else { - res.status(400).json({ success: false, message: result.message }); - } -}); - -// ==================== Unified Config (Phase 5) ==================== - -/** - * GET /api/config/format - Return current config format and migration status - */ -apiRoutes.get('/config/format', (_req: Request, res: Response) => { - res.json({ - format: getConfigFormat(), - migrationNeeded: needsMigration(), - backups: getBackupDirectories(), - }); -}); - -/** - * GET /api/config - Return unified config (excludes secrets) - */ -apiRoutes.get('/config', (_req: Request, res: Response): void => { - if (!hasUnifiedConfig()) { - res.status(400).json({ error: 'Unified config not enabled' }); - return; - } - - const config = loadUnifiedConfig(); - if (!config) { - res.status(500).json({ error: 'Failed to load config' }); - return; - } - - res.json(config); -}); - -/** - * GET /api/config/raw - Return raw YAML content for display - */ -apiRoutes.get('/config/raw', (_req: Request, res: Response): void => { - const yamlPath = getConfigYamlPath(); - if (!fs.existsSync(yamlPath)) { - res.status(404).json({ error: 'Config file not found' }); - return; - } - try { - const content = fs.readFileSync(yamlPath, 'utf8'); - res.type('text/plain').send(content); - } catch (err) { - res.status(500).json({ error: (err as Error).message }); - } -}); - -/** - * PUT /api/config - Update unified config - */ -apiRoutes.put('/config', (req: Request, res: Response): void => { - const config = req.body; - - if (!isUnifiedConfig(config)) { - res.status(400).json({ error: 'Invalid config format' }); - return; - } - - try { - saveUnifiedConfig(config); - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: (err as Error).message }); - } -}); - -/** - * POST /api/config/migrate - Trigger migration from JSON to YAML - */ -apiRoutes.post('/config/migrate', async (req: Request, res: Response) => { - const dryRun = req.query.dryRun === 'true'; - const result = await migrate(dryRun); - res.json(result); -}); - -/** - * POST /api/config/rollback - Rollback migration to JSON format - */ -apiRoutes.post('/config/rollback', async (req: Request, res: Response): Promise => { - const { backupPath } = req.body; - - if (!backupPath || typeof backupPath !== 'string') { - res.status(400).json({ error: 'Missing required field: backupPath' }); - return; - } - - const success = await rollback(backupPath); - res.json({ success }); -}); - -/** - * PUT /api/secrets/:profile - Update profile secrets (write-only) - */ -apiRoutes.put('/secrets/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const secrets = req.body; - - if (!secrets || typeof secrets !== 'object') { - res.status(400).json({ error: 'Invalid secrets format' }); - return; - } - - try { - setProfileSecrets(profile, secrets as Record); - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: (err as Error).message }); - } -}); - -/** - * GET /api/secrets/:profile/exists - Check if secrets exist (no values returned) - */ -apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { - const { profile } = req.params; - const secrets = getProfileSecrets(profile); - res.json({ - exists: Object.keys(secrets).length > 0, - keys: Object.keys(secrets), // Only key names, not values - }); -}); - -// ==================== Generic File API (Issue #73) ==================== - -/** - * Security: Validate file path is within allowed directories - * - ~/.ccs/ directory: read/write allowed - * - ~/.claude/settings.json: read-only - */ -function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } { - const expandedPath = expandPath(filePath); - const normalizedPath = path.normalize(expandedPath); - const ccsDir = getCcsDir(); - const claudeSettingsPath = expandPath('~/.claude/settings.json'); - - // Check if path is within ~/.ccs/ - if (normalizedPath.startsWith(ccsDir)) { - // Block access to sensitive subdirectories - const relativePath = normalizedPath.slice(ccsDir.length); - if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { - return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; - } - return { valid: true, readonly: false }; - } - - // Allow read-only access to ~/.claude/settings.json - if (normalizedPath === claudeSettingsPath) { - return { valid: true, readonly: true }; - } - - return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; -} - -/** - * GET /api/file - Read a file with path validation - * Query params: path (required) - * Returns: { content: string, mtime: number, readonly: boolean, path: string } - */ -apiRoutes.get('/file', (req: Request, res: Response): void => { - const filePath = req.query.path as string; - - if (!filePath) { - res.status(400).json({ error: 'Missing required query parameter: path' }); - return; - } - - const validation = validateFilePath(filePath); - if (!validation.valid) { - res.status(403).json({ error: validation.error }); - return; - } - - const expandedPath = expandPath(filePath); - - if (!fs.existsSync(expandedPath)) { - res.status(404).json({ error: 'File not found' }); - return; - } - - try { - const stat = fs.statSync(expandedPath); - const content = fs.readFileSync(expandedPath, 'utf8'); - - res.json({ - content, - mtime: stat.mtime.getTime(), - readonly: validation.readonly, - path: expandedPath, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * PUT /api/file - Write a file with conflict detection and backup - * Query params: path (required) - * Body: { content: string, expectedMtime?: number } - * Returns: { success: true, mtime: number, backupPath?: string } - */ -apiRoutes.put('/file', (req: Request, res: Response): void => { - const filePath = req.query.path as string; - const { content, expectedMtime } = req.body; - - if (!filePath) { - res.status(400).json({ error: 'Missing required query parameter: path' }); - return; - } - - if (typeof content !== 'string') { - res.status(400).json({ error: 'Missing required field: content' }); - return; - } - - const validation = validateFilePath(filePath); - if (!validation.valid) { - res.status(403).json({ error: validation.error }); - return; - } - - if (validation.readonly) { - res.status(403).json({ error: 'File is read-only' }); - return; - } - - const expandedPath = expandPath(filePath); - const ccsDir = getCcsDir(); - - // Conflict detection (if file exists and expectedMtime provided) - if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { - const stat = fs.statSync(expandedPath); - if (stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; - } - } - - try { - // Create backup if file exists - let backupPath: string | undefined; - if (fs.existsSync(expandedPath)) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); - } - const filename = path.basename(expandedPath); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); - fs.copyFileSync(expandedPath, backupPath); - } - - // Ensure parent directory exists - const parentDir = path.dirname(expandedPath); - if (!fs.existsSync(parentDir)) { - fs.mkdirSync(parentDir, { recursive: true }); - } - - // Write atomically - const tempPath = expandedPath + '.tmp'; - fs.writeFileSync(tempPath, content); - fs.renameSync(tempPath, expandedPath); - - const newStat = fs.statSync(expandedPath); - res.json({ - success: true, - mtime: newStat.mtime.getTime(), - backupPath, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/files - List editable files in ~/.ccs/ - * Returns: { files: Array<{ name: string, path: string, mtime: number }> } - */ -apiRoutes.get('/files', (_req: Request, res: Response): void => { - const ccsDir = getCcsDir(); - - if (!fs.existsSync(ccsDir)) { - res.json({ files: [] }); - return; - } - - try { - const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); - const files = entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) - .map((entry) => { - const filePath = path.join(ccsDir, entry.name); - const stat = fs.statSync(filePath); - return { - name: entry.name, - path: `~/.ccs/${entry.name}`, - mtime: stat.mtime.getTime(), - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - - res.json({ files }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics - * Returns: CliproxyStats or error if proxy not running - */ -apiRoutes.get('/cliproxy/stats', async (_req: Request, res: Response): Promise => { - try { - // Check if proxy is running first - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxyAPI not running', - message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats', - }); - return; - } - - // Fetch stats from management API - const stats = await fetchCliproxyStats(); - if (!stats) { - res.status(503).json({ - error: 'Stats unavailable', - message: 'CLIProxyAPI is running but stats endpoint not responding', - }); - return; - } - - res.json(stats); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/status - Check CLIProxyAPI running status - * Returns: { running: boolean } - */ -apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise => { - try { - const running = await isCliproxyRunning(); - res.json({ running }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/proxy-status - Get detailed proxy process status - * Returns: { running, port?, pid?, sessionCount?, startedAt? } - * Combines session tracker data with actual port check for accuracy - */ -apiRoutes.get('/cliproxy/proxy-status', async (_req: Request, res: Response): Promise => { - try { - // First check session tracker for detailed info - const sessionStatus = getProxyProcessStatus(); - - // If session tracker says running, trust it - if (sessionStatus.running) { - res.json(sessionStatus); - return; - } - - // Session tracker says not running, but proxy might be running without session tracking - // (e.g., started before session persistence was implemented) - const actuallyRunning = await isCliproxyRunning(); - - if (actuallyRunning) { - // Proxy running but no session lock - legacy/untracked instance - res.json({ - running: true, - port: 8317, // Default port - sessionCount: 0, // Unknown sessions - // No pid/startedAt since we don't have session lock - }); - } else { - res.json(sessionStatus); - } - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/cliproxy/proxy-start - Start the CLIProxy service - * Returns: { started, alreadyRunning, port, error? } - * Starts proxy in background if not already running - */ -apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Promise => { - try { - const result = await ensureCliproxyService(); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/cliproxy/proxy-stop - Stop the CLIProxy service - * Returns: { stopped, pid?, sessionCount?, error? } - */ -apiRoutes.post('/cliproxy/proxy-stop', async (_req: Request, res: Response): Promise => { - try { - const result = await stopProxy(); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates - * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } - */ -apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise => { - try { - const result = await checkCliproxyUpdate(); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/models - Get available models from CLIProxyAPI - * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } - */ -apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise => { - try { - // Check if proxy is running first - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxyAPI not running', - message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models', - }); - return; - } - - // Fetch models from /v1/models endpoint - const modelsResponse = await fetchCliproxyModels(); - if (!modelsResponse) { - res.status(503).json({ - error: 'Models unavailable', - message: 'CLIProxyAPI is running but /v1/models endpoint not responding', - }); - return; - } - - res.json(modelsResponse); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -// ==================== Error Logs ==================== - -/** - * GET /api/cliproxy/error-logs - Get list of error log files - * Returns: { files: CliproxyErrorLog[] } or error if proxy not running - */ -apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Promise => { - try { - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxyAPI not running', - message: 'Start a CLIProxy session to view error logs', - }); - return; - } - - const files = await fetchCliproxyErrorLogs(); - if (files === null) { - res.status(503).json({ - error: 'Error logs unavailable', - message: 'CLIProxyAPI is running but error logs endpoint not responding', - }); - return; - } - - // Inject absolute paths into each file entry - const logsDir = path.join(getCliproxyWritablePath(), 'logs'); - const filesWithPaths = files.map((file) => ({ - ...file, - absolutePath: path.join(logsDir, file.name), - })); - - res.json({ files: filesWithPaths }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/error-logs/:name - Get content of a specific error log - * Returns: plain text log content - */ -apiRoutes.get('/cliproxy/error-logs/:name', async (req: Request, res: Response): Promise => { - const { name } = req.params; - - // Validate filename format and prevent path traversal - if ( - !name || - !name.startsWith('error-') || - !name.endsWith('.log') || - name.includes('..') || - name.includes('/') || - name.includes('\\') - ) { - res.status(400).json({ error: 'Invalid error log filename' }); - return; - } - - try { - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ error: 'CLIProxyAPI not running' }); - return; - } - - const content = await fetchCliproxyErrorLogContent(name); - if (content === null) { - res.status(404).json({ error: 'Error log not found' }); - return; - } - - res.type('text/plain').send(content); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -// ============================================ -// OpenAI Compatibility Layer Routes -// ============================================ - -/** - * GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers - */ -apiRoutes.get('/cliproxy/openai-compat', (_req: Request, res: Response): void => { - try { - const providers = listOpenAICompatProviders(); - // Mask API keys for security - const masked = providers.map((p) => ({ - ...p, - apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', - })); - res.json({ providers: masked }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates - */ -apiRoutes.get('/cliproxy/openai-compat/templates', (_req: Request, res: Response): void => { - res.json({ - templates: [ - { ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' }, - { ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' }, - ], - }); -}); - -/** - * GET /api/cliproxy/openai-compat/:name - Get a specific provider - */ -apiRoutes.get('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { - try { - const provider = getOpenAICompatProvider(req.params.name); - if (!provider) { - res.status(404).json({ error: `Provider '${req.params.name}' not found` }); - return; - } - // Mask API key - res.json({ - ...provider, - apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/cliproxy/openai-compat - Add a new provider - * Body: { name, baseUrl, apiKey, models: [{ name, alias }] } - */ -apiRoutes.post('/cliproxy/openai-compat', (req: Request, res: Response): void => { - try { - const { name, baseUrl, apiKey, models } = req.body; - - // Validation - if (!name || typeof name !== 'string') { - res.status(400).json({ error: 'name is required' }); - return; - } - // Validate reserved names - if (isReservedName(name)) { - res.status(400).json({ - error: `Provider name '${name}' is reserved`, - reserved: RESERVED_PROFILE_NAMES, - }); - return; - } - if (!baseUrl || typeof baseUrl !== 'string') { - res.status(400).json({ error: 'baseUrl is required' }); - return; - } - if (!apiKey || typeof apiKey !== 'string') { - res.status(400).json({ error: 'apiKey is required' }); - return; - } - - addOpenAICompatProvider({ - name, - baseUrl, - apiKey, - models: models || [], - }); - - res.status(201).json({ success: true, name }); - } catch (error) { - const message = (error as Error).message; - if (message.includes('already exists')) { - res.status(409).json({ error: message }); - } else { - res.status(500).json({ error: message }); - } - } -}); - -/** - * PUT /api/cliproxy/openai-compat/:name - Update a provider - * Body: { baseUrl?, apiKey?, models?, name? (for rename) } - */ -apiRoutes.put('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { - try { - const { baseUrl, apiKey, models, name: newName } = req.body; - - updateOpenAICompatProvider(req.params.name, { - baseUrl, - apiKey, - models, - name: newName, - }); - - res.json({ success: true }); - } catch (error) { - const message = (error as Error).message; - if (message.includes('not found')) { - res.status(404).json({ error: message }); - } else { - res.status(500).json({ error: message }); - } - } -}); - -/** - * DELETE /api/cliproxy/openai-compat/:name - Remove a provider - */ -apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): void => { - try { - const removed = removeOpenAICompatProvider(req.params.name); - if (!removed) { - res.status(404).json({ error: `Provider '${req.params.name}' not found` }); - return; - } - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -// ==================== WebSearch Configuration ==================== - -/** - * GET /api/websearch - Get WebSearch configuration - * Returns: WebSearchConfig with enabled, provider, fallback - */ -apiRoutes.get('/websearch', (_req: Request, res: Response): void => { - try { - const config = getWebSearchConfig(); - res.json(config); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * PUT /api/websearch - Update WebSearch configuration - * Body: WebSearchConfig fields (enabled, providers) - * Dashboard is the source of truth for provider selection. - */ -apiRoutes.put('/websearch', (req: Request, res: Response): void => { - const { enabled, providers } = req.body as Partial; - - // Validate enabled - if (enabled !== undefined && typeof enabled !== 'boolean') { - res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); - return; - } - - // Validate providers if specified - if (providers !== undefined && typeof providers !== 'object') { - res.status(400).json({ error: 'Invalid value for providers. Must be an object.' }); - return; - } - - try { - // Load existing config and update websearch section - const existingConfig = loadUnifiedConfig(); - if (!existingConfig) { - res.status(500).json({ error: 'Failed to load config' }); - return; - } - - // Merge updates - supports Gemini CLI and Grok CLI - existingConfig.websearch = { - enabled: enabled ?? existingConfig.websearch?.enabled ?? true, - providers: providers - ? { - gemini: { - enabled: - providers.gemini?.enabled ?? - existingConfig.websearch?.providers?.gemini?.enabled ?? - true, - model: - providers.gemini?.model ?? - existingConfig.websearch?.providers?.gemini?.model ?? - 'gemini-2.5-flash', - timeout: - providers.gemini?.timeout ?? - existingConfig.websearch?.providers?.gemini?.timeout ?? - 55, - }, - grok: { - enabled: - providers.grok?.enabled ?? - existingConfig.websearch?.providers?.grok?.enabled ?? - false, - timeout: - providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55, - }, - opencode: { - enabled: - providers.opencode?.enabled ?? - existingConfig.websearch?.providers?.opencode?.enabled ?? - false, - model: - providers.opencode?.model ?? - existingConfig.websearch?.providers?.opencode?.model ?? - 'opencode/grok-code', - timeout: - providers.opencode?.timeout ?? - existingConfig.websearch?.providers?.opencode?.timeout ?? - 60, - }, - } - : existingConfig.websearch?.providers, - }; - - saveUnifiedConfig(existingConfig); - - res.json({ - success: true, - websearch: existingConfig.websearch, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/websearch/status - Get WebSearch status - * Returns: { geminiCli, grokCli, opencodeCli, readiness } - */ -apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => { - try { - const geminiCli = getGeminiCliStatus(); - const grokCli = getGrokCliStatus(); - const opencodeCli = getOpenCodeCliStatus(); - const readiness = getWebSearchReadiness(); - - res.json({ - geminiCli: { - installed: geminiCli.installed, - path: geminiCli.path, - version: geminiCli.version, - }, - grokCli: { - installed: grokCli.installed, - path: grokCli.path, - version: grokCli.version, - }, - opencodeCli: { - installed: opencodeCli.installed, - path: opencodeCli.path, - version: opencodeCli.version, - }, - readiness: { - status: readiness.readiness, - message: readiness.message, - }, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -// ============================================================================ -// COPILOT API ROUTES -// GitHub Copilot integration via copilot-api proxy -// ============================================================================ - -import { - checkAuthStatus as checkCopilotAuth, - startAuthFlow as startCopilotAuth, - getCopilotStatus, - startDaemon as startCopilotDaemon, - stopDaemon as stopCopilotDaemon, - getAvailableModels as getCopilotModels, - isCopilotApiInstalled, - ensureCopilotApi, - installCopilotApiVersion, - getCopilotApiInfo, - getInstalledVersion as getCopilotInstalledVersion, -} from '../copilot'; -import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; -import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; - -/** - * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) - */ -apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise => { - try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - const status = await getCopilotStatus(copilotConfig); - const installed = isCopilotApiInstalled(); - const version = getCopilotInstalledVersion(); - - res.json({ - enabled: copilotConfig.enabled, - installed, - version, - authenticated: status.auth.authenticated, - daemon_running: status.daemon.running, - port: copilotConfig.port, - model: copilotConfig.model, - account_type: copilotConfig.account_type, - auto_start: copilotConfig.auto_start, - rate_limit: copilotConfig.rate_limit, - wait_on_limit: copilotConfig.wait_on_limit, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/copilot/config - Get Copilot configuration - */ -apiRoutes.get('/copilot/config', (_req: Request, res: Response): void => { - try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - res.json(copilotConfig); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * PUT /api/copilot/config - Update Copilot configuration - */ -apiRoutes.put('/copilot/config', (req: Request, res: Response): void => { - try { - const updates = req.body; - const config = loadOrCreateUnifiedConfig(); - - // Merge updates with existing config - config.copilot = { - enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, - auto_start: - updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start, - port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, - account_type: - updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type, - rate_limit: - updates.rate_limit !== undefined - ? updates.rate_limit - : (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit), - wait_on_limit: - updates.wait_on_limit ?? - config.copilot?.wait_on_limit ?? - DEFAULT_COPILOT_CONFIG.wait_on_limit, - model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, - // Model mapping for opus/sonnet/haiku tiers - opus_model: - updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model, - sonnet_model: - updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model, - haiku_model: - updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model, - }; - - saveUnifiedConfig(config); - res.json({ success: true, copilot: config.copilot }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/copilot/auth/start - Start GitHub OAuth flow - * Note: This is a long-running operation that opens browser - */ -apiRoutes.post('/copilot/auth/start', async (_req: Request, res: Response): Promise => { - try { - const result = await startCopilotAuth(); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/copilot/auth/status - Get auth status only - */ -apiRoutes.get('/copilot/auth/status', async (_req: Request, res: Response): Promise => { - try { - const status = await checkCopilotAuth(); - res.json(status); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/copilot/models - Get available models - */ -apiRoutes.get('/copilot/models', async (_req: Request, res: Response): Promise => { - try { - const config = loadOrCreateUnifiedConfig(); - const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; - const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model; - const models = await getCopilotModels(port); - - // Mark current model - const modelsWithCurrent = models.map((m) => ({ - ...m, - isCurrent: m.id === currentModel, - })); - - res.json({ models: modelsWithCurrent, current: currentModel }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/copilot/daemon/start - Start copilot-api daemon - */ -apiRoutes.post('/copilot/daemon/start', async (_req: Request, res: Response): Promise => { - try { - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - const result = await startCopilotDaemon(copilotConfig); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/copilot/daemon/stop - Stop copilot-api daemon - */ -apiRoutes.post('/copilot/daemon/stop', async (_req: Request, res: Response): Promise => { - try { - const result = await stopCopilotDaemon(); - res.json(result); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * POST /api/copilot/install - Install copilot-api - * Auto-installs latest version or specific version if provided - */ -apiRoutes.post('/copilot/install', async (req: Request, res: Response): Promise => { - try { - const { version } = req.body || {}; - - if (version) { - // Install specific version - await installCopilotApiVersion(version); - } else { - // Install latest version - await ensureCopilotApi(); - } - - const info = getCopilotApiInfo(); - res.json({ - success: true, - installed: info.installed, - version: info.version, - path: info.path, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/copilot/info - Get copilot-api installation info - */ -apiRoutes.get('/copilot/info', (_req: Request, res: Response): void => { - try { - const info = getCopilotApiInfo(); - res.json(info); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * GET /api/copilot/settings/raw - Get raw copilot.settings.json - * Returns the raw JSON content for editing in the code editor - */ -apiRoutes.get('/copilot/settings/raw', (_req: Request, res: Response): void => { - try { - const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); - const config = loadOrCreateUnifiedConfig(); - const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - - // Default model for all tiers - const defaultModel = copilotConfig.model; - - // If file doesn't exist, return default structure with all model mappings - if (!fs.existsSync(settingsPath)) { - // Create settings structure matching CLIProxy pattern - always include all model mappings - // Use 127.0.0.1 instead of localhost for more reliable local connections - const defaultSettings = { - env: { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, - ANTHROPIC_AUTH_TOKEN: 'copilot-managed', - ANTHROPIC_MODEL: defaultModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel, - }, - }; - - res.json({ - settings: defaultSettings, - mtime: Date.now(), - path: `~/.ccs/copilot.settings.json`, - exists: false, - }); - return; - } - - const content = fs.readFileSync(settingsPath, 'utf-8'); - const settings = JSON.parse(content); - const stat = fs.statSync(settingsPath); - - res.json({ - settings, - mtime: stat.mtimeMs, - path: `~/.ccs/copilot.settings.json`, - exists: true, - }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * PUT /api/copilot/settings/raw - Save raw copilot.settings.json - * Saves the raw JSON content from the code editor - */ -apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => { - try { - const { settings, expectedMtime } = req.body; - const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); - - // Check for conflict if file exists and expectedMtime provided - if (fs.existsSync(settingsPath) && expectedMtime) { - const stat = fs.statSync(settingsPath); - if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { - res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); - return; - } - } - - // Write settings file atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - // Also sync model mappings back to unified config - const config = loadOrCreateUnifiedConfig(); - const env = settings.env || {}; - - config.copilot = { - ...(config.copilot ?? DEFAULT_COPILOT_CONFIG), - model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, - opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, - sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, - haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, - }; - saveUnifiedConfig(config); - - const stat = fs.statSync(settingsPath); - res.json({ success: true, mtime: stat.mtimeMs }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -// ==================== Global Environment Variables ==================== - -/** - * GET /api/global-env - Get global environment variables configuration - * Returns the global_env section from config.yaml - */ -apiRoutes.get('/global-env', (_req: Request, res: Response): void => { - try { - const config = getGlobalEnvConfig(); - res.json(config); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); - -/** - * PUT /api/global-env - Update global environment variables configuration - * Updates the global_env section in config.yaml - */ -apiRoutes.put('/global-env', (req: Request, res: Response): void => { - try { - const { enabled, env } = req.body; - const config = loadOrCreateUnifiedConfig(); - - // Validate env is an object with string values - if (env !== undefined && typeof env === 'object' && env !== null) { - for (const [key, value] of Object.entries(env)) { - if (typeof value !== 'string') { - res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); - return; - } - } - } - - // Update global_env section - config.global_env = { - enabled: enabled ?? config.global_env?.enabled ?? true, - env: env ?? config.global_env?.env ?? {}, - }; - - saveUnifiedConfig(config); - res.json({ success: true, config: config.global_env }); - } catch (error) { - res.status(500).json({ error: (error as Error).message }); - } -}); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts new file mode 100644 index 00000000..6d5d16b2 --- /dev/null +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -0,0 +1,243 @@ +/** + * CLIProxy Auth Routes - Authentication and account management for CLIProxy providers + */ + +import { Router, Request, Response } from 'express'; +import { + getAllAuthStatus, + getOAuthConfig, + initializeAccounts, + triggerOAuth, +} from '../../cliproxy/auth-handler'; +import { + submitProjectSelection, + getPendingSelection, +} from '../../cliproxy/project-selection-handler'; +import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher'; +import { + getAllAccountsSummary, + getProviderAccounts, + setDefaultAccount as setDefaultAccountFn, + removeAccount as removeAccountFn, + touchAccount, +} from '../../cliproxy/account-manager'; +import type { CLIProxyProvider } from '../../cliproxy/types'; + +const router = Router(); + +// Valid providers list +const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + +/** + * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles + * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers + */ +router.get('/', async (_req: Request, res: Response) => { + // Initialize accounts from existing tokens on first request + initializeAccounts(); + + // Fetch CLIProxyAPI usage stats to determine active providers + const stats = await fetchCliproxyStats(); + + // Map CLIProxyAPI provider names to our internal provider names + const statsProviderMap: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', + }; + + // Update lastUsedAt for providers with recent activity + if (stats?.requestsByProvider) { + for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { + if (requestCount > 0) { + const provider = statsProviderMap[statsProvider.toLowerCase()]; + if (provider) { + // Touch the default account for this provider (or all accounts) + const accounts = getProviderAccounts(provider); + for (const account of accounts) { + // Only touch if this is the default account (most likely being used) + if (account.isDefault) { + touchAccount(provider, account.id); + } + } + } + } + } + } + + const statuses = getAllAuthStatus(); + + const authStatus = statuses.map((status) => { + const oauthConfig = getOAuthConfig(status.provider); + return { + provider: status.provider, + displayName: oauthConfig.displayName, + authenticated: status.authenticated, + lastAuth: status.lastAuth?.toISOString() || null, + tokenFiles: status.tokenFiles.length, + accounts: status.accounts, + defaultAccount: status.defaultAccount, + }; + }); + + res.json({ authStatus }); +}); + +// ==================== Account Management ==================== + +/** + * GET /api/cliproxy/accounts - Get all accounts across all providers + */ +router.get('/accounts', (_req: Request, res: Response) => { + // Initialize accounts from existing tokens + initializeAccounts(); + + const accounts = getAllAccountsSummary(); + res.json({ accounts }); +}); + +/** + * GET /api/cliproxy/accounts/:provider - Get accounts for a specific provider + */ +router.get('/accounts/:provider', (req: Request, res: Response): void => { + const { provider } = req.params; + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const accounts = getProviderAccounts(provider as CLIProxyProvider); + res.json({ provider, accounts }); +}); + +/** + * POST /api/cliproxy/accounts/:provider/default - Set default account for provider + */ +router.post('/accounts/:provider/default', (req: Request, res: Response): void => { + const { provider } = req.params; + const { accountId } = req.body; + + if (!accountId) { + res.status(400).json({ error: 'Missing required field: accountId' }); + return; + } + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); + + if (success) { + res.json({ provider, defaultAccount: accountId }); + } else { + res.status(404).json({ error: 'Account not found' }); + } +}); + +/** + * DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account + */ +router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => { + const { provider, accountId } = req.params; + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const success = removeAccountFn(provider as CLIProxyProvider, accountId); + + if (success) { + res.json({ provider, accountId, deleted: true }); + } else { + res.status(404).json({ error: 'Account not found' }); + } +}); + +/** + * POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider + * Opens browser for authentication and returns account info when complete + */ +router.post('/:provider/start', async (req: Request, res: Response): Promise => { + const { provider } = req.params; + const { nickname } = req.body; + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + try { + // Trigger OAuth flow - this opens browser and waits for completion + const account = await triggerOAuth(provider as CLIProxyProvider, { + add: true, // Always add mode from UI + headless: false, // Force interactive mode + nickname: nickname || undefined, + fromUI: true, // Enable project selection prompt in UI + }); + + if (account) { + res.json({ + success: true, + account: { + id: account.id, + email: account.email, + nickname: account.nickname, + provider: account.provider, + isDefault: account.isDefault, + }, + }); + } else { + res.status(400).json({ error: 'Authentication failed or was cancelled' }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt + * Returns project list for user to select from during OAuth flow + */ +router.get('/project-selection/:sessionId', (req: Request, res: Response): void => { + const { sessionId } = req.params; + + const pending = getPendingSelection(sessionId); + if (pending) { + res.json(pending); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } +}); + +/** + * POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection + * Submits user's project choice during OAuth flow + */ +router.post('/project-selection/:sessionId', (req: Request, res: Response): void => { + const { sessionId } = req.params; + const { selectedId } = req.body; + + if (!selectedId && selectedId !== '') { + res.status(400).json({ error: 'selectedId is required (use empty string for default)' }); + return; + } + + const success = submitProjectSelection({ sessionId, selectedId }); + if (success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } +}); + +export default router; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts new file mode 100644 index 00000000..c1b4a676 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -0,0 +1,252 @@ +/** + * CLIProxy Stats Routes - Stats, status, models, error logs for CLIProxyAPI + */ + +import { Router, Request, Response } from 'express'; +import * as path from 'path'; +import { + fetchCliproxyStats, + fetchCliproxyModels, + isCliproxyRunning, + fetchCliproxyErrorLogs, + fetchCliproxyErrorLogContent, +} from '../../cliproxy/stats-fetcher'; +import { getCliproxyWritablePath } from '../../cliproxy/config-generator'; +import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; +import { ensureCliproxyService } from '../../cliproxy/service-manager'; +import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; + +const router = Router(); + +/** + * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics + * Returns: CliproxyStats or error if proxy not running + */ +router.get('/stats', async (_req: Request, res: Response): Promise => { + try { + // Check if proxy is running first + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats', + }); + return; + } + + // Fetch stats from management API + const stats = await fetchCliproxyStats(); + if (!stats) { + res.status(503).json({ + error: 'Stats unavailable', + message: 'CLIProxyAPI is running but stats endpoint not responding', + }); + return; + } + + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/status - Check CLIProxyAPI running status + * Returns: { running: boolean } + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + res.json({ running }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/proxy-status - Get detailed proxy process status + * Returns: { running, port?, pid?, sessionCount?, startedAt? } + * Combines session tracker data with actual port check for accuracy + */ +router.get('/proxy-status', async (_req: Request, res: Response): Promise => { + try { + // First check session tracker for detailed info + const sessionStatus = getProxyProcessStatus(); + + // If session tracker says running, trust it + if (sessionStatus.running) { + res.json(sessionStatus); + return; + } + + // Session tracker says not running, but proxy might be running without session tracking + // (e.g., started before session persistence was implemented) + const actuallyRunning = await isCliproxyRunning(); + + if (actuallyRunning) { + // Proxy running but no session lock - legacy/untracked instance + res.json({ + running: true, + port: 8317, // Default port + sessionCount: 0, // Unknown sessions + // No pid/startedAt since we don't have session lock + }); + } else { + res.json(sessionStatus); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/proxy-start - Start the CLIProxy service + * Returns: { started, alreadyRunning, port, error? } + * Starts proxy in background if not already running + */ +router.post('/proxy-start', async (_req: Request, res: Response): Promise => { + try { + const result = await ensureCliproxyService(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/proxy-stop - Stop the CLIProxy service + * Returns: { stopped, pid?, sessionCount?, error? } + */ +router.post('/proxy-stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopProxy(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates + * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } + */ +router.get('/update-check', async (_req: Request, res: Response): Promise => { + try { + const result = await checkCliproxyUpdate(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/models - Get available models from CLIProxyAPI + * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } + */ +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + // Check if proxy is running first + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models', + }); + return; + } + + // Fetch models from /v1/models endpoint + const modelsResponse = await fetchCliproxyModels(); + if (!modelsResponse) { + res.status(503).json({ + error: 'Models unavailable', + message: 'CLIProxyAPI is running but /v1/models endpoint not responding', + }); + return; + } + + res.json(modelsResponse); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Error Logs ==================== + +/** + * GET /api/cliproxy/error-logs - Get list of error log files + * Returns: { files: CliproxyErrorLog[] } or error if proxy not running + */ +router.get('/error-logs', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session to view error logs', + }); + return; + } + + const files = await fetchCliproxyErrorLogs(); + if (files === null) { + res.status(503).json({ + error: 'Error logs unavailable', + message: 'CLIProxyAPI is running but error logs endpoint not responding', + }); + return; + } + + // Inject absolute paths into each file entry + const logsDir = path.join(getCliproxyWritablePath(), 'logs'); + const filesWithPaths = files.map((file) => ({ + ...file, + absolutePath: path.join(logsDir, file.name), + })); + + res.json({ files: filesWithPaths }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/error-logs/:name - Get content of a specific error log + * Returns: plain text log content + */ +router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { + const { name } = req.params; + + // Validate filename format and prevent path traversal + if ( + !name || + !name.startsWith('error-') || + !name.endsWith('.log') || + name.includes('..') || + name.includes('/') || + name.includes('\\') + ) { + res.status(400).json({ error: 'Invalid error log filename' }); + return; + } + + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ error: 'CLIProxyAPI not running' }); + return; + } + + const content = await fetchCliproxyErrorLogContent(name); + if (content === null) { + res.status(404).json({ error: 'Error log not found' }); + return; + } + + res.type('text/plain').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts new file mode 100644 index 00000000..03bbc62c --- /dev/null +++ b/src/web-server/routes/config-routes.ts @@ -0,0 +1,146 @@ +/** + * Config Routes - Unified config management and migration + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import { + hasUnifiedConfig, + loadUnifiedConfig, + saveUnifiedConfig, + getConfigFormat, + getConfigYamlPath, +} from '../../config/unified-config-loader'; +import { + needsMigration, + migrate, + rollback, + getBackupDirectories, +} from '../../config/migration-manager'; +import { getProfileSecrets, setProfileSecrets } from '../../config/secrets-manager'; +import { isUnifiedConfig } from '../../config/unified-config-types'; + +const router = Router(); + +/** + * GET /api/config/format - Return current config format and migration status + */ +router.get('/format', (_req: Request, res: Response) => { + res.json({ + format: getConfigFormat(), + migrationNeeded: needsMigration(), + backups: getBackupDirectories(), + }); +}); + +/** + * GET /api/config - Return unified config (excludes secrets) + */ +router.get('/', (_req: Request, res: Response): void => { + if (!hasUnifiedConfig()) { + res.status(400).json({ error: 'Unified config not enabled' }); + return; + } + + const config = loadUnifiedConfig(); + if (!config) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + res.json(config); +}); + +/** + * GET /api/config/raw - Return raw YAML content for display + */ +router.get('/raw', (_req: Request, res: Response): void => { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + res.status(404).json({ error: 'Config file not found' }); + return; + } + try { + const content = fs.readFileSync(yamlPath, 'utf8'); + res.type('text/plain').send(content); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * PUT /api/config - Update unified config + */ +router.put('/', (req: Request, res: Response): void => { + const config = req.body; + + if (!isUnifiedConfig(config)) { + res.status(400).json({ error: 'Invalid config format' }); + return; + } + + try { + saveUnifiedConfig(config); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * POST /api/config/migrate - Trigger migration from JSON to YAML + */ +router.post('/migrate', async (req: Request, res: Response) => { + const dryRun = req.query.dryRun === 'true'; + const result = await migrate(dryRun); + res.json(result); +}); + +/** + * POST /api/config/rollback - Rollback migration to JSON format + */ +router.post('/rollback', async (req: Request, res: Response): Promise => { + const { backupPath } = req.body; + + if (!backupPath || typeof backupPath !== 'string') { + res.status(400).json({ error: 'Missing required field: backupPath' }); + return; + } + + const success = await rollback(backupPath); + res.json({ success }); +}); + +/** + * PUT /api/secrets/:profile - Update profile secrets (write-only) + */ +router.put('/secrets/:profile', (req: Request, res: Response): void => { + const { profile } = req.params; + const secrets = req.body; + + if (!secrets || typeof secrets !== 'object') { + res.status(400).json({ error: 'Invalid secrets format' }); + return; + } + + try { + setProfileSecrets(profile, secrets as Record); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * GET /api/secrets/:profile/exists - Check if secrets exist (no values returned) + */ +router.get('/secrets/:profile/exists', (req: Request, res: Response) => { + const { profile } = req.params; + const secrets = getProfileSecrets(profile); + res.json({ + exists: Object.keys(secrets).length > 0, + keys: Object.keys(secrets), // Only key names, not values + }); +}); + +export default router; diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts new file mode 100644 index 00000000..71ac1817 --- /dev/null +++ b/src/web-server/routes/copilot-routes.ts @@ -0,0 +1,218 @@ +/** + * Copilot Routes - GitHub Copilot integration via copilot-api proxy + */ + +import { Router, Request, Response } from 'express'; +import { + checkAuthStatus as checkCopilotAuth, + startAuthFlow as startCopilotAuth, + getCopilotStatus, + startDaemon as startCopilotDaemon, + stopDaemon as stopCopilotDaemon, + getAvailableModels as getCopilotModels, + isCopilotApiInstalled, + ensureCopilotApi, + installCopilotApiVersion, + getCopilotApiInfo, + getInstalledVersion as getCopilotInstalledVersion, +} from '../../copilot'; +import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import copilotSettingsRoutes from './copilot-settings-routes'; + +const router = Router(); + +// Mount settings sub-routes +router.use('/settings', copilotSettingsRoutes); + +/** + * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const status = await getCopilotStatus(copilotConfig); + const installed = isCopilotApiInstalled(); + const version = getCopilotInstalledVersion(); + + res.json({ + enabled: copilotConfig.enabled, + installed, + version, + authenticated: status.auth.authenticated, + daemon_running: status.daemon.running, + port: copilotConfig.port, + model: copilotConfig.model, + account_type: copilotConfig.account_type, + auto_start: copilotConfig.auto_start, + rate_limit: copilotConfig.rate_limit, + wait_on_limit: copilotConfig.wait_on_limit, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/config - Get Copilot configuration + */ +router.get('/config', (_req: Request, res: Response): void => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + res.json(copilotConfig); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/copilot/config - Update Copilot configuration + */ +router.put('/config', (req: Request, res: Response): void => { + try { + const updates = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + config.copilot = { + enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, + auto_start: + updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start, + port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, + account_type: + updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type, + rate_limit: + updates.rate_limit !== undefined + ? updates.rate_limit + : (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit), + wait_on_limit: + updates.wait_on_limit ?? + config.copilot?.wait_on_limit ?? + DEFAULT_COPILOT_CONFIG.wait_on_limit, + model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, + opus_model: + updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model, + sonnet_model: + updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model, + haiku_model: + updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model, + }; + + saveUnifiedConfig(config); + res.json({ success: true, copilot: config.copilot }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/copilot/auth/start - Start GitHub OAuth flow + */ +router.post('/auth/start', async (_req: Request, res: Response): Promise => { + try { + const result = await startCopilotAuth(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/auth/status - Get auth status only + */ +router.get('/auth/status', async (_req: Request, res: Response): Promise => { + try { + const status = await checkCopilotAuth(); + res.json(status); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/models - Get available models + */ +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port; + const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model; + const models = await getCopilotModels(port); + + const modelsWithCurrent = models.map((m) => ({ + ...m, + isCurrent: m.id === currentModel, + })); + + res.json({ models: modelsWithCurrent, current: currentModel }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/copilot/daemon/start - Start copilot-api daemon + */ +router.post('/daemon/start', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const result = await startCopilotDaemon(copilotConfig); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/copilot/daemon/stop - Stop copilot-api daemon + */ +router.post('/daemon/stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopCopilotDaemon(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/copilot/install - Install copilot-api + */ +router.post('/install', async (req: Request, res: Response): Promise => { + try { + const { version } = req.body || {}; + + if (version) { + await installCopilotApiVersion(version); + } else { + await ensureCopilotApi(); + } + + const info = getCopilotApiInfo(); + res.json({ + success: true, + installed: info.installed, + version: info.version, + path: info.path, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/info - Get copilot-api installation info + */ +router.get('/info', (_req: Request, res: Response): void => { + try { + const info = getCopilotApiInfo(); + res.json(info); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/copilot-settings-routes.ts b/src/web-server/routes/copilot-settings-routes.ts new file mode 100644 index 00000000..19165f2e --- /dev/null +++ b/src/web-server/routes/copilot-settings-routes.ts @@ -0,0 +1,109 @@ +/** + * Copilot Settings Routes - Settings editor and raw settings for GitHub Copilot + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { DEFAULT_COPILOT_CONFIG } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; + +const router = Router(); + +/** + * GET /api/copilot/settings/raw - Get raw copilot.settings.json + * Returns the raw JSON content for editing in the code editor + */ +router.get('/raw', (_req: Request, res: Response): void => { + try { + const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + + // Default model for all tiers + const defaultModel = copilotConfig.model; + + // If file doesn't exist, return default structure with all model mappings + if (!fs.existsSync(settingsPath)) { + // Create settings structure matching CLIProxy pattern + // Use 127.0.0.1 instead of localhost for more reliable local connections + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'copilot-managed', + ANTHROPIC_MODEL: defaultModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel, + }, + }; + + res.json({ + settings: defaultSettings, + mtime: Date.now(), + path: `~/.ccs/copilot.settings.json`, + exists: false, + }); + return; + } + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + const stat = fs.statSync(settingsPath); + + res.json({ + settings, + mtime: stat.mtimeMs, + path: `~/.ccs/copilot.settings.json`, + exists: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/copilot/settings/raw - Save raw copilot.settings.json + * Saves the raw JSON content from the code editor + */ +router.put('/raw', (req: Request, res: Response): void => { + try { + const { settings, expectedMtime } = req.body; + const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); + + // Check for conflict if file exists and expectedMtime provided + if (fs.existsSync(settingsPath) && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { + res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); + return; + } + } + + // Write settings file atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + // Also sync model mappings back to unified config + const config = loadOrCreateUnifiedConfig(); + const env = settings.env || {}; + + config.copilot = { + ...(config.copilot ?? DEFAULT_COPILOT_CONFIG), + model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, + opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, + sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, + haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, + }; + saveUnifiedConfig(config); + + const stat = fs.statSync(settingsPath); + res.json({ success: true, mtime: stat.mtimeMs }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/health-routes.ts b/src/web-server/routes/health-routes.ts new file mode 100644 index 00000000..5740633a --- /dev/null +++ b/src/web-server/routes/health-routes.ts @@ -0,0 +1,32 @@ +/** + * Health Routes - System health checks and fixes + */ + +import { Router, Request, Response } from 'express'; +import { runHealthChecks, fixHealthIssue } from '../health-service'; + +const router = Router(); + +/** + * GET /api/health - Run health checks + */ +router.get('/', async (_req: Request, res: Response) => { + const report = await runHealthChecks(); + res.json(report); +}); + +/** + * POST /api/health/fix/:checkId - Fix a health issue + */ +router.post('/fix/:checkId', (req: Request, res: Response): void => { + const { checkId } = req.params; + const result = fixHealthIssue(checkId); + + if (result.success) { + res.json({ success: true, message: result.message }); + } else { + res.status(400).json({ success: false, message: result.message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts new file mode 100644 index 00000000..7b3f7266 --- /dev/null +++ b/src/web-server/routes/index.ts @@ -0,0 +1,58 @@ +/** + * Routes Aggregator - Combines all domain-specific route modules + * + * This file serves as the central entry point for all API routes, + * mounting each domain router at its appropriate path. + */ + +import { Router } from 'express'; + +// Import domain routers +import profileRoutes from './profile-routes'; +import configRoutes from './config-routes'; +import healthRoutes from './health-routes'; +import providerRoutes from './provider-routes'; +import variantRoutes from './variant-routes'; +import settingsRoutes from './settings-routes'; +import websearchRoutes from './websearch-routes'; +import cliproxyAuthRoutes from './cliproxy-auth-routes'; +import cliproxyStatsRoutes from './cliproxy-stats-routes'; +import copilotRoutes from './copilot-routes'; +import miscRoutes from './misc-routes'; +import cliproxyServerRoutes from './proxy-routes'; + +// Create the main API router +export const apiRoutes = Router(); + +// ==================== Profile & Settings ==================== +// Profile CRUD, settings management, presets, accounts +apiRoutes.use('/profiles', profileRoutes); +apiRoutes.use('/settings', settingsRoutes); +apiRoutes.use('/accounts', profileRoutes); + +// ==================== Unified Config ==================== +// Config format, migration, secrets +apiRoutes.use('/config', configRoutes); +apiRoutes.use('/secrets', configRoutes); + +// ==================== Health Checks ==================== +apiRoutes.use('/health', healthRoutes); + +// ==================== CLIProxy ==================== +// Variants, auth, accounts, stats, status, models, error logs +apiRoutes.use('/cliproxy', variantRoutes); +apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); +apiRoutes.use('/cliproxy', cliproxyStatsRoutes); +apiRoutes.use('/cliproxy/openai-compat', providerRoutes); + +// ==================== WebSearch ==================== +apiRoutes.use('/websearch', websearchRoutes); + +// ==================== Copilot ==================== +apiRoutes.use('/copilot', copilotRoutes); + +// ==================== CLIProxy Server Settings ==================== +apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); + +// ==================== Misc (File API, Global Env) ==================== +apiRoutes.use('/', miscRoutes); diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts new file mode 100644 index 00000000..f88339b4 --- /dev/null +++ b/src/web-server/routes/misc-routes.ts @@ -0,0 +1,224 @@ +/** + * Misc Routes - Generic file API and global environment variables + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + getGlobalEnvConfig, +} from '../../config/unified-config-loader'; +import { validateFilePath } from './route-helpers'; + +const router = Router(); + +// ==================== Generic File API ==================== + +/** + * GET /api/file - Read a file with path validation + * Query params: path (required) + * Returns: { content: string, mtime: number, readonly: boolean, path: string } + */ +router.get('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + const expandedPath = expandPath(filePath); + + if (!fs.existsSync(expandedPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + const stat = fs.statSync(expandedPath); + const content = fs.readFileSync(expandedPath, 'utf8'); + + res.json({ + content, + mtime: stat.mtime.getTime(), + readonly: validation.readonly, + path: expandedPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/file - Write a file with conflict detection and backup + * Query params: path (required) + * Body: { content: string, expectedMtime?: number } + * Returns: { success: true, mtime: number, backupPath?: string } + */ +router.put('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + const { content, expectedMtime } = req.body; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + if (validation.readonly) { + res.status(403).json({ error: 'File is read-only' }); + return; + } + + const expandedPath = expandPath(filePath); + const ccsDir = getCcsDir(); + + // Conflict detection (if file exists and expectedMtime provided) + if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { + const stat = fs.statSync(expandedPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + try { + // Create backup if file exists + let backupPath: string | undefined; + if (fs.existsSync(expandedPath)) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const filename = path.basename(expandedPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); + fs.copyFileSync(expandedPath, backupPath); + } + + // Ensure parent directory exists + const parentDir = path.dirname(expandedPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + // Write atomically + const tempPath = expandedPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, expandedPath); + + const newStat = fs.statSync(expandedPath); + res.json({ + success: true, + mtime: newStat.mtime.getTime(), + backupPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/files - List editable files in ~/.ccs/ + * Returns: { files: Array<{ name: string, path: string, mtime: number }> } + */ +router.get('/files', (_req: Request, res: Response): void => { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(ccsDir)) { + res.json({ files: [] }); + return; + } + + try { + const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(ccsDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + path: `~/.ccs/${entry.name}`, + mtime: stat.mtime.getTime(), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Global Environment Variables ==================== + +/** + * GET /api/global-env - Get global environment variables configuration + * Returns the global_env section from config.yaml + */ +router.get('/global-env', (_req: Request, res: Response): void => { + try { + const config = getGlobalEnvConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/global-env - Update global environment variables configuration + * Updates the global_env section in config.yaml + */ +router.put('/global-env', (req: Request, res: Response): void => { + try { + const { enabled, env } = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Validate env is an object with string values + if (env !== undefined && typeof env === 'object' && env !== null) { + for (const [key, value] of Object.entries(env)) { + if (typeof value !== 'string') { + res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); + return; + } + } + } + + // Update global_env section + config.global_env = { + enabled: enabled ?? config.global_env?.enabled ?? true, + env: env ?? config.global_env?.env ?? {}, + }; + + saveUnifiedConfig(config); + res.json({ success: true, config: config.global_env }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts new file mode 100644 index 00000000..db0c382f --- /dev/null +++ b/src/web-server/routes/profile-routes.ts @@ -0,0 +1,179 @@ +/** + * Profile Routes - CRUD operations for user profiles and accounts + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; +import { + readConfigSafe, + writeConfig, + isConfigured, + createSettingsFile, + updateSettingsFile, +} from './route-helpers'; + +const router = Router(); + +// ==================== Profile CRUD ==================== + +/** + * GET /api/profiles - List all profiles + */ +router.get('/', (_req: Request, res: Response) => { + const config = readConfigSafe(); + const profiles = Object.entries(config.profiles).map(([name, settingsPath]) => ({ + name, + settingsPath, + configured: isConfigured(name, config), + })); + + res.json({ profiles }); +}); + +/** + * POST /api/profiles - Create new profile + */ +router.post('/', (req: Request, res: Response): void => { + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + + if (!name || !baseUrl || !apiKey) { + res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); + return; + } + + // Validate reserved names + if (isReservedName(name)) { + res.status(400).json({ + error: `Profile name '${name}' is reserved`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } + + const config = readConfigSafe(); + + if (config.profiles[name]) { + res.status(409).json({ error: 'Profile already exists' }); + return; + } + + // Ensure .ccs directory exists + if (!fs.existsSync(getCcsDir())) { + fs.mkdirSync(getCcsDir(), { recursive: true }); + } + + // Create settings file with model mapping + const settingsPath = createSettingsFile(name, baseUrl, apiKey, { + model, + opusModel, + sonnetModel, + haikuModel, + }); + + // Update config + config.profiles[name] = settingsPath; + writeConfig(config); + + res.status(201).json({ name, settingsPath }); +}); + +/** + * PUT /api/profiles/:name - Update profile + */ +router.put('/:name', (req: Request, res: Response): void => { + const { name } = req.params; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + + const config = readConfigSafe(); + + if (!config.profiles[name]) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + try { + updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); + res.json({ name, updated: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * DELETE /api/profiles/:name - Delete profile + */ +router.delete('/:name', (req: Request, res: Response): void => { + const { name } = req.params; + + const config = readConfigSafe(); + + if (!config.profiles[name]) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + // Delete settings file + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + + // Remove from config + delete config.profiles[name]; + writeConfig(config); + + res.json({ name, deleted: true }); +}); + +// ==================== Accounts ==================== + +/** + * GET /api/accounts - List accounts from profiles.json + */ +router.get('/accounts', (_req: Request, res: Response): void => { + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + if (!fs.existsSync(profilesPath)) { + res.json({ accounts: [], default: null }); + return; + } + + const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); + const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { + const metadata = meta as Record; + return { + name, + ...metadata, + }; + }); + + res.json({ accounts, default: data.default || null }); +}); + +/** + * POST /api/accounts/default - Set default account + */ +router.post('/accounts/default', (req: Request, res: Response): void => { + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }); + return; + } + + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + const data = fs.existsSync(profilesPath) + ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) + : { profiles: {} }; + + data.default = name; + fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); + + res.json({ default: name }); +}); + +export default router; diff --git a/src/web-server/routes/provider-routes.ts b/src/web-server/routes/provider-routes.ts new file mode 100644 index 00000000..e5b05659 --- /dev/null +++ b/src/web-server/routes/provider-routes.ts @@ -0,0 +1,158 @@ +/** + * Provider Routes - OpenAI-compatible provider management + */ + +import { Router, Request, Response } from 'express'; +import { + listOpenAICompatProviders, + getOpenAICompatProvider, + addOpenAICompatProvider, + updateOpenAICompatProvider, + removeOpenAICompatProvider, + OPENROUTER_TEMPLATE, + TOGETHER_TEMPLATE, +} from '../../cliproxy/openai-compat-manager'; +import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; + +const router = Router(); + +/** + * GET /api/cliproxy/openai-compat - List all OpenAI-compatible providers + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const providers = listOpenAICompatProviders(); + // Mask API keys for security + const masked = providers.map((p) => ({ + ...p, + apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', + })); + res.json({ providers: masked }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/openai-compat/templates - Get pre-configured provider templates + */ +router.get('/templates', (_req: Request, res: Response): void => { + res.json({ + templates: [ + { ...OPENROUTER_TEMPLATE, description: 'OpenRouter - Access multiple AI models' }, + { ...TOGETHER_TEMPLATE, description: 'Together AI - Open source models' }, + ], + }); +}); + +/** + * GET /api/cliproxy/openai-compat/:name - Get a specific provider + */ +router.get('/:name', (req: Request, res: Response): void => { + try { + const provider = getOpenAICompatProvider(req.params.name); + if (!provider) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + // Mask API key + res.json({ + ...provider, + apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/openai-compat - Add a new provider + * Body: { name, baseUrl, apiKey, models: [{ name, alias }] } + */ +router.post('/', (req: Request, res: Response): void => { + try { + const { name, baseUrl, apiKey, models } = req.body; + + // Validation + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'name is required' }); + return; + } + // Validate reserved names + if (isReservedName(name)) { + res.status(400).json({ + error: `Provider name '${name}' is reserved`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } + if (!baseUrl || typeof baseUrl !== 'string') { + res.status(400).json({ error: 'baseUrl is required' }); + return; + } + if (!apiKey || typeof apiKey !== 'string') { + res.status(400).json({ error: 'apiKey is required' }); + return; + } + + addOpenAICompatProvider({ + name, + baseUrl, + apiKey, + models: models || [], + }); + + res.status(201).json({ success: true, name }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('already exists')) { + res.status(409).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * PUT /api/cliproxy/openai-compat/:name - Update a provider + * Body: { baseUrl?, apiKey?, models?, name? (for rename) } + */ +router.put('/:name', (req: Request, res: Response): void => { + try { + const { baseUrl, apiKey, models, name: newName } = req.body; + + updateOpenAICompatProvider(req.params.name, { + baseUrl, + apiKey, + models, + name: newName, + }); + + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + if (message.includes('not found')) { + res.status(404).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } +}); + +/** + * DELETE /api/cliproxy/openai-compat/:name - Remove a provider + */ +router.delete('/:name', (req: Request, res: Response): void => { + try { + const removed = removeOpenAICompatProvider(req.params.name); + if (!removed) { + res.status(404).json({ error: `Provider '${req.params.name}' not found` }); + return; + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts new file mode 100644 index 00000000..9a712d50 --- /dev/null +++ b/src/web-server/routes/route-helpers.ts @@ -0,0 +1,221 @@ +/** + * Route Helpers - Shared utility functions for route handlers + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; +import type { Config, Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { getClaudeEnvVars } from '../../cliproxy/config-generator'; + +/** Model mapping for API profiles */ +export interface ModelMapping { + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +/** + * Read config safely with fallback + */ +export function readConfigSafe(): Config { + try { + return loadConfig(); + } catch { + return { profiles: {} }; + } +} + +/** + * Write config atomically + */ +export function writeConfig(config: Config): void { + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n'); + fs.renameSync(tempPath, configPath); +} + +/** + * Check if profile is configured (has valid settings file) + */ +export function isConfigured(profileName: string, config: Config): boolean { + const settingsPath = config.profiles[profileName]; + if (!settingsPath) return false; + + try { + const expandedPath = expandPath(settingsPath); + if (!fs.existsSync(expandedPath)) return false; + + const settings = loadSettings(expandedPath); + return !!(settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN); + } catch { + return false; + } +} + +/** + * Create settings file for profile + */ +export function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping = {} +): string { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + const { model, opusModel, sonnetModel, haikuModel } = models; + + const settings: Settings = { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ...(model && { ANTHROPIC_MODEL: model }), + ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), + ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), + ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + return `~/.ccs/${name}.settings.json`; +} + +/** + * Update settings file + */ +export function updateSettingsFile( + name: string, + updates: { + baseUrl?: string; + apiKey?: string; + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + } +): void { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + throw new Error('Settings file not found'); + } + + const settings = loadSettings(settingsPath); + + if (updates.baseUrl) { + settings.env = settings.env || {}; + settings.env.ANTHROPIC_BASE_URL = updates.baseUrl; + } + + if (updates.apiKey) { + settings.env = settings.env || {}; + settings.env.ANTHROPIC_AUTH_TOKEN = updates.apiKey; + } + + if (updates.model !== undefined) { + settings.env = settings.env || {}; + if (updates.model) { + settings.env.ANTHROPIC_MODEL = updates.model; + } else { + delete settings.env.ANTHROPIC_MODEL; + } + } + + // Handle model mapping fields + if (updates.opusModel !== undefined) { + settings.env = settings.env || {}; + if (updates.opusModel) { + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + } + } + + if (updates.sonnetModel !== undefined) { + settings.env = settings.env || {}; + if (updates.sonnetModel) { + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + } + } + + if (updates.haikuModel !== undefined) { + settings.env = settings.env || {}; + if (updates.haikuModel) { + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + } + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); +} + +/** + * Create cliproxy variant settings + * Includes base URL and auth token for proper Claude CLI integration + */ +export function createCliproxySettings( + name: string, + provider: CLIProxyProvider, + model?: string +): string { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + + // Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN) + const baseEnv = getClaudeEnvVars(provider); + + const settings: Settings = { + env: { + ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', + ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', + ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '', + ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '', + ANTHROPIC_DEFAULT_SONNET_MODEL: + model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '', + ANTHROPIC_DEFAULT_HAIKU_MODEL: + (baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '', + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + return `~/.ccs/${name}.settings.json`; +} + +/** + * Security: Validate file path is within allowed directories + * - ~/.ccs/ directory: read/write allowed + * - ~/.claude/settings.json: read-only + */ +export function validateFilePath(filePath: string): { + valid: boolean; + readonly: boolean; + error?: string; +} { + const expandedPath = expandPath(filePath); + const normalizedPath = path.normalize(expandedPath); + const ccsDir = getCcsDir(); + const claudeSettingsPath = expandPath('~/.claude/settings.json'); + + // Check if path is within ~/.ccs/ + if (normalizedPath.startsWith(ccsDir)) { + // Block access to sensitive subdirectories + const relativePath = normalizedPath.slice(ccsDir.length); + if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + return { valid: true, readonly: false }; + } + + // Allow read-only access to ~/.claude/settings.json + if (normalizedPath === claudeSettingsPath) { + return { valid: true, readonly: true }; + } + + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; +} diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts new file mode 100644 index 00000000..c810c568 --- /dev/null +++ b/src/web-server/routes/settings-routes.ts @@ -0,0 +1,221 @@ +/** + * Settings Routes - Settings and preset management + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadSettings } from '../../utils/config-manager'; +import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; +import type { Settings } from '../../types/config'; + +const router = Router(); + +/** + * Helper: Mask API keys in settings + */ +function maskApiKeys(settings: Settings): Settings { + if (!settings.env) return settings; + + const masked = { ...settings, env: { ...settings.env } }; + + for (const key of Object.keys(masked.env)) { + if (isSensitiveKey(key)) { + masked.env[key] = maskSensitiveValue(masked.env[key]); + } + } + + return masked; +} + +/** + * GET /api/settings/:profile - Get settings with masked API keys + */ +router.get('/:profile', (req: Request, res: Response): void => { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + const masked = maskApiKeys(settings); + + res.json({ + profile, + settings: masked, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); +}); + +/** + * GET /api/settings/:profile/raw - Get full settings (for editing) + */ +router.get('/:profile/raw', (req: Request, res: Response): void => { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + + res.json({ + profile, + settings, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); +}); + +/** + * PUT /api/settings/:profile - Update settings with conflict detection and backup + */ +router.put('/:profile', (req: Request, res: Response): void => { + const { profile } = req.params; + const { settings, expectedMtime } = req.body; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + const fileExists = fs.existsSync(settingsPath); + + // Only check conflict if file exists and expectedMtime was provided + if (fileExists && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + // Create backup only if file exists + let backupPath: string | undefined; + if (fileExists) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); + } + + // Ensure directory exists for new files + if (!fileExists) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + } + + // Write new settings atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const newStat = fs.statSync(settingsPath); + res.json({ + profile, + mtime: newStat.mtime.getTime(), + backupPath, + created: !fileExists, + }); +}); + +// ==================== Presets ==================== + +/** + * GET /api/settings/:profile/presets - Get saved presets for a provider + */ +router.get('/:profile/presets', (req: Request, res: Response): void => { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.json({ presets: [] }); + return; + } + + const settings = loadSettings(settingsPath); + res.json({ presets: settings.presets || [] }); +}); + +/** + * POST /api/settings/:profile/presets - Create a new preset + */ +router.post('/:profile/presets', (req: Request, res: Response): void => { + const { profile } = req.params; + const { name, default: defaultModel, opus, sonnet, haiku } = req.body; + + if (!name || !defaultModel) { + res.status(400).json({ error: 'Missing required fields: name, default' }); + return; + } + + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + // Create settings file if it doesn't exist + if (!fs.existsSync(settingsPath)) { + fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); + } + + const settings = loadSettings(settingsPath); + settings.presets = settings.presets || []; + + // Check for duplicate name + if (settings.presets.some((p) => p.name === name)) { + res.status(409).json({ error: 'Preset with this name already exists' }); + return; + } + + const preset = { + name, + default: defaultModel, + opus: opus || defaultModel, + sonnet: sonnet || defaultModel, + haiku: haiku || defaultModel, + }; + + settings.presets.push(preset); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.status(201).json({ preset }); +}); + +/** + * DELETE /api/settings/:profile/presets/:name - Delete a preset + */ +router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { + const { profile, name } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const settings = loadSettings(settingsPath); + if (!settings.presets || !settings.presets.some((p) => p.name === name)) { + res.status(404).json({ error: 'Preset not found' }); + return; + } + + settings.presets = settings.presets.filter((p) => p.name !== name); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.json({ success: true }); +}); + +export default router; diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts new file mode 100644 index 00000000..1ce3d601 --- /dev/null +++ b/src/web-server/routes/variant-routes.ts @@ -0,0 +1,159 @@ +/** + * Variant Routes - CLIProxy variant management (custom profiles) + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadSettings } from '../../utils/config-manager'; +import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers'; + +const router = Router(); + +/** + * GET /api/cliproxy - List cliproxy variants + */ +router.get('/', (_req: Request, res: Response) => { + const config = readConfigSafe(); + const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({ + name, + provider: variant.provider, + settings: variant.settings, + account: variant.account || 'default', // Include account field + })); + + res.json({ variants }); +}); + +/** + * POST /api/cliproxy - Create cliproxy variant + */ +router.post('/', (req: Request, res: Response): void => { + const { name, provider, model, account } = req.body; + + if (!name || !provider) { + res.status(400).json({ error: 'Missing required fields: name, provider' }); + return; + } + + // Reject reserved names as variant names (prevents collision with built-in providers) + if (isReservedName(name)) { + res.status(400).json({ + error: `Cannot use reserved name '${name}' as variant name`, + reserved: RESERVED_PROFILE_NAMES, + }); + return; + } + + const config = readConfigSafe(); + config.cliproxy = config.cliproxy || {}; + + if (config.cliproxy[name]) { + res.status(409).json({ error: 'Variant already exists' }); + return; + } + + // Ensure .ccs directory exists + if (!fs.existsSync(getCcsDir())) { + fs.mkdirSync(getCcsDir(), { recursive: true }); + } + + // Create settings file for variant + const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model); + + // Include account if specified (defaults to 'default' if not provided) + config.cliproxy[name] = { + provider, + settings: settingsPath, + ...(account && { account }), + }; + writeConfig(config); + + res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' }); +}); + +/** + * PUT /api/cliproxy/:name - Update cliproxy variant + */ +router.put('/:name', (req: Request, res: Response): void => { + const { name } = req.params; + const { provider, account, model } = req.body; + + const config = readConfigSafe(); + + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; + } + + const variant = config.cliproxy[name]; + + // Update fields if provided + if (provider) { + variant.provider = provider; + } + if (account !== undefined) { + if (account) { + variant.account = account; + } else { + delete variant.account; // Remove account to use default + } + } + + // Update model in settings file if provided + if (model !== undefined) { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + const settings = loadSettings(settingsPath); + if (model) { + settings.env = settings.env || {}; + settings.env.ANTHROPIC_MODEL = model; + } else if (settings.env) { + delete settings.env.ANTHROPIC_MODEL; + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + } + } + + writeConfig(config); + + res.json({ + name, + provider: variant.provider, + account: variant.account || 'default', + settings: variant.settings, + updated: true, + }); +}); + +/** + * DELETE /api/cliproxy/:name - Delete cliproxy variant + */ +router.delete('/:name', (req: Request, res: Response): void => { + const { name } = req.params; + + const config = readConfigSafe(); + + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; + } + + // Never delete settings files for reserved provider names (safety guard) + if (!isReservedName(name)) { + // Only delete settings file for non-reserved variant names + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + } + + delete config.cliproxy[name]; + writeConfig(config); + + res.json({ name, deleted: true }); +}); + +export default router; diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts new file mode 100644 index 00000000..564ca508 --- /dev/null +++ b/src/web-server/routes/websearch-routes.ts @@ -0,0 +1,155 @@ +/** + * WebSearch Routes - WebSearch configuration and status + */ + +import { Router, Request, Response } from 'express'; +import { + loadUnifiedConfig, + saveUnifiedConfig, + getWebSearchConfig, +} from '../../config/unified-config-loader'; +import type { WebSearchConfig } from '../../config/unified-config-types'; +import { + getWebSearchReadiness, + getGeminiCliStatus, + getGrokCliStatus, + getOpenCodeCliStatus, +} from '../../utils/websearch-manager'; + +const router = Router(); + +/** + * GET /api/websearch - Get WebSearch configuration + * Returns: WebSearchConfig with enabled, provider, fallback + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const config = getWebSearchConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/websearch - Update WebSearch configuration + * Body: WebSearchConfig fields (enabled, providers) + * Dashboard is the source of truth for provider selection. + */ +router.put('/', (req: Request, res: Response): void => { + const { enabled, providers } = req.body as Partial; + + // Validate enabled + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + // Validate providers if specified + if (providers !== undefined && typeof providers !== 'object') { + res.status(400).json({ error: 'Invalid value for providers. Must be an object.' }); + return; + } + + try { + // Load existing config and update websearch section + const existingConfig = loadUnifiedConfig(); + if (!existingConfig) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + // Merge updates - supports Gemini CLI and Grok CLI + existingConfig.websearch = { + enabled: enabled ?? existingConfig.websearch?.enabled ?? true, + providers: providers + ? { + gemini: { + enabled: + providers.gemini?.enabled ?? + existingConfig.websearch?.providers?.gemini?.enabled ?? + true, + model: + providers.gemini?.model ?? + existingConfig.websearch?.providers?.gemini?.model ?? + 'gemini-2.5-flash', + timeout: + providers.gemini?.timeout ?? + existingConfig.websearch?.providers?.gemini?.timeout ?? + 55, + }, + grok: { + enabled: + providers.grok?.enabled ?? + existingConfig.websearch?.providers?.grok?.enabled ?? + false, + timeout: + providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55, + }, + opencode: { + enabled: + providers.opencode?.enabled ?? + existingConfig.websearch?.providers?.opencode?.enabled ?? + false, + model: + providers.opencode?.model ?? + existingConfig.websearch?.providers?.opencode?.model ?? + 'opencode/grok-code', + timeout: + providers.opencode?.timeout ?? + existingConfig.websearch?.providers?.opencode?.timeout ?? + 60, + }, + } + : existingConfig.websearch?.providers, + }; + + saveUnifiedConfig(existingConfig); + + res.json({ + success: true, + websearch: existingConfig.websearch, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/websearch/status - Get WebSearch status + * Returns: { geminiCli, grokCli, opencodeCli, readiness } + */ +router.get('/status', (_req: Request, res: Response): void => { + try { + const geminiCli = getGeminiCliStatus(); + const grokCli = getGrokCliStatus(); + const opencodeCli = getOpenCodeCliStatus(); + const readiness = getWebSearchReadiness(); + + res.json({ + geminiCli: { + installed: geminiCli.installed, + path: geminiCli.path, + version: geminiCli.version, + }, + grokCli: { + installed: grokCli.installed, + path: grokCli.path, + version: grokCli.version, + }, + opencodeCli: { + installed: opencodeCli.installed, + path: opencodeCli.path, + version: opencodeCli.version, + }, + readiness: { + status: readiness.readiness, + message: readiness.message, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/services/index.ts b/src/web-server/services/index.ts new file mode 100644 index 00000000..59c5cbbc --- /dev/null +++ b/src/web-server/services/index.ts @@ -0,0 +1,19 @@ +/** + * Services Barrel Export + * + * Re-exports all service modules for convenient imports. + */ + +export { + getCachedDailyData, + getCachedMonthlyData, + getCachedSessionData, + getCachedHourlyData, + clearUsageCache, + prewarmUsageCache, + getLastFetchTimestamp, + mergeDailyData, + mergeMonthlyData, + mergeHourlyData, + mergeSessionData, +} from './usage-aggregator'; diff --git a/src/web-server/services/usage-aggregator.ts b/src/web-server/services/usage-aggregator.ts new file mode 100644 index 00000000..bda65d69 --- /dev/null +++ b/src/web-server/services/usage-aggregator.ts @@ -0,0 +1,7 @@ +/** + * Usage Aggregator Service - Re-export from modularized location + * + * @deprecated Import from '../usage/aggregator' instead + */ + +export * from '../usage/aggregator'; diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index 0af47521..18d6db23 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -1,155 +1,7 @@ /** - * Persistent Disk Cache for Usage Data + * Usage Disk Cache - Re-export from modularized location * - * Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files - * on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate. - * - * Cache location: ~/.ccs/cache/usage.json - * Default TTL: 5 minutes (configurable) + * @deprecated Import from './usage/disk-cache' instead */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './usage-types'; -import { ok, info, warn } from '../utils/ui'; - -// Cache configuration -const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_DIR = path.join(CCS_DIR, 'cache'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes -const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) - -/** Structure of the disk cache file */ -export interface UsageDiskCache { - version: number; - timestamp: number; - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -} - -// Current cache version - increment to invalidate old caches -// v3: Added hourly data to cache -const CACHE_VERSION = 3; - -/** - * Ensure ~/.ccs/cache directory exists - */ -function ensureCacheDir(): void { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - } -} - -/** - * Read usage data from disk cache - * Returns null if cache is missing, corrupted, or has incompatible version - * NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern - */ -export function readDiskCache(): UsageDiskCache | null { - try { - if (!fs.existsSync(CACHE_FILE)) { - return null; - } - - const data = fs.readFileSync(CACHE_FILE, 'utf-8'); - const cache: UsageDiskCache = JSON.parse(data); - - // Version check - invalidate if schema changed - if (cache.version !== CACHE_VERSION) { - console.log(info('Cache version mismatch, will refresh')); - return null; - } - - // Always return cache regardless of age - SWR pattern handles staleness - return cache; - } catch (err) { - // Cache corrupted or unreadable - treat as miss - console.log(info('Cache read failed, will refresh:') + ` ${(err as Error).message}`); - return null; - } -} - -/** - * Check if disk cache is fresh (within TTL) - */ -export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean { - if (!cache) return false; - const age = Date.now() - cache.timestamp; - return age < CACHE_TTL_MS; -} - -/** - * Check if disk cache is stale but usable (between TTL and STALE_TTL) - */ -export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { - if (!cache) return false; - const age = Date.now() - cache.timestamp; - return age >= CACHE_TTL_MS && age < STALE_TTL_MS; -} - -/** - * Write usage data to disk cache - */ -export function writeDiskCache( - daily: DailyUsage[], - hourly: HourlyUsage[], - monthly: MonthlyUsage[], - session: SessionUsage[] -): void { - try { - ensureCacheDir(); - - const cache: UsageDiskCache = { - version: CACHE_VERSION, - timestamp: Date.now(), - daily, - hourly, - monthly, - session, - }; - - // Write atomically using temp file + rename - const tempFile = CACHE_FILE + '.tmp'; - fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); - fs.renameSync(tempFile, CACHE_FILE); - - console.log(ok('Disk cache updated')); - } catch (err) { - // Non-fatal - we can still serve from memory - console.log(warn('Failed to write disk cache:') + ` ${(err as Error).message}`); - } -} - -/** - * Get cache age in human-readable format - */ -export function getCacheAge(cache: UsageDiskCache | null): string { - if (!cache) return 'never'; - - const age = Date.now() - cache.timestamp; - const seconds = Math.floor(age / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) return `${hours}h ${minutes % 60}m ago`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`; - return `${seconds}s ago`; -} - -/** - * Delete disk cache (for manual refresh) - */ -export function clearDiskCache(): void { - try { - if (fs.existsSync(CACHE_FILE)) { - fs.unlinkSync(CACHE_FILE); - console.log(ok('Disk cache cleared')); - } - } catch (err) { - console.log(warn('Failed to clear disk cache:') + ` ${(err as Error).message}`); - } -} +export * from './usage/disk-cache'; diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 2f255b62..73322caf 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -1,1391 +1,12 @@ /** - * Usage Analytics API Routes + * Usage Analytics API Routes - Re-export from modularized location * - * Provides REST endpoints for Claude Code usage analytics. - * Supports daily, monthly, and session-based usage data aggregation. - * - * Performance optimizations: - * - Persistent disk cache to avoid re-parsing JSONL files on startup - * - TTL-based in-memory caching for fast repeated requests - * - Request coalescing to prevent duplicate concurrent requests - * - Non-blocking prewarm with instant stale data serving + * @deprecated Import from './usage/routes' instead */ -import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - loadDailyUsageData, - loadMonthlyUsageData, - loadSessionData, - loadAllUsageData, - loadHourlyUsageData, -} from './data-aggregator'; -import type { - DailyUsage, - HourlyUsage, - MonthlyUsage, - SessionUsage, - Anomaly, - AnomalySummary, - TokenBreakdown, -} from './usage-types'; -import { getModelPricing } from './model-pricing'; -import { - readDiskCache, - writeDiskCache, - isDiskCacheFresh, - isDiskCacheStale, - clearDiskCache, - getCacheAge, -} from './usage-disk-cache'; -import { ok, info, fail } from '../utils/ui'; - -// ============================================================================ -// Multi-Instance Support - Aggregate usage from CCS profiles -// ============================================================================ - -/** Path to CCS instances directory */ -const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); - -/** - * Get list of CCS instance paths that have usage data - * Only returns instances with existing projects/ directory - */ -function getInstancePaths(): string[] { - if (!fs.existsSync(CCS_INSTANCES_DIR)) { - return []; - } - - try { - const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) - .filter((instancePath) => { - // Only include instances that have a projects directory - const projectsPath = path.join(instancePath, 'projects'); - return fs.existsSync(projectsPath); - }); - } catch { - console.error(fail('Failed to read CCS instances directory')); - return []; - } -} - -/** - * Load usage data from a specific instance - * Uses custom JSONL parser with instance's projects directory - */ -async function loadInstanceData(instancePath: string): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - try { - const projectsDir = path.join(instancePath, 'projects'); - const result = await loadAllUsageData({ projectsDir }); - return result; - } catch (_err) { - // Instance may have no usage data - that's OK - const instanceName = path.basename(instancePath); - console.log(info(`No usage data in instance: ${instanceName}`)); - return { daily: [], hourly: [], monthly: [], session: [] }; - } -} - -/** - * Merge daily usage data from multiple sources - * Combines entries with same date by aggregating tokens - */ -function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { - const dateMap = new Map(); - - for (const source of sources) { - for (const day of source) { - const existing = dateMap.get(day.date); - if (existing) { - // Aggregate tokens for same date - existing.inputTokens += day.inputTokens; - existing.outputTokens += day.outputTokens; - existing.cacheCreationTokens += day.cacheCreationTokens; - existing.cacheReadTokens += day.cacheReadTokens; - existing.totalCost += day.totalCost; - // Merge unique models - const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - // Merge model breakdowns by aggregating same modelName - for (const breakdown of day.modelBreakdowns) { - const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName - ); - if (existingBreakdown) { - existingBreakdown.inputTokens += breakdown.inputTokens; - existingBreakdown.outputTokens += breakdown.outputTokens; - existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; - existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; - existingBreakdown.cost += breakdown.cost; - } else { - existing.modelBreakdowns.push({ ...breakdown }); - } - } - } else { - // Clone to avoid mutating original - dateMap.set(day.date, { - ...day, - modelsUsed: [...day.modelsUsed], - modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), - }); - } - } - } - - return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); -} - -/** - * Merge monthly usage data from multiple sources - */ -function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { - const monthMap = new Map(); - - for (const source of sources) { - for (const month of source) { - const existing = monthMap.get(month.month); - if (existing) { - existing.inputTokens += month.inputTokens; - existing.outputTokens += month.outputTokens; - existing.cacheCreationTokens += month.cacheCreationTokens; - existing.cacheReadTokens += month.cacheReadTokens; - existing.totalCost += month.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - } else { - monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); - } - } - } - - return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); -} - -/** - * Merge hourly usage data from multiple sources - * Combines entries with same hour by aggregating tokens - */ -function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { - const hourMap = new Map(); - - for (const source of sources) { - for (const hour of source) { - const existing = hourMap.get(hour.hour); - if (existing) { - existing.inputTokens += hour.inputTokens; - existing.outputTokens += hour.outputTokens; - existing.cacheCreationTokens += hour.cacheCreationTokens; - existing.cacheReadTokens += hour.cacheReadTokens; - existing.totalCost += hour.totalCost; - const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); - existing.modelsUsed = Array.from(modelSet); - // Merge model breakdowns - for (const breakdown of hour.modelBreakdowns) { - const existingBreakdown = existing.modelBreakdowns.find( - (b) => b.modelName === breakdown.modelName - ); - if (existingBreakdown) { - existingBreakdown.inputTokens += breakdown.inputTokens; - existingBreakdown.outputTokens += breakdown.outputTokens; - existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; - existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; - existingBreakdown.cost += breakdown.cost; - } else { - existing.modelBreakdowns.push({ ...breakdown }); - } - } - } else { - hourMap.set(hour.hour, { - ...hour, - modelsUsed: [...hour.modelsUsed], - modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), - }); - } - } - } - - return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); -} - -/** - * Merge session data from multiple sources - * Deduplicates by sessionId (same session shouldn't appear in multiple instances) - */ -function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] { - const sessionMap = new Map(); - - for (const source of sources) { - for (const session of source) { - // Use sessionId as unique key - later entries overwrite earlier ones - sessionMap.set(session.sessionId, session); - } - } - - return Array.from(sessionMap.values()).sort( - (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() - ); -} - -export const usageRoutes = Router(); - -/** Query parameters for usage endpoints */ -interface UsageQuery { - since?: string; // YYYYMMDD format - until?: string; // YYYYMMDD format - limit?: string; - offset?: string; -} - -// Constants for validation -const MAX_LIMIT = 1000; -const DEFAULT_LIMIT = 50; -const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format - -// ============================================================================ -// Caching Layer - Reduces better-ccusage library calls -// ============================================================================ - -interface CacheEntry { - data: T; - timestamp: number; -} - -// Cache TTLs (milliseconds) -const CACHE_TTL = { - daily: 60 * 1000, // 1 minute - changes frequently - monthly: 5 * 60 * 1000, // 5 minutes - aggregated data - session: 60 * 1000, // 1 minute - user may refresh -}; - -/// Stale-while-revalidate: max age for stale data (7 days) -// We always show cached data to avoid blocking UI, refresh happens in background -const STALE_TTL = 7 * 24 * 60 * 60 * 1000; - -// Track when data was last fetched (for UI indicator) -let lastFetchTimestamp: number | null = null; - -/** Get timestamp of last successful data fetch */ -export function getLastFetchTimestamp(): number | null { - return lastFetchTimestamp; -} - -// In-memory cache -const cache = new Map>(); - -// Pending requests for coalescing (prevents duplicate concurrent calls) -const pendingRequests = new Map>(); - -// Track if disk cache has been loaded into memory -let diskCacheInitialized = false; - -/** - * Persist cache to disk when we have enough data to be useful. - * Writes immediately with whatever data is available (empty arrays for missing). - * This ensures disk cache is created after first Analytics page visit. - */ -function persistCacheIfComplete(): void { - const daily = cache.get('daily') as CacheEntry | undefined; - const hourly = cache.get('hourly') as CacheEntry | undefined; - const monthly = cache.get('monthly') as CacheEntry | undefined; - const session = cache.get('session') as CacheEntry | undefined; - - // Write if we have at least daily data (the most essential) - if (daily) { - writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); - } -} - -/** - * Get cached data or fetch from loader with TTL - * Also coalesces concurrent requests to prevent duplicate library calls - * Implements stale-while-revalidate pattern for instant responses - */ -async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { - // Ensure disk cache is loaded on first request - ensureDiskCacheLoaded(); - - const cached = cache.get(key) as CacheEntry | undefined; - const now = Date.now(); - - // Fresh cache - return immediately - if (cached && now - cached.timestamp < ttl) { - return cached.data; - } - - // Stale cache - return immediately, refresh in background (SWR pattern) - if (cached && now - cached.timestamp < STALE_TTL) { - // Fire and forget background refresh if not already pending - if (!pendingRequests.has(key)) { - const promise = loader() - .then((data) => { - cache.set(key, { data, timestamp: Date.now() }); - lastFetchTimestamp = Date.now(); - // Persist to disk if all data types are cached - persistCacheIfComplete(); - }) - .catch((err) => { - console.error(fail(`Background refresh failed for ${key}: ${err}`)); - }) - .finally(() => { - pendingRequests.delete(key); - }); - pendingRequests.set(key, promise); - } - return cached.data; - } - - // No usable cache - check if request is already pending (coalesce) - const pending = pendingRequests.get(key) as Promise | undefined; - if (pending) { - return pending; - } - - // Create new request - const promise = loader() - .then((data) => { - cache.set(key, { data, timestamp: Date.now() }); - lastFetchTimestamp = Date.now(); - // Persist to disk if all data types are cached - persistCacheIfComplete(); - return data; - }) - .finally(() => { - pendingRequests.delete(key); - }); - - pendingRequests.set(key, promise); - return promise; -} - -/** Cached loader for daily usage data */ -async function getCachedDailyData(): Promise { - return getCachedData('daily', CACHE_TTL.daily, async () => { - return await loadDailyUsageData(); - }); -} - -/** Cached loader for monthly usage data */ -async function getCachedMonthlyData(): Promise { - return getCachedData('monthly', CACHE_TTL.monthly, async () => { - return await loadMonthlyUsageData(); - }); -} - -/** Cached loader for session data */ -async function getCachedSessionData(): Promise { - return getCachedData('session', CACHE_TTL.session, async () => { - return await loadSessionData(); - }); -} - -/** Cached loader for hourly usage data */ -async function getCachedHourlyData(): Promise { - return getCachedData('hourly', CACHE_TTL.daily, async () => { - return await loadHourlyUsageData(); - }); -} - -/** - * Clear all cached data (useful for manual refresh) - */ -export function clearUsageCache(): void { - cache.clear(); - clearDiskCache(); - // Reset so next API call will try to reload from disk/source - diskCacheInitialized = false; -} - -// Track if background refresh is in progress -let isRefreshing = false; - -/** - * Load fresh data and update both memory and disk caches - * Aggregates data from default ~/.claude/ AND all CCS instances - */ -async function refreshFromSource(): Promise<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; -}> { - // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) - const defaultData = await loadAllUsageData(); - - // Load data from all CCS instances sequentially (to avoid env var race condition) - const instancePaths = getInstancePaths(); - const instanceDataResults: Array<{ - daily: DailyUsage[]; - hourly: HourlyUsage[]; - monthly: MonthlyUsage[]; - session: SessionUsage[]; - }> = []; - - for (const instancePath of instancePaths) { - try { - const data = await loadInstanceData(instancePath); - instanceDataResults.push(data); - } catch (err) { - const instanceName = path.basename(instancePath); - console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); - } - } - - // Collect successful instance data - const allDailySources: DailyUsage[][] = [defaultData.daily]; - const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; - const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; - const allSessionSources: SessionUsage[][] = [defaultData.session]; - - for (const result of instanceDataResults) { - allDailySources.push(result.daily); - allHourlySources.push(result.hourly); - allMonthlySources.push(result.monthly); - allSessionSources.push(result.session); - } - - if (instanceDataResults.length > 0) { - console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); - } - - // Merge all data sources - const daily = mergeDailyData(allDailySources); - const hourly = mergeHourlyData(allHourlySources); - const monthly = mergeMonthlyData(allMonthlySources); - const session = mergeSessionData(allSessionSources); - - // Update in-memory cache - const now = Date.now(); - cache.set('daily', { data: daily, timestamp: now }); - cache.set('hourly', { data: hourly, timestamp: now }); - cache.set('monthly', { data: monthly, timestamp: now }); - cache.set('session', { data: session, timestamp: now }); - lastFetchTimestamp = now; - - // Persist to disk - writeDiskCache(daily, hourly, monthly, session); - - return { daily, hourly, monthly, session }; -} - -// ============================================================================ -// Module Initialization - Load disk cache immediately for instant API responses -// ============================================================================ - -/** - * Initialize in-memory cache from disk cache (lazy - called on first API request). - * This ensures first API request gets instant data without calling better-ccusage. - * Background refresh is NOT triggered here - it happens via SWR pattern in getCachedData(). - */ -function ensureDiskCacheLoaded(): void { - if (diskCacheInitialized) return; - diskCacheInitialized = true; - - const diskCache = readDiskCache(); - if (!diskCache) return; - - // Load disk cache into memory (regardless of freshness) - // SWR pattern in getCachedData() will handle background refresh - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; -} - -/** - * Pre-warm usage caches on server startup - * - * Strategy: - * 1. Check disk cache - if fresh, use it (instant startup) - * 2. If stale, use it immediately but trigger background refresh - * 3. If no cache, return immediately and let first request trigger load - * - * This ensures dashboard opens in <1s regardless of cache state - */ -export async function prewarmUsageCache(): Promise<{ - timestamp: number; - elapsed: number; - source: string; -}> { - const start = Date.now(); - console.log(info('Pre-warming usage cache...')); - - try { - const diskCache = readDiskCache(); - - // Fresh disk cache - use it directly - if (diskCache && isDiskCacheFresh(diskCache)) { - const now = Date.now(); - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; - - const elapsed = Date.now() - start; - console.log( - ok(`Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`) - ); - return { timestamp: now, elapsed, source: 'disk-fresh' }; - } - - // Stale disk cache - use it immediately, refresh in background - if (diskCache && isDiskCacheStale(diskCache)) { - const now = Date.now(); - cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); - cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); - cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); - cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); - lastFetchTimestamp = diskCache.timestamp; - - const elapsed = Date.now() - start; - console.log( - ok( - `Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` - ) - ); - - // Background refresh - if (!isRefreshing) { - isRefreshing = true; - refreshFromSource() - .then(() => console.log(ok('Background refresh complete'))) - .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) - .finally(() => { - isRefreshing = false; - }); - } - - return { timestamp: now, elapsed, source: 'disk-stale' }; - } - - // No usable disk cache - refresh from source (blocking for first startup only) - console.log(info('No disk cache, loading from source...')); - await refreshFromSource(); - - const elapsed = Date.now() - start; - console.log(ok(`Usage cache ready (${elapsed}ms)`)); - return { timestamp: Date.now(), elapsed, source: 'fresh' }; - } catch (err) { - console.error(fail(`Failed to prewarm usage cache: ${err}`)); - throw err; - } -} - -// ============================================================================ -// Validation Helpers -// ============================================================================ - -/** - * Validate date string in YYYYMMDD format - */ -function validateDate(dateString?: string): string | undefined { - if (!dateString) return undefined; - - if (!DATE_REGEX.test(dateString)) { - throw new Error('Invalid date format. Use YYYYMMDD'); - } - - // Basic range check - const year = parseInt(dateString.substring(0, 4), 10); - const month = parseInt(dateString.substring(4, 6), 10); - const day = parseInt(dateString.substring(6, 8), 10); - - if (year < 2024 || year > 2100) throw new Error('Year out of valid range'); - if (month < 1 || month > 12) throw new Error('Month out of valid range'); - if (day < 1 || day > 31) throw new Error('Day out of valid range'); - - return dateString; -} - -/** - * Validate and parse limit parameter - */ -function validateLimit(limit?: string): number { - if (!limit) return DEFAULT_LIMIT; - - const num = parseInt(limit, 10); - if (isNaN(num) || num < 1 || num > MAX_LIMIT) { - throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`); - } - - return num; -} - -/** - * Validate and parse offset parameter - */ -function validateOffset(offset?: string): number { - if (!offset) return 0; - - const num = parseInt(offset, 10); - if (isNaN(num) || num < 0) { - throw new Error('Offset must be a non-negative number'); - } - - return num; -} - -/** - * Filter data by date range - */ -function filterByDateRange( - data: T[] | undefined, - since?: string, - until?: string -): T[] { - if (!data || !Array.isArray(data)) return []; - if (!since && !until) return data; - - return data.filter((item) => { - // Get the date field (prioritize date, then month, then lastActivity) - const itemDate = - item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, ''); - if (!itemDate) return true; - - // Normalize to YYYYMMDD for comparison - const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8); - - if (since && normalizedDate < since) return false; - if (until && normalizedDate > until) return false; - - return true; - }); -} - -/** - * Create standard error response - */ -function errorResponse(res: Response, error: unknown, defaultMessage: string): void { - console.error(defaultMessage + ':', error); - - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const isValidationError = - errorMessage.includes('Invalid') || - errorMessage.includes('format') || - errorMessage.includes('range') || - errorMessage.includes('must be'); - - const statusCode = isValidationError ? 400 : 500; - - res.status(statusCode).json({ - success: false, - error: isValidationError ? errorMessage : defaultMessage, - }); -} - -/** - * Calculate cost breakdown for token categories - * Uses weighted average pricing across models in the dataset - */ -function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let inputCost = 0; - let outputCost = 0; - let cacheCreationCost = 0; - let cacheReadCost = 0; - - for (const day of dailyData) { - for (const breakdown of day.modelBreakdowns) { - const pricing = getModelPricing(breakdown.modelName); - - inputTokens += breakdown.inputTokens; - outputTokens += breakdown.outputTokens; - cacheCreationTokens += breakdown.cacheCreationTokens; - cacheReadTokens += breakdown.cacheReadTokens; - - inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion; - outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion; - cacheCreationCost += - (breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; - cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; - } - } - - return { - input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, - cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, - }; -} - -/** - * GET /api/usage/summary - * - * Returns usage summary data for quick dashboard display. - * Query: ?since=YYYYMMDD&until=YYYYMMDD - */ -usageRoutes.get( - '/summary', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - // Calculate totals - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheCreationTokens = 0; - let totalCacheReadTokens = 0; - let totalCost = 0; - - for (const day of filtered) { - totalInputTokens += day.inputTokens; - totalOutputTokens += day.outputTokens; - totalCacheCreationTokens += day.cacheCreationTokens; - totalCacheReadTokens += day.cacheReadTokens; - totalCost += day.totalCost; - } - - const totalTokens = totalInputTokens + totalOutputTokens; - const totalCacheTokens = totalCacheCreationTokens + totalCacheReadTokens; - - // Calculate detailed token breakdown with costs - const tokenBreakdown = calculateTokenBreakdownCosts(filtered); - - res.json({ - success: true, - data: { - totalTokens, - totalInputTokens, - totalOutputTokens, - totalCacheTokens, - totalCacheCreationTokens, - totalCacheReadTokens, - totalCost: Math.round(totalCost * 100) / 100, - tokenBreakdown, - totalDays: filtered.length, - averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, - averageCostPerDay: - filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, - }, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch usage summary'); - } - } -); - -/** - * GET /api/usage/daily - * - * Returns daily usage trends for chart visualization. - * Query: ?since=YYYYMMDD&until=YYYYMMDD - */ -usageRoutes.get( - '/daily', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - // Transform for chart consumption - const trends = filtered.map((day) => ({ - date: day.date, - tokens: day.inputTokens + day.outputTokens, - inputTokens: day.inputTokens, - outputTokens: day.outputTokens, - cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, - cost: Math.round(day.totalCost * 100) / 100, - modelsUsed: day.modelsUsed.length, - })); - - res.json({ - success: true, - data: trends, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch daily usage'); - } - } -); - -/** - * GET /api/usage/hourly - * - * Returns hourly usage trends for chart visualization. - * Query: ?since=YYYYMMDD&until=YYYYMMDD (defaults to last 24 hours) - * Fills in gaps with zero values for hours without activity. - */ -usageRoutes.get( - '/hourly', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const hourlyData = await getCachedHourlyData(); - - // Filter by date range (guard against undefined) - const filtered = (hourlyData || []).filter((h) => { - // Extract date from hour format "YYYY-MM-DD HH:00" - const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); - if (since && hourDate < since) return false; - if (until && hourDate > until) return false; - return true; - }); - - // Transform for chart consumption - const trends = filtered.map((hour) => ({ - hour: hour.hour, - tokens: hour.inputTokens + hour.outputTokens, - inputTokens: hour.inputTokens, - outputTokens: hour.outputTokens, - cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, - cost: Math.round(hour.totalCost * 100) / 100, - modelsUsed: hour.modelsUsed.length, - requests: hour.modelBreakdowns.length, - })); - - // Fill gaps with zero values for hours without activity - const filledTrends = fillHourlyGaps(trends, since, until); - - res.json({ - success: true, - data: filledTrends, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch hourly usage'); - } - } -); - -/** - * Fill gaps in hourly data with zero values - * Ensures continuous timeline for chart display - */ -function fillHourlyGaps( - data: Array<{ - hour: string; - tokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - cost: number; - modelsUsed: number; - requests: number; - }>, - since?: string, - until?: string -): typeof data { - // If no date range specified, return as-is - if (!since && !until) { - return data.sort((a, b) => a.hour.localeCompare(b.hour)); - } - - // Create a map of existing hours for O(1) lookup - const hourMap = new Map(data.map((d) => [d.hour, d])); - - // Determine the hour range (use UTC to match stored hour keys) - const now = new Date(); - const startDate = since - ? new Date( - Date.UTC( - parseInt(since.slice(0, 4)), - parseInt(since.slice(4, 6)) - 1, - parseInt(since.slice(6, 8)), - 0, - 0, - 0 - ) - ) - : new Date(now.getTime() - 24 * 60 * 60 * 1000); // Default: 24 hours ago - - const endDate = until - ? new Date( - Date.UTC( - parseInt(until.slice(0, 4)), - parseInt(until.slice(4, 6)) - 1, - parseInt(until.slice(6, 8)), - 23, - 59, - 59 - ) - ) - : now; - - // Cap endDate at current time to avoid filling future hours with zeros - const cappedEndDate = endDate > now ? now : endDate; - - const result: typeof data = []; - - // Iterate through each hour in the range - const current = new Date(startDate); - current.setMinutes(0, 0, 0); - - while (current <= cappedEndDate) { - // Format hour key as "YYYY-MM-DD HH:00" in UTC to match storage format - const year = current.getUTCFullYear(); - const month = String(current.getUTCMonth() + 1).padStart(2, '0'); - const day = String(current.getUTCDate()).padStart(2, '0'); - const hour = String(current.getUTCHours()).padStart(2, '0'); - const hourKey = `${year}-${month}-${day} ${hour}:00`; - - if (hourMap.has(hourKey)) { - const entry = hourMap.get(hourKey); - if (entry) { - result.push(entry); - } - } else { - // Insert zero entry for this hour - result.push({ - hour: hourKey, - tokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - cost: 0, - modelsUsed: 0, - requests: 0, - }); - } - - // Move to next hour - current.setTime(current.getTime() + 60 * 60 * 1000); - } - - return result; -} - -/** - * GET /api/usage/models - * - * Returns usage breakdown by model for pie/bar charts. - * Query: ?since=YYYYMMDD&until=YYYYMMDD - */ -usageRoutes.get( - '/models', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - // Aggregate model usage across all days with detailed breakdown - const modelMap = new Map< - string, - { - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - } - >(); - - for (const day of filtered) { - for (const breakdown of day.modelBreakdowns) { - const existing = modelMap.get(breakdown.modelName) || { - model: breakdown.modelName, - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0, - }; - - existing.inputTokens += breakdown.inputTokens; - existing.outputTokens += breakdown.outputTokens; - existing.cacheCreationTokens += breakdown.cacheCreationTokens; - existing.cacheReadTokens += breakdown.cacheReadTokens; - existing.cost += breakdown.cost; - - modelMap.set(breakdown.modelName, existing); - } - } - - // Calculate totals for percentage - const models = Array.from(modelMap.values()); - const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); - - // Add percentage, cost breakdown, and I/O ratio - const result = models - .map((m) => { - const pricing = getModelPricing(m.model); - - // Calculate cost breakdown - const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; - const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; - const cacheCreationCost = - (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; - const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; - - // Calculate I/O ratio - const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; - - return { - model: m.model, - tokens: m.inputTokens + m.outputTokens, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - cacheCreationTokens: m.cacheCreationTokens, - cacheReadTokens: m.cacheReadTokens, - cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.cost * 100) / 100, - percentage: - totalTokens > 0 - ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 - : 0, - costBreakdown: { - input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { - tokens: m.cacheCreationTokens, - cost: Math.round(cacheCreationCost * 100) / 100, - }, - cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, - }, - ioRatio: Math.round(ioRatio * 10) / 10, - }; - }) - .sort((a, b) => b.tokens - a.tokens); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch model usage'); - } - } -); - -/** - * GET /api/usage/sessions - * - * Returns paginated list of sessions. - * Query: ?since=YYYYMMDD&until=YYYYMMDD&limit=50&offset=0 - */ -usageRoutes.get( - '/sessions', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - const limit = validateLimit(req.query.limit); - const offset = validateOffset(req.query.offset); - - const sessionData = await getCachedSessionData(); - - // Filter by date range using lastActivity - const filtered = filterByDateRange(sessionData, since, until); - - // Sort by lastActivity descending - const sorted = [...filtered].sort( - (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() - ); - - // Paginate - const paginated = sorted.slice(offset, offset + limit); - - // Transform for frontend - const sessions = paginated.map((s) => ({ - sessionId: s.sessionId, - projectPath: s.projectPath, - tokens: s.inputTokens + s.outputTokens, - inputTokens: s.inputTokens, - outputTokens: s.outputTokens, - cost: Math.round(s.totalCost * 100) / 100, - lastActivity: s.lastActivity, - modelsUsed: s.modelsUsed, - })); - - res.json({ - success: true, - data: { - sessions, - total: filtered.length, - limit, - offset, - hasMore: offset + limit < filtered.length, - }, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch sessions'); - } - } -); - -/** - * GET /api/usage/monthly - * - * Returns monthly usage summary for charts. - * Query: ?since=YYYYMMDD&until=YYYYMMDD - */ -usageRoutes.get( - '/monthly', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const monthlyData = await getCachedMonthlyData(); - - // Filter by date range (convert month YYYY-MM to YYYYMM01 for comparison) - const filtered = - since || until - ? monthlyData.filter((m) => { - const monthDate = m.month.replace('-', '') + '01'; - if (since && monthDate < since) return false; - if (until && monthDate > until) return false; - return true; - }) - : monthlyData; - - // Transform for charts - const result = filtered.map((m) => ({ - month: m.month, - tokens: m.inputTokens + m.outputTokens, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.totalCost * 100) / 100, - modelsUsed: m.modelsUsed.length, - })); - - res.json({ - success: true, - data: result.sort((a, b) => a.month.localeCompare(b.month)), - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch monthly usage'); - } - } -); - -/** - * POST /api/usage/refresh - * - * Clears the usage cache to force fresh data fetch. - * Useful when user wants to see latest data immediately. - */ -usageRoutes.post('/refresh', (_req: Request, res: Response) => { - clearUsageCache(); - res.json({ - success: true, - message: 'Usage cache cleared', - }); -}); - -/** - * GET /api/usage/status - * - * Returns cache status including last fetch timestamp. - * Used by UI to show "Last updated: X ago" indicator. - */ -usageRoutes.get('/status', (_req: Request, res: Response) => { - res.json({ - success: true, - data: { - lastFetch: lastFetchTimestamp, - cacheSize: cache.size, - }, - }); -}); - -// ============================================================================ -// ANOMALY DETECTION -// ============================================================================ - -/** Anomaly detection thresholds */ -const ANOMALY_THRESHOLDS = { - HIGH_INPUT_TOKENS: 10_000_000, // 10M tokens/day/model - HIGH_IO_RATIO: 100, // 100x input/output ratio - COST_SPIKE_MULTIPLIER: 2, // 2x average daily cost - HIGH_CACHE_READ_TOKENS: 1_000_000_000, // 1B cache read tokens -}; - -/** - * Detect anomalies in usage data - */ -function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] { - const anomalies: Anomaly[] = []; - - // Calculate average daily cost for spike detection - const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0); - const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0; - const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER; - - for (const day of dailyData) { - // Check for cost spikes - if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) { - const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'cost_spike', - value: day.totalCost, - threshold: avgDailyCost, - message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`, - }); - } - - // Check per-model anomalies - for (const breakdown of day.modelBreakdowns) { - // High input tokens per model - if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) { - const multiplier = - Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'high_input', - model: breakdown.modelName, - value: breakdown.inputTokens, - threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS, - message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`, - }); - } - - // High I/O ratio - if (breakdown.outputTokens > 0) { - const ioRatio = breakdown.inputTokens / breakdown.outputTokens; - if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) { - const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10; - anomalies.push({ - date: day.date, - type: 'high_io_ratio', - model: breakdown.modelName, - value: ioRatio, - threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO, - message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`, - }); - } - } - - // High cache read tokens - if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) { - const multiplier = - Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) / - 10; - anomalies.push({ - date: day.date, - type: 'high_cache_read', - model: breakdown.modelName, - value: breakdown.cacheReadTokens, - threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS, - message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`, - }); - } - } - } - - // Sort by date descending - return anomalies.sort((a, b) => b.date.localeCompare(a.date)); -} - -/** - * Format token count for human readability - */ -function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000_000) { - return `${(tokens / 1_000_000_000).toFixed(1)}B`; - } else if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } else if (tokens >= 1_000) { - return `${(tokens / 1_000).toFixed(1)}K`; - } - return tokens.toString(); -} - -/** - * Summarize anomalies by type - */ -function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary { - const uniqueDates = new Set(); - let highInputDays = 0; - let highIoRatioDays = 0; - let costSpikeDays = 0; - let highCacheReadDays = 0; - - // Track unique dates per anomaly type - const highInputDates = new Set(); - const highIoRatioDates = new Set(); - const costSpikeDates = new Set(); - const highCacheReadDates = new Set(); - - for (const anomaly of anomalies) { - uniqueDates.add(anomaly.date); - - switch (anomaly.type) { - case 'high_input': - highInputDates.add(anomaly.date); - break; - case 'high_io_ratio': - highIoRatioDates.add(anomaly.date); - break; - case 'cost_spike': - costSpikeDates.add(anomaly.date); - break; - case 'high_cache_read': - highCacheReadDates.add(anomaly.date); - break; - } - } - - highInputDays = highInputDates.size; - highIoRatioDays = highIoRatioDates.size; - costSpikeDays = costSpikeDates.size; - highCacheReadDays = highCacheReadDates.size; - - return { - totalAnomalies: anomalies.length, - highInputDays, - highIoRatioDays, - costSpikeDays, - highCacheReadDays, - }; -} - -/** - * GET /api/usage/insights - * - * Returns anomaly detection results for usage patterns. - * Query: ?since=YYYYMMDD&until=YYYYMMDD - */ -usageRoutes.get( - '/insights', - async (req: Request, res: Response) => { - try { - const since = validateDate(req.query.since); - const until = validateDate(req.query.until); - - const dailyData = await getCachedDailyData(); - const filtered = filterByDateRange(dailyData, since, until); - - const anomalies = detectAnomalies(filtered); - const summary = summarizeAnomalies(anomalies); - - res.json({ - success: true, - data: { - anomalies, - summary, - }, - }); - } catch (error) { - errorResponse(res, error, 'Failed to fetch usage insights'); - } - } -); +export { + usageRoutes, + prewarmUsageCache, + clearUsageCache, + getLastFetchTimestamp, +} from './usage/routes'; diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts index 820bfc53..0430433a 100644 --- a/src/web-server/usage-types.ts +++ b/src/web-server/usage-types.ts @@ -1,146 +1,7 @@ /** - * Usage Data Types + * Usage Types - Re-export from modularized location * - * Type definitions for aggregated usage data. - * Compatible with better-ccusage interfaces for drop-in replacement. + * @deprecated Import from './usage/types' instead */ -// ============================================================================ -// MODEL BREAKDOWN -// ============================================================================ - -/** Per-model token and cost breakdown */ -export interface ModelBreakdown { - modelName: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; -} - -// ============================================================================ -// AGGREGATED USAGE TYPES -// ============================================================================ - -/** Daily usage aggregation (YYYY-MM-DD) */ -export interface DailyUsage { - date: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ -export interface HourlyUsage { - hour: string; // Format: "YYYY-MM-DD HH:00" - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Monthly usage aggregation (YYYY-MM) */ -export interface MonthlyUsage { - month: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; -} - -/** Session-level usage aggregation */ -export interface SessionUsage { - sessionId: string; - projectPath: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - lastActivity: string; - versions: string[]; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; - source: string; -} - -// ============================================================================ -// ANALYTICS INSIGHTS TYPES -// ============================================================================ - -/** Token category with count and cost */ -export interface TokenCategoryCost { - tokens: number; - cost: number; -} - -/** Breakdown of tokens by type with individual costs */ -export interface TokenBreakdown { - input: TokenCategoryCost; - output: TokenCategoryCost; - cacheCreation: TokenCategoryCost; - cacheRead: TokenCategoryCost; -} - -/** Anomaly types for usage pattern detection */ -export type AnomalyType = - | 'high_input' // >10M tokens/day/model - | 'high_io_ratio' // >100x input/output ratio - | 'cost_spike' // >2x daily average cost - | 'high_cache_read'; // >1B cache read tokens - -/** Single anomaly detection result */ -export interface Anomaly { - date: string; - type: AnomalyType; - model?: string; - value: number; - threshold: number; - message: string; -} - -/** Summary of all detected anomalies */ -export interface AnomalySummary { - totalAnomalies: number; - highInputDays: number; - highIoRatioDays: number; - costSpikeDays: number; - highCacheReadDays: number; -} - -/** Insights API response */ -export interface UsageInsights { - anomalies: Anomaly[]; - summary: AnomalySummary; -} - -/** Extended model usage with cost breakdown */ -export interface ExtendedModelUsage { - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - tokens: number; - cost: number; - percentage: number; - costBreakdown: TokenBreakdown; - ioRatio: number; -} +export * from './usage/types'; diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts new file mode 100644 index 00000000..ce59ba07 --- /dev/null +++ b/src/web-server/usage/aggregator.ts @@ -0,0 +1,538 @@ +/** + * Usage Aggregator Service + * + * Handles multi-instance usage data aggregation and caching. + * Combines data from default Claude config and all CCS instances. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + loadDailyUsageData, + loadMonthlyUsageData, + loadSessionData, + loadAllUsageData, + loadHourlyUsageData, +} from './data-aggregator'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; +import { + readDiskCache, + writeDiskCache, + isDiskCacheFresh, + isDiskCacheStale, + clearDiskCache, + getCacheAge, +} from './disk-cache'; +import { ok, info, fail } from '../../utils/ui'; + +// ============================================================================ +// Multi-Instance Support - Aggregate usage from CCS profiles +// ============================================================================ + +/** Path to CCS instances directory */ +const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); + +/** + * Get list of CCS instance paths that have usage data + * Only returns instances with existing projects/ directory + */ +function getInstancePaths(): string[] { + if (!fs.existsSync(CCS_INSTANCES_DIR)) { + return []; + } + + try { + const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) + .filter((instancePath) => { + // Only include instances that have a projects directory + const projectsPath = path.join(instancePath, 'projects'); + return fs.existsSync(projectsPath); + }); + } catch { + console.error(fail('Failed to read CCS instances directory')); + return []; + } +} + +/** + * Load usage data from a specific instance + * Uses custom JSONL parser with instance's projects directory + */ +async function loadInstanceData(instancePath: string): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + try { + const projectsDir = path.join(instancePath, 'projects'); + const result = await loadAllUsageData({ projectsDir }); + return result; + } catch (_err) { + // Instance may have no usage data - that's OK + const instanceName = path.basename(instancePath); + console.log(info(`No usage data in instance: ${instanceName}`)); + return { daily: [], hourly: [], monthly: [], session: [] }; + } +} + +/** + * Merge daily usage data from multiple sources + * Combines entries with same date by aggregating tokens + */ +export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] { + const dateMap = new Map(); + + for (const source of sources) { + for (const day of source) { + const existing = dateMap.get(day.date); + if (existing) { + // Aggregate tokens for same date + existing.inputTokens += day.inputTokens; + existing.outputTokens += day.outputTokens; + existing.cacheCreationTokens += day.cacheCreationTokens; + existing.cacheReadTokens += day.cacheReadTokens; + existing.totalCost += day.totalCost; + // Merge unique models + const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns by aggregating same modelName + for (const breakdown of day.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + // Clone to avoid mutating original + dateMap.set(day.date, { + ...day, + modelsUsed: [...day.modelsUsed], + modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Merge monthly usage data from multiple sources + */ +export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { + const monthMap = new Map(); + + for (const source of sources) { + for (const month of source) { + const existing = monthMap.get(month.month); + if (existing) { + existing.inputTokens += month.inputTokens; + existing.outputTokens += month.outputTokens; + existing.cacheCreationTokens += month.cacheCreationTokens; + existing.cacheReadTokens += month.cacheReadTokens; + existing.totalCost += month.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + } else { + monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); + } + } + } + + return Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); +} + +/** + * Merge hourly usage data from multiple sources + * Combines entries with same hour by aggregating tokens + */ +export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { + const hourMap = new Map(); + + for (const source of sources) { + for (const hour of source) { + const existing = hourMap.get(hour.hour); + if (existing) { + existing.inputTokens += hour.inputTokens; + existing.outputTokens += hour.outputTokens; + existing.cacheCreationTokens += hour.cacheCreationTokens; + existing.cacheReadTokens += hour.cacheReadTokens; + existing.totalCost += hour.totalCost; + const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); + existing.modelsUsed = Array.from(modelSet); + // Merge model breakdowns + for (const breakdown of hour.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (b) => b.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } + } else { + hourMap.set(hour.hour, { + ...hour, + modelsUsed: [...hour.modelsUsed], + modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + }); + } + } + } + + return Array.from(hourMap.values()).sort((a, b) => a.hour.localeCompare(b.hour)); +} + +/** + * Merge session data from multiple sources + * Deduplicates by sessionId (same session shouldn't appear in multiple instances) + */ +export function mergeSessionData(sources: SessionUsage[][]): SessionUsage[] { + const sessionMap = new Map(); + + for (const source of sources) { + for (const session of source) { + // Use sessionId as unique key - later entries overwrite earlier ones + sessionMap.set(session.sessionId, session); + } + } + + return Array.from(sessionMap.values()).sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); +} + +// ============================================================================ +// Caching Layer - Reduces better-ccusage library calls +// ============================================================================ + +interface CacheEntry { + data: T; + timestamp: number; +} + +// Cache TTLs (milliseconds) +const CACHE_TTL = { + daily: 60 * 1000, // 1 minute - changes frequently + monthly: 5 * 60 * 1000, // 5 minutes - aggregated data + session: 60 * 1000, // 1 minute - user may refresh +}; + +/// Stale-while-revalidate: max age for stale data (7 days) +// We always show cached data to avoid blocking UI, refresh happens in background +const STALE_TTL = 7 * 24 * 60 * 60 * 1000; + +// Track when data was last fetched (for UI indicator) +let lastFetchTimestamp: number | null = null; + +/** Get timestamp of last successful data fetch */ +export function getLastFetchTimestamp(): number | null { + return lastFetchTimestamp; +} + +// In-memory cache +const cache = new Map>(); + +// Pending requests for coalescing (prevents duplicate concurrent calls) +const pendingRequests = new Map>(); + +// Track if disk cache has been loaded into memory +let diskCacheInitialized = false; + +// Track if background refresh is in progress +let isRefreshing = false; + +/** + * Persist cache to disk when we have enough data to be useful. + */ +function persistCacheIfComplete(): void { + const daily = cache.get('daily') as CacheEntry | undefined; + const hourly = cache.get('hourly') as CacheEntry | undefined; + const monthly = cache.get('monthly') as CacheEntry | undefined; + const session = cache.get('session') as CacheEntry | undefined; + + // Write if we have at least daily data (the most essential) + if (daily) { + writeDiskCache(daily.data, hourly?.data ?? [], monthly?.data ?? [], session?.data ?? []); + } +} + +/** + * Load fresh data and update both memory and disk caches + * Aggregates data from default ~/.claude/ AND all CCS instances + */ +async function refreshFromSource(): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) + const defaultData = await loadAllUsageData(); + + // Load data from all CCS instances sequentially + const instancePaths = getInstancePaths(); + const instanceDataResults: Array<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; + }> = []; + + for (const instancePath of instancePaths) { + try { + const data = await loadInstanceData(instancePath); + instanceDataResults.push(data); + } catch (err) { + const instanceName = path.basename(instancePath); + console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); + } + } + + // Collect successful instance data + const allDailySources: DailyUsage[][] = [defaultData.daily]; + const allHourlySources: HourlyUsage[][] = [defaultData.hourly]; + const allMonthlySources: MonthlyUsage[][] = [defaultData.monthly]; + const allSessionSources: SessionUsage[][] = [defaultData.session]; + + for (const result of instanceDataResults) { + allDailySources.push(result.daily); + allHourlySources.push(result.hourly); + allMonthlySources.push(result.monthly); + allSessionSources.push(result.session); + } + + if (instanceDataResults.length > 0) { + console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); + } + + // Merge all data sources + const daily = mergeDailyData(allDailySources); + const hourly = mergeHourlyData(allHourlySources); + const monthly = mergeMonthlyData(allMonthlySources); + const session = mergeSessionData(allSessionSources); + + // Update in-memory cache + const now = Date.now(); + cache.set('daily', { data: daily, timestamp: now }); + cache.set('hourly', { data: hourly, timestamp: now }); + cache.set('monthly', { data: monthly, timestamp: now }); + cache.set('session', { data: session, timestamp: now }); + lastFetchTimestamp = now; + + // Persist to disk + writeDiskCache(daily, hourly, monthly, session); + + return { daily, hourly, monthly, session }; +} + +/** + * Initialize in-memory cache from disk cache (lazy - called on first API request). + */ +function ensureDiskCacheLoaded(): void { + if (diskCacheInitialized) return; + diskCacheInitialized = true; + + const diskCache = readDiskCache(); + if (!diskCache) return; + + // Load disk cache into memory (regardless of freshness) + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; +} + +/** + * Get cached data or fetch from loader with TTL + * Implements stale-while-revalidate pattern for instant responses + */ +async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { + // Ensure disk cache is loaded on first request + ensureDiskCacheLoaded(); + + const cached = cache.get(key) as CacheEntry | undefined; + const now = Date.now(); + + // Fresh cache - return immediately + if (cached && now - cached.timestamp < ttl) { + return cached.data; + } + + // Stale cache - return immediately, refresh in background (SWR pattern) + if (cached && now - cached.timestamp < STALE_TTL) { + // Fire and forget background refresh if not already pending + if (!pendingRequests.has(key)) { + const promise = loader() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + lastFetchTimestamp = Date.now(); + persistCacheIfComplete(); + }) + .catch((err) => { + console.error(fail(`Background refresh failed for ${key}: ${err}`)); + }) + .finally(() => { + pendingRequests.delete(key); + }); + pendingRequests.set(key, promise); + } + return cached.data; + } + + // No usable cache - check if request is already pending (coalesce) + const pending = pendingRequests.get(key) as Promise | undefined; + if (pending) { + return pending; + } + + // Create new request + const promise = loader() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + lastFetchTimestamp = Date.now(); + persistCacheIfComplete(); + return data; + }) + .finally(() => { + pendingRequests.delete(key); + }); + + pendingRequests.set(key, promise); + return promise; +} + +/** Cached loader for daily usage data */ +export async function getCachedDailyData(): Promise { + return getCachedData('daily', CACHE_TTL.daily, async () => { + return await loadDailyUsageData(); + }); +} + +/** Cached loader for monthly usage data */ +export async function getCachedMonthlyData(): Promise { + return getCachedData('monthly', CACHE_TTL.monthly, async () => { + return await loadMonthlyUsageData(); + }); +} + +/** Cached loader for session data */ +export async function getCachedSessionData(): Promise { + return getCachedData('session', CACHE_TTL.session, async () => { + return await loadSessionData(); + }); +} + +/** Cached loader for hourly usage data */ +export async function getCachedHourlyData(): Promise { + return getCachedData('hourly', CACHE_TTL.daily, async () => { + return await loadHourlyUsageData(); + }); +} + +/** + * Clear all cached data (useful for manual refresh) + */ +export function clearUsageCache(): void { + cache.clear(); + clearDiskCache(); + // Reset so next API call will try to reload from disk/source + diskCacheInitialized = false; +} + +/** + * Pre-warm usage caches on server startup + * + * Strategy: + * 1. Check disk cache - if fresh, use it (instant startup) + * 2. If stale, use it immediately but trigger background refresh + * 3. If no cache, return immediately and let first request trigger load + */ +export async function prewarmUsageCache(): Promise<{ + timestamp: number; + elapsed: number; + source: string; +}> { + const start = Date.now(); + console.log(info('Pre-warming usage cache...')); + + try { + const diskCache = readDiskCache(); + + // Fresh disk cache - use it directly + if (diskCache && isDiskCacheFresh(diskCache)) { + const now = Date.now(); + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; + + const elapsed = Date.now() - start; + console.log( + ok(`Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`) + ); + return { timestamp: now, elapsed, source: 'disk-fresh' }; + } + + // Stale disk cache - use it immediately, refresh in background + if (diskCache && isDiskCacheStale(diskCache)) { + const now = Date.now(); + cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp }); + cache.set('hourly', { data: diskCache.hourly || [], timestamp: diskCache.timestamp }); + cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp }); + cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp }); + lastFetchTimestamp = diskCache.timestamp; + + const elapsed = Date.now() - start; + console.log( + ok( + `Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)` + ) + ); + + // Background refresh + if (!isRefreshing) { + isRefreshing = true; + refreshFromSource() + .then(() => console.log(ok('Background refresh complete'))) + .catch((err) => console.error(fail(`Background refresh failed: ${err}`))) + .finally(() => { + isRefreshing = false; + }); + } + + return { timestamp: now, elapsed, source: 'disk-stale' }; + } + + // No usable disk cache - refresh from source (blocking for first startup only) + console.log(info('No disk cache, loading from source...')); + await refreshFromSource(); + + const elapsed = Date.now() - start; + console.log(ok(`Usage cache ready (${elapsed}ms)`)); + return { timestamp: Date.now(), elapsed, source: 'fresh' }; + } catch (err) { + console.error(fail(`Failed to prewarm usage cache: ${err}`)); + throw err; + } +} diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts new file mode 100644 index 00000000..85a13c22 --- /dev/null +++ b/src/web-server/usage/data-aggregator.ts @@ -0,0 +1,516 @@ +/** + * Data Aggregator for Claude Code Usage Analytics + * + * Aggregates raw JSONL entries into daily, monthly, and session summaries. + * Uses model-pricing.ts for cost calculations. + */ + +import { type RawUsageEntry } from '../jsonl-parser'; +import { calculateCost } from '../model-pricing'; +import { + type ModelBreakdown, + type DailyUsage, + type HourlyUsage, + type MonthlyUsage, + type SessionUsage, +} from './types'; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Extract YYYY-MM-DD from ISO timestamp */ +function extractDate(timestamp: string): string { + return timestamp.slice(0, 10); +} + +/** Extract YYYY-MM from ISO timestamp */ +function extractMonth(timestamp: string): string { + return timestamp.slice(0, 7); +} + +/** Extract YYYY-MM-DD HH:00 from ISO timestamp */ +function extractHour(timestamp: string): string { + const date = timestamp.slice(0, 10); + const hour = timestamp.slice(11, 13) || '00'; + return `${date} ${hour}:00`; +} + +/** Create model breakdown from accumulated data */ +function createModelBreakdown( + modelName: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number +): ModelBreakdown { + const cost = calculateCost( + { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, + modelName + ); + + return { + modelName, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + cost, + }; +} + +/** Accumulator for per-model token counts */ +interface ModelAccumulator { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +// ============================================================================ +// DAILY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into daily usage summaries + * Groups by date (YYYY-MM-DD), calculates costs per model + */ +export function aggregateDailyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): DailyUsage[] { + // Group entries by date + const byDate = new Map(); + + for (const entry of entries) { + const date = extractDate(entry.timestamp); + const existing = byDate.get(date) || []; + existing.push(entry); + byDate.set(date, existing); + } + + // Build daily summaries + const dailyUsage: DailyUsage[] = []; + + for (const [date, dateEntries] of byDate) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of dateEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + dailyUsage.push({ + date, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by date descending (most recent first) + dailyUsage.sort((a, b) => b.date.localeCompare(a.date)); + + return dailyUsage; +} + +// ============================================================================ +// HOURLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into hourly usage summaries + * Groups by hour (YYYY-MM-DD HH:00), calculates costs per model + */ +export function aggregateHourlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): HourlyUsage[] { + // Group entries by hour + const byHour = new Map(); + + for (const entry of entries) { + const hour = extractHour(entry.timestamp); + const existing = byHour.get(hour) || []; + existing.push(entry); + byHour.set(hour, existing); + } + + // Build hourly summaries + const hourlyUsage: HourlyUsage[] = []; + + for (const [hour, hourEntries] of byHour) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of hourEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + hourlyUsage.push({ + hour, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by hour descending (most recent first) + hourlyUsage.sort((a, b) => b.hour.localeCompare(a.hour)); + + return hourlyUsage; +} + +// ============================================================================ +// MONTHLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into monthly usage summaries + * Groups by month (YYYY-MM), calculates costs per model + */ +export function aggregateMonthlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): MonthlyUsage[] { + // Group entries by month + const byMonth = new Map(); + + for (const entry of entries) { + const month = extractMonth(entry.timestamp); + const existing = byMonth.get(month) || []; + existing.push(entry); + byMonth.set(month, existing); + } + + // Build monthly summaries + const monthlyUsage: MonthlyUsage[] = []; + + for (const [month, monthEntries] of byMonth) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of monthEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + monthlyUsage.push({ + month, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by month descending (most recent first) + monthlyUsage.sort((a, b) => b.month.localeCompare(a.month)); + + return monthlyUsage; +} + +// ============================================================================ +// SESSION AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into session usage summaries + * Groups by sessionId, tracks last activity and versions + */ +export function aggregateSessionUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): SessionUsage[] { + // Group entries by sessionId + const bySession = new Map(); + + for (const entry of entries) { + if (!entry.sessionId) continue; + const existing = bySession.get(entry.sessionId) || []; + existing.push(entry); + bySession.set(entry.sessionId, existing); + } + + // Build session summaries + const sessionUsage: SessionUsage[] = []; + + for (const [sessionId, sessionEntries] of bySession) { + // Aggregate by model + const modelMap = new Map(); + const versions = new Set(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + let lastActivity = ''; + let projectPath = ''; + + for (const entry of sessionEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + + // Track latest timestamp + if (entry.timestamp > lastActivity) { + lastActivity = entry.timestamp; + } + + // Track versions + if (entry.version) { + versions.add(entry.version); + } + + // Use project path from entry + if (entry.projectPath) { + projectPath = entry.projectPath; + } + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + sessionUsage.push({ + sessionId, + projectPath, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + lastActivity, + versions: Array.from(versions), + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + source, + }); + } + + // Sort by last activity descending (most recent first) + sessionUsage.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); + + return sessionUsage; +} + +// ============================================================================ +// MAIN DATA LOADER (drop-in replacement for better-ccusage) +// ============================================================================ + +import { scanProjectsDirectory, type ParserOptions } from '../jsonl-parser'; + +/** + * Load daily usage data (replaces better-ccusage loadDailyUsageData) + */ +export async function loadDailyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateDailyUsage(entries); +} + +/** + * Load hourly usage data for today's chart + */ +export async function loadHourlyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateHourlyUsage(entries); +} + +/** + * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) + */ +export async function loadMonthlyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateMonthlyUsage(entries); +} + +/** + * Load session data (replaces better-ccusage loadSessionData) + */ +export async function loadSessionData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateSessionUsage(entries); +} + +/** + * Load all usage data in a single pass (more efficient) + */ +export async function loadAllUsageData(options?: ParserOptions): Promise<{ + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + const entries = await scanProjectsDirectory(options); + return { + daily: aggregateDailyUsage(entries), + hourly: aggregateHourlyUsage(entries), + monthly: aggregateMonthlyUsage(entries), + session: aggregateSessionUsage(entries), + }; +} diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts new file mode 100644 index 00000000..fd834da2 --- /dev/null +++ b/src/web-server/usage/disk-cache.ts @@ -0,0 +1,155 @@ +/** + * Persistent Disk Cache for Usage Data + * + * Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files + * on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate. + * + * Cache location: ~/.ccs/cache/usage.json + * Default TTL: 5 minutes (configurable) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; +import { ok, info, warn } from '../../utils/ui'; + +// Cache configuration +const CCS_DIR = path.join(os.homedir(), '.ccs'); +const CACHE_DIR = path.join(CCS_DIR, 'cache'); +const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) + +/** Structure of the disk cache file */ +export interface UsageDiskCache { + version: number; + timestamp: number; + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +} + +// Current cache version - increment to invalidate old caches +// v3: Added hourly data to cache +const CACHE_VERSION = 3; + +/** + * Ensure ~/.ccs/cache directory exists + */ +function ensureCacheDir(): void { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +/** + * Read usage data from disk cache + * Returns null if cache is missing, corrupted, or has incompatible version + * NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern + */ +export function readDiskCache(): UsageDiskCache | null { + try { + if (!fs.existsSync(CACHE_FILE)) { + return null; + } + + const data = fs.readFileSync(CACHE_FILE, 'utf-8'); + const cache: UsageDiskCache = JSON.parse(data); + + // Version check - invalidate if schema changed + if (cache.version !== CACHE_VERSION) { + console.log(info('Cache version mismatch, will refresh')); + return null; + } + + // Always return cache regardless of age - SWR pattern handles staleness + return cache; + } catch (err) { + // Cache corrupted or unreadable - treat as miss + console.log(info('Cache read failed, will refresh:') + ` ${(err as Error).message}`); + return null; + } +} + +/** + * Check if disk cache is fresh (within TTL) + */ +export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean { + if (!cache) return false; + const age = Date.now() - cache.timestamp; + return age < CACHE_TTL_MS; +} + +/** + * Check if disk cache is stale but usable (between TTL and STALE_TTL) + */ +export function isDiskCacheStale(cache: UsageDiskCache | null): boolean { + if (!cache) return false; + const age = Date.now() - cache.timestamp; + return age >= CACHE_TTL_MS && age < STALE_TTL_MS; +} + +/** + * Write usage data to disk cache + */ +export function writeDiskCache( + daily: DailyUsage[], + hourly: HourlyUsage[], + monthly: MonthlyUsage[], + session: SessionUsage[] +): void { + try { + ensureCacheDir(); + + const cache: UsageDiskCache = { + version: CACHE_VERSION, + timestamp: Date.now(), + daily, + hourly, + monthly, + session, + }; + + // Write atomically using temp file + rename + const tempFile = CACHE_FILE + '.tmp'; + fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); + fs.renameSync(tempFile, CACHE_FILE); + + console.log(ok('Disk cache updated')); + } catch (err) { + // Non-fatal - we can still serve from memory + console.log(warn('Failed to write disk cache:') + ` ${(err as Error).message}`); + } +} + +/** + * Get cache age in human-readable format + */ +export function getCacheAge(cache: UsageDiskCache | null): string { + if (!cache) return 'never'; + + const age = Date.now() - cache.timestamp; + const seconds = Math.floor(age / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `${hours}h ${minutes % 60}m ago`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`; + return `${seconds}s ago`; +} + +/** + * Delete disk cache (for manual refresh) + */ +export function clearDiskCache(): void { + try { + if (fs.existsSync(CACHE_FILE)) { + fs.unlinkSync(CACHE_FILE); + console.log(ok('Disk cache cleared')); + } + } catch (err) { + console.log(warn('Failed to clear disk cache:') + ` ${(err as Error).message}`); + } +} diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts new file mode 100644 index 00000000..e2b69f06 --- /dev/null +++ b/src/web-server/usage/handlers.ts @@ -0,0 +1,633 @@ +/** + * Usage Route Handlers + * + * Contains all route handler logic for usage analytics endpoints. + * Separated from routes for better testability and organization. + */ + +import type { Request, Response } from 'express'; +import type { DailyUsage, Anomaly, AnomalySummary, TokenBreakdown } from './types'; +import { getModelPricing } from '../model-pricing'; +import { + getCachedDailyData, + getCachedMonthlyData, + getCachedSessionData, + getCachedHourlyData, + clearUsageCache, + getLastFetchTimestamp, +} from './aggregator'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Query parameters for usage endpoints */ +export interface UsageQuery { + since?: string; // YYYYMMDD format + until?: string; // YYYYMMDD format + limit?: string; + offset?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MAX_LIMIT = 1000; +const DEFAULT_LIMIT = 50; +const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format + +const ANOMALY_THRESHOLDS = { + HIGH_INPUT_TOKENS: 10_000_000, + HIGH_IO_RATIO: 100, + COST_SPIKE_MULTIPLIER: 2, + HIGH_CACHE_READ_TOKENS: 1_000_000_000, +}; + +// ============================================================================ +// Validation Helpers +// ============================================================================ + +/** + * Validate date string in YYYYMMDD format + */ +export function validateDate(dateString?: string): string | undefined { + if (!dateString) return undefined; + + if (!DATE_REGEX.test(dateString)) { + throw new Error('Invalid date format. Use YYYYMMDD'); + } + + const year = parseInt(dateString.substring(0, 4), 10); + const month = parseInt(dateString.substring(4, 6), 10); + const day = parseInt(dateString.substring(6, 8), 10); + + if (year < 2024 || year > 2100) throw new Error('Year out of valid range'); + if (month < 1 || month > 12) throw new Error('Month out of valid range'); + if (day < 1 || day > 31) throw new Error('Day out of valid range'); + + return dateString; +} + +export function validateLimit(limit?: string): number { + if (!limit) return DEFAULT_LIMIT; + const num = parseInt(limit, 10); + if (isNaN(num) || num < 1 || num > MAX_LIMIT) { + throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`); + } + return num; +} + +export function validateOffset(offset?: string): number { + if (!offset) return 0; + const num = parseInt(offset, 10); + if (isNaN(num) || num < 0) { + throw new Error('Offset must be a non-negative number'); + } + return num; +} + +export function filterByDateRange< + T extends { date?: string; month?: string; lastActivity?: string }, +>(data: T[] | undefined, since?: string, until?: string): T[] { + if (!data || !Array.isArray(data)) return []; + if (!since && !until) return data; + + return data.filter((item) => { + const itemDate = + item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, ''); + if (!itemDate) return true; + + const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8); + if (since && normalizedDate < since) return false; + if (until && normalizedDate > until) return false; + return true; + }); +} + +export function errorResponse(res: Response, error: unknown, defaultMessage: string): void { + console.error(defaultMessage + ':', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const isValidationError = + errorMessage.includes('Invalid') || + errorMessage.includes('format') || + errorMessage.includes('range') || + errorMessage.includes('must be'); + + res.status(isValidationError ? 400 : 500).json({ + success: false, + error: isValidationError ? errorMessage : defaultMessage, + }); +} + +// ============================================================================ +// Cost Calculation Helpers +// ============================================================================ + +export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown { + let inputTokens = 0, + outputTokens = 0, + cacheCreationTokens = 0, + cacheReadTokens = 0; + let inputCost = 0, + outputCost = 0, + cacheCreationCost = 0, + cacheReadCost = 0; + + for (const day of dailyData) { + for (const breakdown of day.modelBreakdowns) { + const pricing = getModelPricing(breakdown.modelName); + inputTokens += breakdown.inputTokens; + outputTokens += breakdown.outputTokens; + cacheCreationTokens += breakdown.cacheCreationTokens; + cacheReadTokens += breakdown.cacheReadTokens; + inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion; + outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion; + cacheCreationCost += + (breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + } + } + + return { + input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, + cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }; +} + +// ============================================================================ +// Hourly Gap Filling +// ============================================================================ + +export function fillHourlyGaps( + data: Array<{ + hour: string; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + modelsUsed: number; + requests: number; + }>, + since?: string, + until?: string +): typeof data { + if (!since && !until) return data.sort((a, b) => a.hour.localeCompare(b.hour)); + + const hourMap = new Map(data.map((d) => [d.hour, d])); + const now = new Date(); + const startDate = since + ? new Date(Date.UTC(+since.slice(0, 4), +since.slice(4, 6) - 1, +since.slice(6, 8), 0, 0, 0)) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); + const endDate = until + ? new Date(Date.UTC(+until.slice(0, 4), +until.slice(4, 6) - 1, +until.slice(6, 8), 23, 59, 59)) + : now; + const cappedEndDate = endDate > now ? now : endDate; + + const result: typeof data = []; + const current = new Date(startDate); + current.setMinutes(0, 0, 0); + + while (current <= cappedEndDate) { + const year = current.getUTCFullYear(); + const month = String(current.getUTCMonth() + 1).padStart(2, '0'); + const day = String(current.getUTCDate()).padStart(2, '0'); + const hour = String(current.getUTCHours()).padStart(2, '0'); + const hourKey = `${year}-${month}-${day} ${hour}:00`; + + result.push( + hourMap.get(hourKey) || { + hour: hourKey, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cost: 0, + modelsUsed: 0, + requests: 0, + } + ); + current.setTime(current.getTime() + 60 * 60 * 1000); + } + return result; +} + +// ============================================================================ +// Anomaly Detection +// ============================================================================ + +function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`; + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`; + return tokens.toString(); +} + +export function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] { + const anomalies: Anomaly[] = []; + const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0); + const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0; + const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER; + + for (const day of dailyData) { + if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) { + const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'cost_spike', + value: day.totalCost, + threshold: avgDailyCost, + message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`, + }); + } + + for (const breakdown of day.modelBreakdowns) { + if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) { + const multiplier = + Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_input', + model: breakdown.modelName, + value: breakdown.inputTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS, + message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`, + }); + } + + if (breakdown.outputTokens > 0) { + const ioRatio = breakdown.inputTokens / breakdown.outputTokens; + if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) { + const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10; + anomalies.push({ + date: day.date, + type: 'high_io_ratio', + model: breakdown.modelName, + value: ioRatio, + threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO, + message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`, + }); + } + } + + if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) { + const multiplier = + Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) / + 10; + anomalies.push({ + date: day.date, + type: 'high_cache_read', + model: breakdown.modelName, + value: breakdown.cacheReadTokens, + threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS, + message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`, + }); + } + } + } + return anomalies.sort((a, b) => b.date.localeCompare(a.date)); +} + +export function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary { + const highInputDates = new Set(); + const highIoRatioDates = new Set(); + const costSpikeDates = new Set(); + const highCacheReadDates = new Set(); + + for (const anomaly of anomalies) { + switch (anomaly.type) { + case 'high_input': + highInputDates.add(anomaly.date); + break; + case 'high_io_ratio': + highIoRatioDates.add(anomaly.date); + break; + case 'cost_spike': + costSpikeDates.add(anomaly.date); + break; + case 'high_cache_read': + highCacheReadDates.add(anomaly.date); + break; + } + } + + return { + totalAnomalies: anomalies.length, + highInputDays: highInputDates.size, + highIoRatioDays: highIoRatioDates.size, + costSpikeDays: costSpikeDates.size, + highCacheReadDays: highCacheReadDates.size, + }; +} + +// ============================================================================ +// Route Handlers +// ============================================================================ + +export async function handleSummary( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + let totalInputTokens = 0, + totalOutputTokens = 0; + let totalCacheCreationTokens = 0, + totalCacheReadTokens = 0, + totalCost = 0; + + for (const day of filtered) { + totalInputTokens += day.inputTokens; + totalOutputTokens += day.outputTokens; + totalCacheCreationTokens += day.cacheCreationTokens; + totalCacheReadTokens += day.cacheReadTokens; + totalCost += day.totalCost; + } + + const totalTokens = totalInputTokens + totalOutputTokens; + const tokenBreakdown = calculateTokenBreakdownCosts(filtered); + + res.json({ + success: true, + data: { + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens, + totalCacheCreationTokens, + totalCacheReadTokens, + totalCost: Math.round(totalCost * 100) / 100, + tokenBreakdown, + totalDays: filtered.length, + averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, + averageCostPerDay: + filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage summary'); + } +} + +export async function handleDaily( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + const trends = filtered.map((day) => ({ + date: day.date, + tokens: day.inputTokens + day.outputTokens, + inputTokens: day.inputTokens, + outputTokens: day.outputTokens, + cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, + cost: Math.round(day.totalCost * 100) / 100, + modelsUsed: day.modelsUsed.length, + })); + + res.json({ success: true, data: trends }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch daily usage'); + } +} + +export async function handleHourly( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const hourlyData = await getCachedHourlyData(); + + const filtered = (hourlyData || []).filter((h) => { + const hourDate = h.hour.slice(0, 10).replace(/-/g, ''); + if (since && hourDate < since) return false; + if (until && hourDate > until) return false; + return true; + }); + + const trends = filtered.map((hour) => ({ + hour: hour.hour, + tokens: hour.inputTokens + hour.outputTokens, + inputTokens: hour.inputTokens, + outputTokens: hour.outputTokens, + cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, + cost: Math.round(hour.totalCost * 100) / 100, + modelsUsed: hour.modelsUsed.length, + requests: hour.modelBreakdowns.length, + })); + + const filledTrends = fillHourlyGaps(trends, since, until); + res.json({ success: true, data: filledTrends }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch hourly usage'); + } +} + +export async function handleModels( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + const modelMap = new Map< + string, + { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + } + >(); + + for (const day of filtered) { + for (const breakdown of day.modelBreakdowns) { + const existing = modelMap.get(breakdown.modelName) || { + model: breakdown.modelName, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0, + }; + existing.inputTokens += breakdown.inputTokens; + existing.outputTokens += breakdown.outputTokens; + existing.cacheCreationTokens += breakdown.cacheCreationTokens; + existing.cacheReadTokens += breakdown.cacheReadTokens; + existing.cost += breakdown.cost; + modelMap.set(breakdown.modelName, existing); + } + } + + const models = Array.from(modelMap.values()); + const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); + + const result = models + .map((m) => { + const pricing = getModelPricing(m.model); + const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion; + const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion; + const cacheCreationCost = + (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; + + return { + model: m.model, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheCreationTokens: m.cacheCreationTokens, + cacheReadTokens: m.cacheReadTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.cost * 100) / 100, + percentage: + totalTokens > 0 + ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 + : 0, + costBreakdown: { + input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, + output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, + cacheCreation: { + tokens: m.cacheCreationTokens, + cost: Math.round(cacheCreationCost * 100) / 100, + }, + cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + }, + ioRatio: Math.round(ioRatio * 10) / 10, + }; + }) + .sort((a, b) => b.tokens - a.tokens); + + res.json({ success: true, data: result }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch model usage'); + } +} + +export async function handleSessions( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const limit = validateLimit(req.query.limit); + const offset = validateOffset(req.query.offset); + + const sessionData = await getCachedSessionData(); + const filtered = filterByDateRange(sessionData, since, until); + const sorted = [...filtered].sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); + const paginated = sorted.slice(offset, offset + limit); + + const sessions = paginated.map((s) => ({ + sessionId: s.sessionId, + projectPath: s.projectPath, + tokens: s.inputTokens + s.outputTokens, + inputTokens: s.inputTokens, + outputTokens: s.outputTokens, + cost: Math.round(s.totalCost * 100) / 100, + lastActivity: s.lastActivity, + modelsUsed: s.modelsUsed, + })); + + res.json({ + success: true, + data: { + sessions, + total: filtered.length, + limit, + offset, + hasMore: offset + limit < filtered.length, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch sessions'); + } +} + +export async function handleMonthly( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const monthlyData = await getCachedMonthlyData(); + + const filtered = + since || until + ? monthlyData.filter((m) => { + const monthDate = m.month.replace('-', '') + '01'; + if (since && monthDate < since) return false; + if (until && monthDate > until) return false; + return true; + }) + : monthlyData; + + const result = filtered.map((m) => ({ + month: m.month, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.totalCost * 100) / 100, + modelsUsed: m.modelsUsed.length, + })); + + res.json({ success: true, data: result.sort((a, b) => a.month.localeCompare(b.month)) }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch monthly usage'); + } +} + +export function handleRefresh(_req: Request, res: Response): void { + clearUsageCache(); + res.json({ success: true, message: 'Usage cache cleared' }); +} + +export function handleStatus(_req: Request, res: Response): void { + const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator + res.json({ + success: true, + data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size }, + }); +} + +export async function handleInsights( + req: Request, + res: Response +): Promise { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + const anomalies = detectAnomalies(filtered); + const summary = summarizeAnomalies(anomalies); + + res.json({ success: true, data: { anomalies, summary } }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage insights'); + } +} diff --git a/src/web-server/usage/index.ts b/src/web-server/usage/index.ts new file mode 100644 index 00000000..a8f55168 --- /dev/null +++ b/src/web-server/usage/index.ts @@ -0,0 +1,76 @@ +/** + * Usage Module Barrel Export + * + * Re-exports all usage analytics functionality for convenient imports. + */ + +// Types +export type { + ModelBreakdown, + DailyUsage, + HourlyUsage, + MonthlyUsage, + SessionUsage, + TokenCategoryCost, + TokenBreakdown, + AnomalyType, + Anomaly, + AnomalySummary, + UsageInsights, + ExtendedModelUsage, +} from './types'; + +// Disk cache +export { + readDiskCache, + writeDiskCache, + isDiskCacheFresh, + isDiskCacheStale, + clearDiskCache, + getCacheAge, + type UsageDiskCache, +} from './disk-cache'; + +// Data aggregator - aggregation functions +export { + aggregateDailyUsage, + aggregateHourlyUsage, + aggregateMonthlyUsage, + aggregateSessionUsage, + loadDailyUsageData, + loadHourlyUsageData, + loadMonthlyUsageData, + loadSessionData, + loadAllUsageData, +} from './data-aggregator'; + +// Usage aggregator service - caching layer +export { + getCachedDailyData, + getCachedMonthlyData, + getCachedSessionData, + getCachedHourlyData, + clearUsageCache, + prewarmUsageCache, + getLastFetchTimestamp, + mergeDailyData, + mergeMonthlyData, + mergeHourlyData, + mergeSessionData, +} from './aggregator'; + +// Routes +export { usageRoutes } from './routes'; + +// Handlers (for testing) +export { + validateDate, + validateLimit, + validateOffset, + filterByDateRange, + calculateTokenBreakdownCosts, + fillHourlyGaps, + detectAnomalies, + summarizeAnomalies, + type UsageQuery, +} from './handlers'; diff --git a/src/web-server/usage/routes.ts b/src/web-server/usage/routes.ts new file mode 100644 index 00000000..c4a2dabe --- /dev/null +++ b/src/web-server/usage/routes.ts @@ -0,0 +1,52 @@ +/** + * Usage Analytics API Routes + * + * Provides REST endpoints for Claude Code usage analytics. + * Supports daily, monthly, and session-based usage data aggregation. + * + * Route handlers are in ./handlers.ts + */ + +import { Router } from 'express'; +import { + handleSummary, + handleDaily, + handleHourly, + handleModels, + handleSessions, + handleMonthly, + handleRefresh, + handleStatus, + handleInsights, +} from './handlers'; + +export { prewarmUsageCache, clearUsageCache, getLastFetchTimestamp } from './aggregator'; + +export const usageRoutes = Router(); + +// Summary endpoint +usageRoutes.get('/summary', handleSummary); + +// Daily usage endpoint +usageRoutes.get('/daily', handleDaily); + +// Hourly usage endpoint +usageRoutes.get('/hourly', handleHourly); + +// Models usage endpoint +usageRoutes.get('/models', handleModels); + +// Sessions endpoint +usageRoutes.get('/sessions', handleSessions); + +// Monthly usage endpoint +usageRoutes.get('/monthly', handleMonthly); + +// Cache refresh endpoint +usageRoutes.post('/refresh', handleRefresh); + +// Status endpoint +usageRoutes.get('/status', handleStatus); + +// Insights endpoint (anomaly detection) +usageRoutes.get('/insights', handleInsights); diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts new file mode 100644 index 00000000..820bfc53 --- /dev/null +++ b/src/web-server/usage/types.ts @@ -0,0 +1,146 @@ +/** + * Usage Data Types + * + * Type definitions for aggregated usage data. + * Compatible with better-ccusage interfaces for drop-in replacement. + */ + +// ============================================================================ +// MODEL BREAKDOWN +// ============================================================================ + +/** Per-model token and cost breakdown */ +export interface ModelBreakdown { + modelName: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + +// ============================================================================ +// AGGREGATED USAGE TYPES +// ============================================================================ + +/** Daily usage aggregation (YYYY-MM-DD) */ +export interface DailyUsage { + date: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Hourly usage aggregation (YYYY-MM-DD HH:00) */ +export interface HourlyUsage { + hour: string; // Format: "YYYY-MM-DD HH:00" + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Monthly usage aggregation (YYYY-MM) */ +export interface MonthlyUsage { + month: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Session-level usage aggregation */ +export interface SessionUsage { + sessionId: string; + projectPath: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + lastActivity: string; + versions: string[]; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + source: string; +} + +// ============================================================================ +// ANALYTICS INSIGHTS TYPES +// ============================================================================ + +/** Token category with count and cost */ +export interface TokenCategoryCost { + tokens: number; + cost: number; +} + +/** Breakdown of tokens by type with individual costs */ +export interface TokenBreakdown { + input: TokenCategoryCost; + output: TokenCategoryCost; + cacheCreation: TokenCategoryCost; + cacheRead: TokenCategoryCost; +} + +/** Anomaly types for usage pattern detection */ +export type AnomalyType = + | 'high_input' // >10M tokens/day/model + | 'high_io_ratio' // >100x input/output ratio + | 'cost_spike' // >2x daily average cost + | 'high_cache_read'; // >1B cache read tokens + +/** Single anomaly detection result */ +export interface Anomaly { + date: string; + type: AnomalyType; + model?: string; + value: number; + threshold: number; + message: string; +} + +/** Summary of all detected anomalies */ +export interface AnomalySummary { + totalAnomalies: number; + highInputDays: number; + highIoRatioDays: number; + costSpikeDays: number; + highCacheReadDays: number; +} + +/** Insights API response */ +export interface UsageInsights { + anomalies: Anomaly[]; + summary: AnomalySummary; +} + +/** Extended model usage with cost breakdown */ +export interface ExtendedModelUsage { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + tokens: number; + cost: number; + percentage: number; + costBreakdown: TokenBreakdown; + ioRatio: number; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2604f341..80a0bf1e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,9 +3,9 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; -import { ThemeProvider } from '@/components/theme-provider'; +import { ThemeProvider } from '@/components/layout/theme-provider'; import { PrivacyProvider } from '@/contexts/privacy-context'; -import { Layout } from '@/components/layout'; +import { Layout } from '@/components/layout/layout'; // Eager load: HomePage (initial route) import { HomePage } from '@/pages'; diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index c707e212..daa07b65 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -1,1144 +1,12 @@ /** * Account Flow Visualization - * Custom SVG bezier curve visualization showing request flow from accounts to providers - * Inspired by modern dark theme design with glass panels and glow effects + * Re-exports from modularized location for backward compatibility */ -import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; -import { cn } from '@/lib/utils'; -import { ProviderIcon } from '@/components/provider-icon'; -import { PROVIDER_COLORS } from '@/lib/provider-config'; -import { STATUS_COLORS } from '@/lib/utils'; -import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { - ChevronRight, - CheckCircle2, - XCircle, - Activity, - GripVertical, - Eye, - EyeOff, - RotateCcw, -} from 'lucide-react'; - -// Maximum events to display in the Connection Timeline to prevent performance issues -const MAX_TIMELINE_EVENTS = 100; - -// Earthy, sophisticated color palette for connection lines - works in both light/dark themes -const CONNECTION_COLORS = [ - '#3b3c36', // Charcoal Brown - urban mystery - '#568203', // Forest Moss - woodland depth - '#8d4557', // Vintage Berry - timeless elegance - '#da9100', // Harvest Gold - sun-drenched warmth - '#3c6c82', // Blue Slate - cool authority - '#c96907', // Burnt Caramel - earthy comfort -]; - -/** Get a muted connection color based on index */ -function getConnectionColor(index: number): string { - return CONNECTION_COLORS[index % CONNECTION_COLORS.length]; -} - -/** Position offset for draggable cards */ -interface DragOffset { - x: number; - y: number; -} - -interface AccountData { - id: string; - email: string; - provider: string; - successCount: number; - failureCount: number; - lastUsedAt?: string; - color: string; -} - -interface ProviderData { - provider: string; - displayName: string; - totalRequests: number; - accounts: AccountData[]; -} - -interface AccountFlowVizProps { - providerData: ProviderData; - onBack?: () => void; -} - -interface ConnectionEvent { - id: string; - timestamp: Date; - accountEmail: string; - status: 'success' | 'failed' | 'pending'; - latencyMs?: number; -} - -/** Generate connection events from real account data */ -function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { - const events: ConnectionEvent[] = []; - - accounts.forEach((account) => { - const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); - - // Helper to add events - const addEvents = (count: number, status: 'success' | 'failed') => { - for (let i = 0; i < count; i++) { - // Simulate timestamps: - // - Distribute events over a 24-hour window relative to lastUsed - // - Add random jitter so events from different accounts mix - const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1))); - const timestamp = new Date(lastUsed.getTime() - timeOffset); - - // Add small random jitter (±5 mins) to avoid exact overlaps - const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000); - timestamp.setTime(timestamp.getTime() + jitter); - - // Sanity check: don't go into the future relative to "now" - const now = new Date(); - if (timestamp > now) timestamp.setTime(now.getTime()); - - events.push({ - id: `${account.id}-${status}-${i}`, - timestamp, - accountEmail: account.email, - status, - // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms) - latencyMs: - status === 'success' - ? 50 + Math.floor(Math.random() * 150) - : 200 + Math.floor(Math.random() * 4800), - }); - } - }; - - addEvents(account.successCount, 'success'); - addEvents(account.failureCount, 'failed'); - }); - - // Sort by timestamp descending (most recent first) - return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); -} - -/** Format timestamp for timeline display */ -function formatTimelineTime(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return 'now'; - if (diffMins < 60) return `${diffMins}m`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h`; - return `${Math.floor(diffHours / 24)}d`; -} - -/** Connection Timeline Component - right sidebar panel */ -function ConnectionTimeline({ - events, - privacyMode, -}: { - events: ConnectionEvent[]; - privacyMode: boolean; -}) { - if (events.length === 0) { - return ( -
-
No recent connections
-
- ); - } - - return ( -
- {/* Header */} -
- - - Connection Timeline - -
- - {/* Timeline container */} -
-
- {/* Vertical line */} -
- - {/* Events */} -
- {events.map((event) => { - const statusColor = - event.status === 'success' - ? STATUS_COLORS.success - : event.status === 'failed' - ? STATUS_COLORS.failed - : STATUS_COLORS.degraded; - - return ( -
- {/* Timeline dot */} -
- - {/* Event content */} -
-
- - {cleanEmail(event.accountEmail)} - - - {formatTimelineTime(event.timestamp)} - -
-
- - {event.status} - - {event.latencyMs && ( - - {event.latencyMs}ms - - )} -
-
-
- ); - })} -
-
-
-
- ); -} - -/** Strip common email domains for cleaner display */ -function cleanEmail(email: string): string { - return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); -} - -/** Premium compact stats visualization for account cards */ -function AccountCardStats({ - success, - failure, - showDetails, -}: { - success: number; - failure: number; - showDetails: boolean; -}) { - const total = success + failure; - const successRate = total > 0 ? (success / total) * 100 : 100; - - return ( -
- {/* Primary Row: Success Rate & Total */} -
-
- - Success Rate - - = 90 - ? 'text-amber-500' - : 'text-red-500' - )} - > - {Math.round(successRate)}% - -
-
- - Volume - - - {total.toLocaleString()} - -
-
- - {/* Detailed Stats - Collapsible */} -
-
- - - {success} - -
-
0 - ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20' - : 'bg-muted/10 border-transparent opacity-40' - )} - > - 0 ? 'text-red-500' : 'text-muted-foreground')} - /> - 0 ? 'text-red-500' : 'text-muted-foreground' - )} - > - {failure} - -
-
-
- ); -} - -export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { - const containerRef = useRef(null); - const svgRef = useRef(null); - const [hoveredAccount, setHoveredAccount] = useState(null); - const [showDetails, setShowDetails] = useState(false); - const [paths, setPaths] = useState([]); - - // Privacy mode for demo purposes - const { privacyMode } = usePrivacy(); - - // Drag state for all cards (account IDs + 'provider') - const [draggingId, setDraggingId] = useState(null); - const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( - null - ); - const didDragRef = useRef(false); // Track if actual movement occurred (for click vs drag detection) - - // LocalStorage persistence for card positions - const storageKey = `ccs-flow-positions-${providerData.provider}`; - const loadSavedPositions = useCallback((): Record => { - try { - const saved = localStorage.getItem(storageKey); - if (saved) return JSON.parse(saved); - } catch { - // Ignore parse errors - } - return {}; - }, [storageKey]); - - const [dragOffsets, setDragOffsets] = useState>(() => - loadSavedPositions() - ); - - // Save positions to localStorage when they change - useEffect(() => { - if (Object.keys(dragOffsets).length > 0) { - localStorage.setItem(storageKey, JSON.stringify(dragOffsets)); - } - }, [dragOffsets, storageKey]); - - // Reset positions handler - const resetPositions = useCallback(() => { - setDragOffsets({}); - localStorage.removeItem(storageKey); - }, [storageKey]); - - // Pulse state: account IDs that are currently pulsing - const [pulsingAccounts, setPulsingAccounts] = useState>(new Set()); - // Store previous counts to detect changes - const [prevCounts, setPrevCounts] = useState>({}); - - const { accounts } = providerData; - const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); - const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); - - // Detect new activity and trigger pulse (runs when accounts data changes) - useEffect(() => { - const newPulsing = new Set(); - const newCounts: Record = {}; - - accounts.forEach((account) => { - const currentCount = account.successCount + account.failureCount; - newCounts[account.id] = currentCount; - const prev = prevCounts[account.id] ?? 0; - - if (currentCount > prev && prev > 0) { - newPulsing.add(account.id); - } - }); - - setPrevCounts(newCounts); - - if (newPulsing.size > 0) { - setPulsingAccounts(newPulsing); - // Clear pulse after animation completes (match CSS animation duration) - const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000); - return () => clearTimeout(timer); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accounts]); - - // Generate connection events for timeline - // Limit events to prevent UI lag with high request counts - const connectionEvents = useMemo( - () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS), - [accounts] - ); - - // Calculate SVG paths for bezier curves - const calculatePaths = useCallback(() => { - if (!containerRef.current || !svgRef.current) return; - - const container = containerRef.current; - const svg = svgRef.current; - const svgRect = svg.getBoundingClientRect(); - - const destEl = container.querySelector('[data-provider-node]'); - if (!destEl) return; - const destRect = destEl.getBoundingClientRect(); - - const newPaths: string[] = []; - - accounts.forEach((_, i) => { - const sourceEl = container.querySelector(`[data-account-index="${i}"]`); - if (!sourceEl) return; - const sourceRect = sourceEl.getBoundingClientRect(); - - // Determine zone from data attribute - const zone = sourceEl.getAttribute('data-zone') || 'left'; - - let startX: number, startY: number, destX: number, destY: number; - - // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit - - switch (zone) { - case 'right': - // Right side: connect from left edge of card to right edge of provider - startX = sourceRect.left - svgRect.left; - startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; - destX = destRect.right - svgRect.left; - destY = destRect.top + destRect.height / 2 - svgRect.top; - break; - case 'top': - // Top side: connect from bottom edge of card to top edge of provider - startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; - startY = sourceRect.bottom - svgRect.top; - destX = destRect.left + destRect.width / 2 - svgRect.left; - destY = destRect.top - svgRect.top; - break; - case 'bottom': - // Bottom side: connect from top edge of card to bottom edge of provider - startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; - startY = sourceRect.top - svgRect.top; - destX = destRect.left + destRect.width / 2 - svgRect.left; - destY = destRect.bottom - svgRect.top; - break; - default: // 'left' - // Left side: connect from right edge of card to left edge of provider - startX = sourceRect.right - svgRect.left; - startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; - destX = destRect.left - svgRect.left; - destY = destRect.top + destRect.height / 2 - svgRect.top; - } - - // Bezier control points - adjust based on zone direction - // Note: getBoundingClientRect already includes CSS transforms, so no manual offset needed - let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number; - - if (zone === 'top' || zone === 'bottom') { - // Vertical connection - control points extend horizontally for curve - cp1X = startX; - cp1Y = startY + (destY - startY) * 0.5; - cp2X = destX; - cp2Y = destY - (destY - startY) * 0.5; - } else { - // Horizontal connection - control points extend vertically for curve - cp1X = startX + (destX - startX) * 0.5; - cp1Y = startY; - cp2X = destX - (destX - startX) * 0.5; - cp2Y = destY; - } - - newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); - }); - - setPaths(newPaths); - }, [accounts]); - - useEffect(() => { - // Initial calculation after render - const timer = setTimeout(calculatePaths, 50); - window.addEventListener('resize', calculatePaths); - return () => { - clearTimeout(timer); - window.removeEventListener('resize', calculatePaths); - }; - }, [calculatePaths]); - - // Recalculate paths when drag offsets change (including reset) - useEffect(() => { - const timer = setTimeout(calculatePaths, 10); - return () => clearTimeout(timer); - }, [dragOffsets, calculatePaths]); - - // Animate paths when toggling details to match CSS transition - useEffect(() => { - const startTime = Date.now(); - const duration = 350; // Match transition duration (300ms) + buffer - - const animate = () => { - calculatePaths(); - if (Date.now() - startTime < duration) { - requestAnimationFrame(animate); - } - }; - - requestAnimationFrame(animate); - }, [showDetails, calculatePaths]); - - const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; - - // Split accounts into zones based on count (top/left/right/bottom) - const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = useMemo(() => { - const count = accounts.length; - // 1-2 accounts: left only - if (count <= 2) { - return { leftAccounts: accounts, rightAccounts: [], topAccounts: [], bottomAccounts: [] }; - } - // 3-4 accounts: left and right - if (count <= 4) { - const mid = Math.ceil(count / 2); - return { - leftAccounts: accounts.slice(0, mid), - rightAccounts: accounts.slice(mid), - topAccounts: [], - bottomAccounts: [], - }; - } - // 5-8 accounts: left, right, top - if (count <= 8) { - const perZone = Math.ceil(count / 3); - return { - leftAccounts: accounts.slice(0, perZone), - rightAccounts: accounts.slice(perZone, perZone * 2), - topAccounts: accounts.slice(perZone * 2), - bottomAccounts: [], - }; - } - // 9+ accounts: all four zones - const perZone = Math.ceil(count / 4); - return { - leftAccounts: accounts.slice(0, perZone), - rightAccounts: accounts.slice(perZone, perZone * 2), - topAccounts: accounts.slice(perZone * 2, perZone * 3), - bottomAccounts: accounts.slice(perZone * 3), - }; - }, [accounts]); - - const hasRightAccounts = rightAccounts.length > 0; - const hasTopAccounts = topAccounts.length > 0; - const hasBottomAccounts = bottomAccounts.length > 0; - - // Container expansion based on drag offsets (only vertical to avoid squeezing horizontal content) - const containerExpansion = useMemo(() => { - let minY = 0, - maxY = 0; - Object.values(dragOffsets).forEach((offset) => { - minY = Math.min(minY, offset.y); - maxY = Math.max(maxY, offset.y); - }); - return { - paddingTop: Math.max(0, -minY), - paddingBottom: Math.max(0, maxY), - extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2, - }; - }, [dragOffsets]); - - // Dynamic provider card size based on account count - const providerSize = useMemo(() => { - const count = accounts.length; - if (count >= 9) return 'w-64'; // 4 zones - largest - if (count >= 5) return 'w-60'; // 3 zones - if (count >= 3) return 'w-56'; // 2 zones - return 'w-52'; // 1 zone - default - }, [accounts.length]); - - // Drag handlers - const handlePointerDown = useCallback( - (id: string, e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - const offset = dragOffsets[id] || { x: 0, y: 0 }; - dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y }; - didDragRef.current = false; // Reset movement flag - setDraggingId(id); - }, - [dragOffsets] - ); - - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - if (!draggingId || !dragStartRef.current) return; - const start = dragStartRef.current; - const dx = e.clientX - start.x; - const dy = e.clientY - start.y; - // Track if actual movement occurred (threshold of 3px) - if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { - didDragRef.current = true; - } - setDragOffsets((prev) => ({ - ...prev, - [draggingId]: { - x: start.offsetX + dx, - y: start.offsetY + dy, - }, - })); - // Recalculate paths during drag - requestAnimationFrame(calculatePaths); - }, - [draggingId, calculatePaths] - ); - - const handlePointerUp = useCallback(() => { - setDraggingId(null); - dragStartRef.current = null; - }, []); - - // Get offset for a card - const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 }; - - return ( -
- {/* Header: Back button + Reset Layout */} -
- {onBack ? ( - - ) : ( -
- )} -
- - - {Object.keys(dragOffsets).length > 0 && ( - - )} -
-
- - {/* Main visualization area - Multi-zone layout */} -
- {/* Flow visualization section */} -
- {/* SVG Canvas (Background) */} - - - - - - - - {paths.map((d, i) => { - const account = accounts[i]; - const total = account.successCount + account.failureCount; - const strokeWidth = Math.max(2, (total / maxRequests) * 10); - const isHovered = hoveredAccount === i; - const isDimmed = hoveredAccount !== null && hoveredAccount !== i; - const isPulsing = pulsingAccounts.has(account.id); - // Use muted connection colors for gentler appearance - const connectionColor = getConnectionColor(i); - - return ( - - {/* Base path - static connection line */} - - {/* Pulse layer - only shows when new activity detected */} - {isPulsing && ( - <> - {/* Glowing path pulse */} - - {/* Traveling dot along path */} - - - )} - - ); - })} - - - {/* Top Zone Accounts */} - {hasTopAccounts && ( -
- {topAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
setHoveredAccount(originalIndex)} - onMouseLeave={() => setHoveredAccount(null)} - onPointerDown={(e) => handlePointerDown(account.id, e)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - className={cn( - 'group/card relative rounded-lg p-3 pb-4 w-44 cursor-grab transition-shadow duration-200', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-t-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderTopColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - -
- - {cleanEmail(account.email)} - -
- - {/* Connector Dot - Bottom side */} -
-
- ); - })} -
- )} - - {/* Middle Row: Left | Center Provider | Right */} -
- {/* Left Accounts */} -
- {leftAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
setHoveredAccount(originalIndex)} - onMouseLeave={() => setHoveredAccount(null)} - onPointerDown={(e) => handlePointerDown(account.id, e)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - className={cn( - 'group/card relative rounded-lg p-3 pr-4 w-44 cursor-grab transition-shadow duration-200', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-l-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderLeftColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - {/* Drag handle indicator */} - -
- - {cleanEmail(account.email)} - -
- - {/* Connector Dot - Right side */} -
-
- ); - })} -
- - {/* Center Provider */} -
- {(() => { - const isDragging = draggingId === 'provider'; - const offset = getOffset('provider'); - return ( -
handlePointerDown('provider', e)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - className={cn( - 'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border-2 border-border/50 dark:border-white/[0.08]', - // Idle animations: float + border glow (disabled when dragging) - !isDragging && 'animate-subtle-float animate-border-glow', - 'select-none touch-none', - hoveredAccount !== null && 'scale-[1.02]', - isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50' - )} - style={ - { - '--glow-color': `${providerColor}60`, - borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - } as React.CSSProperties - } - > - {/* Drag handle */} - - - {/* Animated glow background */} -
- - {/* Left Connector Point */} -
- - {/* Right Connector Point - only show if there are right accounts */} - {hasRightAccounts && ( -
- )} - - {/* Top Connector Point - only show if there are top accounts */} - {hasTopAccounts && ( -
- )} - - {/* Bottom Connector Point - only show if there are bottom accounts */} - {hasBottomAccounts && ( -
- )} - -
- {/* Provider icon with breathing animation */} -
- -
-
-

- {providerData.displayName} -

-

- Provider -

-
-
- -
-
- Total Requests - - {totalRequests.toLocaleString()} - -
-
- Accounts - {accounts.length} -
-
-
-
-
-
- ); - })()} -
- - {/* Right Accounts */} - {hasRightAccounts && ( -
- {rightAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
setHoveredAccount(originalIndex)} - onMouseLeave={() => setHoveredAccount(null)} - onPointerDown={(e) => handlePointerDown(account.id, e)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - className={cn( - 'group/card relative rounded-lg p-3 pl-4 w-44 cursor-grab transition-shadow duration-200', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-r-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderRightColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - {/* Drag handle indicator */} - -
- - {cleanEmail(account.email)} - -
- - {/* Connector Dot - Left side */} -
-
- ); - })} -
- )} -
- - {/* Bottom Zone Accounts */} - {hasBottomAccounts && ( -
- {bottomAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
setHoveredAccount(originalIndex)} - onMouseLeave={() => setHoveredAccount(null)} - onPointerDown={(e) => handlePointerDown(account.id, e)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - className={cn( - 'group/card relative rounded-lg p-3 pt-4 w-44 cursor-grab transition-shadow duration-200', - 'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm', - 'border border-border/50 dark:border-white/[0.08]', - 'border-b-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderBottomColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - {/* Connector Dot - Top side */} -
- -
- - {cleanEmail(account.email)} - -
- -
- ); - })} -
- )} -
- - {/* Right Section: Connection Timeline - Fixed compact width */} -
-
- -
-
-
-
- ); -} +export { AccountFlowViz } from './account/flow-viz'; +export type { + AccountData, + ProviderData, + AccountFlowVizProps, + ConnectionEvent, +} from './account/flow-viz'; diff --git a/ui/src/components/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx similarity index 100% rename from ui/src/components/accounts-table.tsx rename to ui/src/components/account/accounts-table.tsx diff --git a/ui/src/components/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx similarity index 100% rename from ui/src/components/add-account-dialog.tsx rename to ui/src/components/account/add-account-dialog.tsx diff --git a/ui/src/components/account/flow-viz/account-card-stats.tsx b/ui/src/components/account/flow-viz/account-card-stats.tsx new file mode 100644 index 00000000..3b3a18f3 --- /dev/null +++ b/ui/src/components/account/flow-viz/account-card-stats.tsx @@ -0,0 +1,85 @@ +/** + * Premium compact stats visualization for account cards + */ + +import { cn } from '@/lib/utils'; +import { CheckCircle2, XCircle } from 'lucide-react'; + +interface AccountCardStatsProps { + success: number; + failure: number; + showDetails: boolean; +} + +export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) { + const total = success + failure; + const successRate = total > 0 ? (success / total) * 100 : 100; + + return ( +
+ {/* Primary Row: Success Rate & Total */} +
+
+ + Success Rate + + = 90 + ? 'text-amber-500' + : 'text-red-500' + )} + > + {Math.round(successRate)}% + +
+
+ + Volume + + + {total.toLocaleString()} + +
+
+ + {/* Detailed Stats - Collapsible */} +
+
+ + + {success} + +
+
0 + ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20' + : 'bg-muted/10 border-transparent opacity-40' + )} + > + 0 ? 'text-red-500' : 'text-muted-foreground')} + /> + 0 ? 'text-red-500' : 'text-muted-foreground' + )} + > + {failure} + +
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx new file mode 100644 index 00000000..3defbedc --- /dev/null +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -0,0 +1,127 @@ +/** + * Account Card Component for Flow Visualization + */ + +import { cn } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { GripVertical } from 'lucide-react'; + +import type { AccountData, DragOffset } from './types'; +import { cleanEmail } from './utils'; +import { AccountCardStats } from './account-card-stats'; + +type Zone = 'left' | 'right' | 'top' | 'bottom'; + +interface AccountCardProps { + account: AccountData; + zone: Zone; + originalIndex: number; + isHovered: boolean; + isDragging: boolean; + offset: DragOffset; + showDetails: boolean; + privacyMode: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +const BORDER_SIDE_MAP: Record = { + left: 'border-l-2', + right: 'border-r-2', + top: 'border-t-2', + bottom: 'border-b-2', +}; + +const CONNECTOR_POSITION_MAP: Record = { + left: 'top-1/2 -right-1.5 -translate-y-1/2', + right: 'top-1/2 -left-1.5 -translate-y-1/2', + top: 'left-1/2 -bottom-1.5 -translate-x-1/2', + bottom: 'left-1/2 -top-1.5 -translate-x-1/2', +}; + +function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties { + switch (zone) { + case 'left': + return { borderLeftColor: color }; + case 'right': + return { borderRightColor: color }; + case 'top': + return { borderTopColor: color }; + case 'bottom': + return { borderBottomColor: color }; + } +} + +export function AccountCard({ + account, + zone, + originalIndex, + isHovered, + isDragging, + offset, + showDetails, + privacyMode, + onMouseEnter, + onMouseLeave, + onPointerDown, + onPointerMove, + onPointerUp, +}: AccountCardProps) { + const borderSide = BORDER_SIDE_MAP[zone]; + const borderColor = getBorderColorStyle(zone, account.color); + const connectorPosition = CONNECTOR_POSITION_MAP[zone]; + + return ( +
+ +
+ + {cleanEmail(account.email)} + +
+ +
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/connection-timeline.tsx b/ui/src/components/account/flow-viz/connection-timeline.tsx new file mode 100644 index 00000000..f7743435 --- /dev/null +++ b/ui/src/components/account/flow-viz/connection-timeline.tsx @@ -0,0 +1,107 @@ +/** + * Connection Timeline Component - right sidebar panel + */ + +import { cn } from '@/lib/utils'; +import { STATUS_COLORS } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { Activity } from 'lucide-react'; + +import type { ConnectionEvent } from './types'; +import { cleanEmail, formatTimelineTime } from './utils'; + +interface ConnectionTimelineProps { + events: ConnectionEvent[]; + privacyMode: boolean; +} + +export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) { + if (events.length === 0) { + return ( +
+
No recent connections
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Connection Timeline + +
+ + {/* Timeline container */} +
+
+ {/* Vertical line */} +
+ + {/* Events */} +
+ {events.map((event) => { + const statusColor = + event.status === 'success' + ? STATUS_COLORS.success + : event.status === 'failed' + ? STATUS_COLORS.failed + : STATUS_COLORS.degraded; + + return ( +
+ {/* Timeline dot */} +
+ + {/* Event content */} +
+
+ + {cleanEmail(event.accountEmail)} + + + {formatTimelineTime(event.timestamp)} + +
+
+ + {event.status} + + {event.latencyMs && ( + + {event.latencyMs}ms + + )} +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/flow-paths.tsx b/ui/src/components/account/flow-viz/flow-paths.tsx new file mode 100644 index 00000000..810be3b8 --- /dev/null +++ b/ui/src/components/account/flow-viz/flow-paths.tsx @@ -0,0 +1,89 @@ +/** + * SVG Connection Paths Component + */ + +import type { AccountData } from './types'; +import { getConnectionColor } from './utils'; + +interface FlowPathsProps { + paths: string[]; + accounts: AccountData[]; + maxRequests: number; + hoveredAccount: number | null; + pulsingAccounts: Set; +} + +export function FlowPaths({ + paths, + accounts, + maxRequests, + hoveredAccount, + pulsingAccounts, +}: FlowPathsProps) { + return ( + <> + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + if (!account) return null; + + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + const isPulsing = pulsingAccounts.has(account.id); + const connectionColor = getConnectionColor(i); + + return ( + + + {isPulsing && ( + <> + + + + )} + + ); + })} + + ); +} diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx new file mode 100644 index 00000000..01065856 --- /dev/null +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -0,0 +1,61 @@ +/** + * Flow Visualization Header Component + */ + +import { cn } from '@/lib/utils'; +import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; + +interface FlowVizHeaderProps { + onBack?: () => void; + showDetails: boolean; + onToggleDetails: () => void; + hasCustomPositions: boolean; + onResetPositions: () => void; +} + +export function FlowVizHeader({ + onBack, + showDetails, + onToggleDetails, + hasCustomPositions, + onResetPositions, +}: FlowVizHeaderProps) { + return ( +
+ {onBack ? ( + + ) : ( +
+ )} +
+ + {hasCustomPositions && ( + + )} +
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/hooks.ts b/ui/src/components/account/flow-viz/hooks.ts new file mode 100644 index 00000000..635a700d --- /dev/null +++ b/ui/src/components/account/flow-viz/hooks.ts @@ -0,0 +1,183 @@ +/** + * Custom hooks for drag and position management + */ + +import { useRef, useState, useEffect, useCallback } from 'react'; +import type { DragOffset, ContainerExpansion } from './types'; + +interface UseDragPositionsOptions { + storageKey: string; + onDrag?: () => void; +} + +interface UseDragPositionsReturn { + dragOffsets: Record; + draggingId: string | null; + didDragRef: React.MutableRefObject; + handlePointerDown: (id: string, e: React.PointerEvent) => void; + handlePointerMove: (e: React.PointerEvent) => void; + handlePointerUp: () => void; + getOffset: (id: string) => DragOffset; + resetPositions: () => void; + hasCustomPositions: boolean; +} + +/** + * Hook for managing draggable card positions with localStorage persistence + */ +export function useDragPositions({ + storageKey, + onDrag, +}: UseDragPositionsOptions): UseDragPositionsReturn { + // Drag state + const [draggingId, setDraggingId] = useState(null); + const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( + null + ); + const didDragRef = useRef(false); + + // Load saved positions from localStorage + const loadSavedPositions = useCallback((): Record => { + try { + const saved = localStorage.getItem(storageKey); + if (saved) return JSON.parse(saved); + } catch { + // Ignore parse errors + } + return {}; + }, [storageKey]); + + const [dragOffsets, setDragOffsets] = useState>(() => + loadSavedPositions() + ); + + // Save positions to localStorage when they change + useEffect(() => { + if (Object.keys(dragOffsets).length > 0) { + localStorage.setItem(storageKey, JSON.stringify(dragOffsets)); + } + }, [dragOffsets, storageKey]); + + // Reset positions handler + const resetPositions = useCallback(() => { + setDragOffsets({}); + localStorage.removeItem(storageKey); + }, [storageKey]); + + // Drag handlers + const handlePointerDown = useCallback( + (id: string, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + const offset = dragOffsets[id] || { x: 0, y: 0 }; + dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y }; + didDragRef.current = false; + setDraggingId(id); + }, + [dragOffsets] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!draggingId || !dragStartRef.current) return; + const start = dragStartRef.current; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + // Track if actual movement occurred (threshold of 3px) + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + didDragRef.current = true; + } + setDragOffsets((prev) => ({ + ...prev, + [draggingId]: { + x: start.offsetX + dx, + y: start.offsetY + dy, + }, + })); + // Notify parent to recalculate paths + if (onDrag) { + requestAnimationFrame(onDrag); + } + }, + [draggingId, onDrag] + ); + + const handlePointerUp = useCallback(() => { + setDraggingId(null); + dragStartRef.current = null; + }, []); + + // Get offset for a card + const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 }; + + return { + dragOffsets, + draggingId, + didDragRef, + handlePointerDown, + handlePointerMove, + handlePointerUp, + getOffset, + resetPositions, + hasCustomPositions: Object.keys(dragOffsets).length > 0, + }; +} + +/** + * Calculate container expansion based on drag offsets + */ +export function useContainerExpansion(dragOffsets: Record): ContainerExpansion { + let minY = 0, + maxY = 0; + Object.values(dragOffsets).forEach((offset) => { + minY = Math.min(minY, offset.y); + maxY = Math.max(maxY, offset.y); + }); + return { + paddingTop: Math.max(0, -minY), + paddingBottom: Math.max(0, maxY), + extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2, + }; +} + +interface AccountLike { + id: string; + successCount: number; + failureCount: number; +} + +/** + * Hook for detecting new activity and triggering pulse animations + */ +export function usePulseAnimation(accounts: AccountLike[]): Set { + const [pulsingAccounts, setPulsingAccounts] = useState>(new Set()); + const prevCountsRef = useRef>({}); + + // Detect new activity and trigger pulse animation + useEffect(() => { + const newPulsing = new Set(); + const newCounts: Record = {}; + + accounts.forEach((account) => { + const currentCount = account.successCount + account.failureCount; + newCounts[account.id] = currentCount; + const prev = prevCountsRef.current[account.id]; + if (prev !== undefined && currentCount > prev) { + newPulsing.add(account.id); + } + }); + + prevCountsRef.current = newCounts; + + if (newPulsing.size > 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid pattern for animation triggers + setPulsingAccounts(newPulsing); + + const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000); + return () => clearTimeout(timer); + } + }, [accounts]); + + return pulsingAccounts; +} diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx new file mode 100644 index 00000000..7d1dae00 --- /dev/null +++ b/ui/src/components/account/flow-viz/index.tsx @@ -0,0 +1,202 @@ +/** + * Account Flow Visualization + * Custom SVG bezier curve visualization showing request flow from accounts to providers + */ + +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { PROVIDER_COLORS } from '@/lib/provider-config'; +import { usePrivacy } from '@/contexts/privacy-context'; + +import type { AccountFlowVizProps } from './types'; +import { MAX_TIMELINE_EVENTS, generateConnectionEvents } from './utils'; +import { calculateBezierPaths } from './path-utils'; +import { splitAccountsIntoZones, getProviderSizeClass } from './zone-utils'; +import { useDragPositions, useContainerExpansion, usePulseAnimation } from './hooks'; +import { ConnectionTimeline } from './connection-timeline'; +import { AccountCard } from './account-card'; +import { ProviderCard } from './provider-card'; +import { FlowPaths } from './flow-paths'; +import { FlowVizHeader } from './flow-viz-header'; + +// Re-export types for backward compatibility +export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types'; + +export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [hoveredAccount, setHoveredAccount] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [paths, setPaths] = useState([]); + + const { privacyMode } = usePrivacy(); + const { accounts } = providerData; + const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); + const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0); + + const calculatePaths = useCallback(() => { + const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts }); + if (newPaths.length > 0) setPaths(newPaths); + }, [accounts]); + + const storageKey = `ccs-flow-positions-${providerData.provider}`; + const { + dragOffsets, + draggingId, + handlePointerDown, + handlePointerMove, + handlePointerUp, + getOffset, + resetPositions, + hasCustomPositions, + } = useDragPositions({ storageKey, onDrag: calculatePaths }); + const containerExpansion = useContainerExpansion(dragOffsets); + const pulsingAccounts = usePulseAnimation(accounts); + + const connectionEvents = useMemo( + () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS), + [accounts] + ); + + useEffect(() => { + const timer = setTimeout(calculatePaths, 50); + window.addEventListener('resize', calculatePaths); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', calculatePaths); + }; + }, [calculatePaths]); + + useEffect(() => { + const timer = setTimeout(calculatePaths, 10); + return () => clearTimeout(timer); + }, [dragOffsets, calculatePaths]); + + useEffect(() => { + const startTime = Date.now(); + const duration = 350; + const animate = () => { + calculatePaths(); + if (Date.now() - startTime < duration) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }, [showDetails, calculatePaths]); + + const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; + const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]); + const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = zones; + const hasRightAccounts = rightAccounts.length > 0; + const hasTopAccounts = topAccounts.length > 0; + const hasBottomAccounts = bottomAccounts.length > 0; + const providerSize = useMemo(() => getProviderSizeClass(accounts.length), [accounts.length]); + + const renderAccountCards = ( + accountList: typeof accounts, + zone: 'left' | 'right' | 'top' | 'bottom' + ) => + accountList.map((account) => { + const originalIndex = accounts.findIndex((a) => a.id === account.id); + return ( + setHoveredAccount(originalIndex)} + onMouseLeave={() => setHoveredAccount(null)} + onPointerDown={(e) => handlePointerDown(account.id, e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> + ); + }); + + return ( +
+ setShowDetails(!showDetails)} + hasCustomPositions={hasCustomPositions} + onResetPositions={resetPositions} + /> + +
+
+ + + + + {hasTopAccounts && ( +
+ {renderAccountCards(topAccounts, 'top')} +
+ )} + +
+
+ {renderAccountCards(leftAccounts, 'left')} +
+ +
+ handlePointerDown('provider', e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> +
+ + {hasRightAccounts && ( +
+ {renderAccountCards(rightAccounts, 'right')} +
+ )} +
+ + {hasBottomAccounts && ( +
+ {renderAccountCards(bottomAccounts, 'bottom')} +
+ )} +
+ +
+
+ +
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/path-utils.ts b/ui/src/components/account/flow-viz/path-utils.ts new file mode 100644 index 00000000..422c8727 --- /dev/null +++ b/ui/src/components/account/flow-viz/path-utils.ts @@ -0,0 +1,96 @@ +/** + * SVG path calculation utilities for bezier curves + */ + +import type { AccountData, AccountZone } from './types'; + +interface PathCalculationParams { + containerRef: React.RefObject; + svgRef: React.RefObject; + accounts: AccountData[]; +} + +/** + * Calculate SVG bezier curve paths from account cards to provider node + */ +export function calculateBezierPaths({ + containerRef, + svgRef, + accounts, +}: PathCalculationParams): string[] { + if (!containerRef.current || !svgRef.current) return []; + + const container = containerRef.current; + const svg = svgRef.current; + const svgRect = svg.getBoundingClientRect(); + + const destEl = container.querySelector('[data-provider-node]'); + if (!destEl) return []; + const destRect = destEl.getBoundingClientRect(); + + const newPaths: string[] = []; + + accounts.forEach((_, i) => { + const sourceEl = container.querySelector(`[data-account-index="${i}"]`); + if (!sourceEl) return; + const sourceRect = sourceEl.getBoundingClientRect(); + + // Determine zone from data attribute + const zone = (sourceEl.getAttribute('data-zone') || 'left') as AccountZone; + + let startX: number, startY: number, destX: number, destY: number; + + // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit + + switch (zone) { + case 'right': + // Right side: connect from left edge of card to right edge of provider + startX = sourceRect.left - svgRect.left; + startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + destX = destRect.right - svgRect.left; + destY = destRect.top + destRect.height / 2 - svgRect.top; + break; + case 'top': + // Top side: connect from bottom edge of card to top edge of provider + startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; + startY = sourceRect.bottom - svgRect.top; + destX = destRect.left + destRect.width / 2 - svgRect.left; + destY = destRect.top - svgRect.top; + break; + case 'bottom': + // Bottom side: connect from top edge of card to bottom edge of provider + startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; + startY = sourceRect.top - svgRect.top; + destX = destRect.left + destRect.width / 2 - svgRect.left; + destY = destRect.bottom - svgRect.top; + break; + default: // 'left' + // Left side: connect from right edge of card to left edge of provider + startX = sourceRect.right - svgRect.left; + startY = sourceRect.top + sourceRect.height / 2 - svgRect.top; + destX = destRect.left - svgRect.left; + destY = destRect.top + destRect.height / 2 - svgRect.top; + } + + // Bezier control points - adjust based on zone direction + let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number; + + if (zone === 'top' || zone === 'bottom') { + // Vertical connection - control points extend horizontally for curve + cp1X = startX; + cp1Y = startY + (destY - startY) * 0.5; + cp2X = destX; + cp2Y = destY - (destY - startY) * 0.5; + } else { + // Horizontal connection - control points extend vertically for curve + cp1X = startX + (destX - startX) * 0.5; + cp1Y = startY; + cp2X = destX - (destX - startX) * 0.5; + cp2Y = destY; + } + + newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); + }); + + return newPaths; +} diff --git a/ui/src/components/account/flow-viz/provider-card.tsx b/ui/src/components/account/flow-viz/provider-card.tsx new file mode 100644 index 00000000..95328d94 --- /dev/null +++ b/ui/src/components/account/flow-viz/provider-card.tsx @@ -0,0 +1,143 @@ +/** + * Provider Card Component for Flow Visualization + */ + +import { cn } from '@/lib/utils'; +import { ProviderIcon } from '@/components/shared/provider-icon'; +import { GripVertical } from 'lucide-react'; + +import type { DragOffset, ProviderData } from './types'; + +interface ProviderCardProps { + providerData: ProviderData; + providerColor: string; + totalRequests: number; + maxRequests: number; + isDragging: boolean; + offset: DragOffset; + hoveredAccount: number | null; + hasRightAccounts: boolean; + hasTopAccounts: boolean; + hasBottomAccounts: boolean; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +export function ProviderCard({ + providerData, + providerColor, + totalRequests, + maxRequests, + isDragging, + offset, + hoveredAccount, + hasRightAccounts, + hasTopAccounts, + hasBottomAccounts, + onPointerDown, + onPointerMove, + onPointerUp, +}: ProviderCardProps) { + const { accounts } = providerData; + + return ( +
+ +
+ + {/* Connector Points */} +
+ {hasRightAccounts && ( +
+ )} + {hasTopAccounts && ( +
+ )} + {hasBottomAccounts && ( +
+ )} + +
+
+ +
+
+

+ {providerData.displayName} +

+

Provider

+
+
+ +
+
+ Total Requests + {totalRequests.toLocaleString()} +
+
+ Accounts + {accounts.length} +
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/types.ts b/ui/src/components/account/flow-viz/types.ts new file mode 100644 index 00000000..b6e5ca33 --- /dev/null +++ b/ui/src/components/account/flow-viz/types.ts @@ -0,0 +1,57 @@ +/** + * Type definitions for Account Flow Visualization + */ + +/** Position offset for draggable cards */ +export interface DragOffset { + x: number; + y: number; +} + +export interface AccountData { + id: string; + email: string; + provider: string; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +export interface ProviderData { + provider: string; + displayName: string; + totalRequests: number; + accounts: AccountData[]; +} + +export interface AccountFlowVizProps { + providerData: ProviderData; + onBack?: () => void; +} + +export interface ConnectionEvent { + id: string; + timestamp: Date; + accountEmail: string; + status: 'success' | 'failed' | 'pending'; + latencyMs?: number; +} + +/** Zone type for account card placement */ +export type AccountZone = 'left' | 'right' | 'top' | 'bottom'; + +/** Container expansion state */ +export interface ContainerExpansion { + paddingTop: number; + paddingBottom: number; + extraHeight: number; +} + +/** Account zone distribution */ +export interface AccountZones { + leftAccounts: AccountData[]; + rightAccounts: AccountData[]; + topAccounts: AccountData[]; + bottomAccounts: AccountData[]; +} diff --git a/ui/src/components/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts new file mode 100644 index 00000000..ce101437 --- /dev/null +++ b/ui/src/components/account/flow-viz/utils.ts @@ -0,0 +1,87 @@ +/** + * Utility functions for Account Flow Visualization + */ + +import type { AccountData, ConnectionEvent } from './types'; + +// Maximum events to display in the Connection Timeline to prevent performance issues +export const MAX_TIMELINE_EVENTS = 100; + +// Earthy, sophisticated color palette for connection lines - works in both light/dark themes +export const CONNECTION_COLORS = [ + '#3b3c36', // Charcoal Brown - urban mystery + '#568203', // Forest Moss - woodland depth + '#8d4557', // Vintage Berry - timeless elegance + '#da9100', // Harvest Gold - sun-drenched warmth + '#3c6c82', // Blue Slate - cool authority + '#c96907', // Burnt Caramel - earthy comfort +]; + +/** Get a muted connection color based on index */ +export function getConnectionColor(index: number): string { + return CONNECTION_COLORS[index % CONNECTION_COLORS.length]; +} + +/** Strip common email domains for cleaner display */ +export function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +/** Format timestamp for timeline display */ +export function formatTimelineTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'now'; + if (diffMins < 60) return `${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.floor(diffHours / 24)}d`; +} + +/** Generate connection events from real account data */ +export function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { + const events: ConnectionEvent[] = []; + + accounts.forEach((account) => { + const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); + + // Helper to add events + const addEvents = (count: number, status: 'success' | 'failed') => { + for (let i = 0; i < count; i++) { + // Simulate timestamps: + // - Distribute events over a 24-hour window relative to lastUsed + // - Add random jitter so events from different accounts mix + const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1))); + const timestamp = new Date(lastUsed.getTime() - timeOffset); + + // Add small random jitter (+/-5 mins) to avoid exact overlaps + const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000); + timestamp.setTime(timestamp.getTime() + jitter); + + // Sanity check: don't go into the future relative to "now" + const now = new Date(); + if (timestamp > now) timestamp.setTime(now.getTime()); + + events.push({ + id: `${account.id}-${status}-${i}`, + timestamp, + accountEmail: account.email, + status, + // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms) + latencyMs: + status === 'success' + ? 50 + Math.floor(Math.random() * 150) + : 200 + Math.floor(Math.random() * 4800), + }); + } + }; + + addEvents(account.successCount, 'success'); + addEvents(account.failureCount, 'failed'); + }); + + // Sort by timestamp descending (most recent first) + return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); +} diff --git a/ui/src/components/account/flow-viz/zone-utils.ts b/ui/src/components/account/flow-viz/zone-utils.ts new file mode 100644 index 00000000..328103f7 --- /dev/null +++ b/ui/src/components/account/flow-viz/zone-utils.ts @@ -0,0 +1,63 @@ +/** + * Zone distribution utilities for account cards + */ + +import type { AccountData, AccountZones } from './types'; + +/** + * Split accounts into zones based on count (top/left/right/bottom) + */ +export function splitAccountsIntoZones(accounts: AccountData[]): AccountZones { + const count = accounts.length; + + // 1-2 accounts: left only + if (count <= 2) { + return { + leftAccounts: accounts, + rightAccounts: [], + topAccounts: [], + bottomAccounts: [], + }; + } + + // 3-4 accounts: left and right + if (count <= 4) { + const mid = Math.ceil(count / 2); + return { + leftAccounts: accounts.slice(0, mid), + rightAccounts: accounts.slice(mid), + topAccounts: [], + bottomAccounts: [], + }; + } + + // 5-8 accounts: left, right, top + if (count <= 8) { + const perZone = Math.ceil(count / 3); + return { + leftAccounts: accounts.slice(0, perZone), + rightAccounts: accounts.slice(perZone, perZone * 2), + topAccounts: accounts.slice(perZone * 2), + bottomAccounts: [], + }; + } + + // 9+ accounts: all four zones + const perZone = Math.ceil(count / 4); + return { + leftAccounts: accounts.slice(0, perZone), + rightAccounts: accounts.slice(perZone, perZone * 2), + topAccounts: accounts.slice(perZone * 2, perZone * 3), + bottomAccounts: accounts.slice(perZone * 3), + }; +} + +/** + * Get provider card size class based on account count + */ +export function getProviderSizeClass(accountCount: number): string { + if (accountCount >= 9) return 'w-64'; // 4 zones - largest + if (accountCount >= 5) return 'w-60'; // 3 zones + if (accountCount >= 3) return 'w-56'; // 2 zones + return 'w-52'; // 1 zone - default +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts new file mode 100644 index 00000000..ba0cf9b4 --- /dev/null +++ b/ui/src/components/account/index.ts @@ -0,0 +1,11 @@ +/** + * Account Components Barrel Export + */ + +// Main components +export { AccountsTable } from './accounts-table'; +export { AddAccountDialog } from './add-account-dialog'; + +// Flow visualization (from subdirectory) +export { AccountFlowViz } from './flow-viz'; +export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './flow-viz'; diff --git a/ui/src/components/analytics/index.ts b/ui/src/components/analytics/index.ts new file mode 100644 index 00000000..8a5bf17d --- /dev/null +++ b/ui/src/components/analytics/index.ts @@ -0,0 +1,14 @@ +/** + * Analytics Components Barrel Export + */ + +export { CacheEfficiencyCard } from './cache-efficiency-card'; +export { CliproxyStatsCard } from './cliproxy-stats-card'; +export { DateRangeFilter } from './date-range-filter'; +export { ModelBreakdownChart } from './model-breakdown-chart'; +export { ModelDetailsContent } from './model-details-content'; +export { SessionStatsCard } from './session-stats-card'; +export { TokenBreakdownChart } from './token-breakdown-chart'; +export { UsageInsightsCard } from './usage-insights-card'; +export { UsageSummaryCards } from './usage-summary-cards'; +export { UsageTrendChart } from './usage-trend-chart'; diff --git a/ui/src/components/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx similarity index 100% rename from ui/src/components/cliproxy-dialog.tsx rename to ui/src/components/cliproxy/cliproxy-dialog.tsx diff --git a/ui/src/components/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy/cliproxy-stats-overview.tsx similarity index 100% rename from ui/src/components/cliproxy-stats-overview.tsx rename to ui/src/components/cliproxy/cliproxy-stats-overview.tsx diff --git a/ui/src/components/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx similarity index 100% rename from ui/src/components/cliproxy-table.tsx rename to ui/src/components/cliproxy/cliproxy-table.tsx diff --git a/ui/src/components/cliproxy/index.ts b/ui/src/components/cliproxy/index.ts new file mode 100644 index 00000000..5c743916 --- /dev/null +++ b/ui/src/components/cliproxy/index.ts @@ -0,0 +1,29 @@ +/** + * CLIProxy Components Barrel Export + */ + +// Main cliproxy components +export { CategorizedModelSelector } from './categorized-model-selector'; +export { CliproxyDialog } from './cliproxy-dialog'; +export { CliproxyHeader } from './cliproxy-header'; +export { CliproxyStatsOverview } from './cliproxy-stats-overview'; +export { CliproxyTable } from './cliproxy-table'; +export { CliproxyTabs } from './cliproxy-tabs'; +export { ControlPanelEmbed } from './control-panel-embed'; +export { ProviderLogo } from './provider-logo'; +export { ProviderModelSelector } from './provider-model-selector'; + +// Provider editor (from subdirectory) +export { ProviderEditor } from './provider-editor'; +export type { ProviderEditorProps, ModelMappingValues } from './provider-editor'; + +// Config components (from subdirectory) +export { ConfigSplitView } from './config/config-split-view'; +export { DiffDialog } from './config/diff-dialog'; +export { FileTree } from './config/file-tree'; +export { YamlEditor } from './config/yaml-editor'; + +// Overview components (from subdirectory) +export { CredentialHealthList } from './overview/credential-health-list'; +export { ModelPreferencesGrid } from './overview/model-preferences-grid'; +export { QuickStatsRow } from './overview/quick-stats-row'; diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 0d633340..ce016b03 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -1,921 +1,7 @@ /** * Provider Editor Component - * Split-view editor for CLIProxy provider settings - * Similar to ProfileEditor but tailored for provider configuration + * Re-exports from modular directory for backward compatibility */ -import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Badge } from '@/components/ui/badge'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Separator } from '@/components/ui/separator'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { - Save, - Loader2, - Code2, - Trash2, - RefreshCw, - Info, - X, - Shield, - User, - Plus, - Star, - MoreHorizontal, - Clock, - Sparkles, - Zap, -} from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { toast } from 'sonner'; -import { ProviderLogo } from './provider-logo'; -import { FlexibleModelSelector } from './provider-model-selector'; -import type { ProviderCatalog } from './provider-model-selector'; -import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; -import { - useCliproxyModels, - usePresets, - useCreatePreset, - useDeletePreset, -} from '@/hooks/use-cliproxy'; -import { cn } from '@/lib/utils'; -import { CLIPROXY_PORT } from '@/lib/preset-utils'; -import { GlobalEnvIndicator } from '@/components/global-env-indicator'; -import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; - -// Lazy load CodeEditor -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -interface SettingsResponse { - profile: string; - settings: { - env?: Record; - }; - mtime: number; - path: string; -} - -interface ProviderEditorProps { - provider: string; - displayName: string; - authStatus: AuthStatus; - catalog?: ProviderCatalog; - /** Provider type for logo display (defaults to provider) */ - logoProvider?: string; - onAddAccount: () => void; - onSetDefault: (accountId: string) => void; - onRemoveAccount: (accountId: string) => void; - isRemovingAccount?: boolean; -} - -export function ProviderEditor({ - provider, - displayName, - authStatus, - catalog, - logoProvider, - onAddAccount, - onSetDefault, - onRemoveAccount, - isRemovingAccount, -}: ProviderEditorProps) { - const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [conflictDialog, setConflictDialog] = useState(false); - const [customPresetOpen, setCustomPresetOpen] = useState(false); - const queryClient = useQueryClient(); - const { privacyMode } = usePrivacy(); - - // Fetch available models from CLIProxy API - const { data: modelsData } = useCliproxyModels(); - - // Fetch saved presets for this provider - const { data: presetsData } = usePresets(provider); - const createPresetMutation = useCreatePreset(); - const deletePresetMutation = useDeletePreset(); - const savedPresets = presetsData?.presets || []; - - // Get models for this provider based on owned_by field - const providerModels = useMemo(() => { - if (!modelsData?.models) return []; - const ownerMap: Record = { - gemini: ['google'], - agy: ['antigravity'], - codex: ['openai'], - qwen: ['alibaba', 'qwen'], - iflow: ['iflow'], - }; - const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()]; - return modelsData.models.filter((m) => - owners.some((o) => m.owned_by.toLowerCase().includes(o)) - ); - }, [modelsData, provider]); - - // Fetch settings for this provider - const { data, isLoading, refetch } = useQuery({ - queryKey: ['settings', provider], - queryFn: async () => { - const res = await fetch(`/api/settings/${provider}/raw`); - if (!res.ok) { - // Return empty settings for unconfigured providers - return { - profile: provider, - settings: { env: {} }, - mtime: Date.now(), - path: `~/.ccs/profiles/${provider}/settings.json`, - }; - } - return res.json(); - }, - }); - - const settings = data?.settings; - - // Derive raw JSON content - const rawJsonContent = useMemo(() => { - if (rawJsonEdits !== null) return rawJsonEdits; - if (settings) return JSON.stringify(settings, null, 2); - return '{\n "env": {}\n}'; - }, [rawJsonEdits, settings]); - - const handleRawJsonChange = useCallback((value: string) => { - setRawJsonEdits(value); - }, []); - - // Parse current settings from JSON - const currentSettings = useMemo(() => { - try { - return JSON.parse(rawJsonContent); - } catch { - return settings || { env: {} }; - } - }, [rawJsonContent, settings]); - - // Extract model values from settings - const currentModel = currentSettings?.env?.ANTHROPIC_MODEL; - const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL; - const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; - const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; - - // Update a setting value - const updateEnvValue = (key: string, value: string) => { - const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - }; - - // Batch update multiple env values at once - const updateEnvValues = (updates: Record) => { - const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - }; - - // Check if JSON is valid - const isRawJsonValid = useMemo(() => { - try { - JSON.parse(rawJsonContent); - return true; - } catch { - return false; - } - }, [rawJsonContent]); - - // Check for unsaved changes - const hasChanges = useMemo(() => { - if (rawJsonEdits === null) return false; - return rawJsonEdits !== JSON.stringify(settings, null, 2); - }, [rawJsonEdits, settings]); - - // Save mutation - const saveMutation = useMutation({ - mutationFn: async () => { - const settingsToSave = JSON.parse(rawJsonContent); - const res = await fetch(`/api/settings/${provider}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - settings: settingsToSave, - expectedMtime: data?.mtime, - }), - }); - - if (res.status === 409) throw new Error('CONFLICT'); - if (!res.ok) throw new Error('Failed to save'); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', provider] }); - setRawJsonEdits(null); - toast.success('Settings saved'); - }, - onError: (error: Error) => { - if (error.message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error(error.message); - } - }, - }); - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - await refetch(); - saveMutation.mutate(); - } else { - setRawJsonEdits(null); - } - }; - - const accounts = authStatus.accounts || []; - - // Render Left Column - Model Config + Info tabs - const renderFriendlyUI = () => ( -
- -
- - - Model Config - - - Info & Usage - - -
- -
- {/* Model Config Tab */} - - -
- {/* Quick Presets */} - {(catalog && catalog.models.length > 0) || savedPresets.length > 0 ? ( -
-

- - Presets -

-

- Apply pre-configured model mappings -

-
- {/* Recommended presets from catalog */} - {catalog?.models.slice(0, 3).map((model) => ( - - ))} - - {/* User saved presets */} - {savedPresets.map((preset) => ( -
- - -
- ))} - - -
-
- ) : null} - - - - {/* Model Mapping */} -
-

Model Mapping

-

- Configure which models to use for each tier -

-
- updateEnvValue('ANTHROPIC_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> -
-
- - - - {/* Accounts Section */} -
-
-

- - Accounts - {accounts.length > 0 && ( - - {accounts.length} - - )} -

- -
- - {accounts.length > 0 ? ( -
- {accounts.map((account) => ( - onSetDefault(account.id)} - onRemove={() => onRemoveAccount(account.id)} - isRemoving={isRemovingAccount} - privacyMode={privacyMode} - /> - ))} -
- ) : ( -
- -

No accounts connected

-

Add an account to get started

-
- )} -
-
-
-
- - {/* Info Tab */} - - -
- {/* Provider Information */} -
-

- - Provider Information -

-
-
- Provider - {displayName} -
- {data && ( - <> -
- File Path -
- - {data.path} - - -
-
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
- - )} -
- Status - {authStatus.authenticated ? ( - - - Authenticated - - ) : ( - - Not connected - - )} -
-
-
- - {/* Quick Usage */} -
-

Quick Usage

-
- - - - -
-
-
-
-
-
-
-
- ); - - // Render Right Column - Raw JSON Editor - const renderRawEditor = () => ( - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
- - ); - - return ( -
- {/* Header */} -
-
- -
-
-

{displayName}

- {data?.path && ( - - {data.path.replace(/^.*\//, '')} - - )} -
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} -

- )} -
-
-
- - -
-
- - {isLoading ? ( -
- - Loading settings... -
- ) : ( - // Split Layout (40% Left / 60% Right) -
- {/* Left Column: Friendly UI */} -
{renderFriendlyUI()}
- - {/* Right Column: Raw Editor */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- )} - - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> - - {/* Custom Preset Dialog */} - setCustomPresetOpen(false)} - currentValues={{ - default: currentModel || '', - opus: opusModel || '', - sonnet: sonnetModel || '', - haiku: haikuModel || '', - }} - onApply={(values, presetName) => { - // Always include BASE_URL and AUTH_TOKEN for CLIProxy providers - updateEnvValues({ - ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', - ANTHROPIC_MODEL: values.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku, - }); - toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`); - setCustomPresetOpen(false); - }} - onSave={(values, presetName) => { - if (!presetName) { - toast.error('Please enter a preset name to save'); - return; - } - createPresetMutation.mutate({ - profile: provider, - data: { - name: presetName, - default: values.default, - opus: values.opus, - sonnet: values.sonnet, - haiku: values.haiku, - }, - }); - setCustomPresetOpen(false); - }} - isSaving={createPresetMutation.isPending} - catalog={catalog} - allModels={providerModels} - /> -
- ); -} - -/** Account item component */ -function AccountItem({ - account, - onSetDefault, - onRemove, - isRemoving, - privacyMode, -}: { - account: OAuthAccount; - onSetDefault: () => void; - onRemove: () => void; - isRemoving?: boolean; - privacyMode?: boolean; -}) { - return ( -
-
-
- -
-
-
- - {account.email || account.id} - - {account.isDefault && ( - - - Default - - )} -
- {account.lastUsedAt && ( -
- - Last used: {new Date(account.lastUsedAt).toLocaleDateString()} -
- )} -
-
- - - - - - - {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - -
- ); -} - -/** Usage command with copy button */ -function UsageCommand({ label, command }: { label: string; command: string }) { - return ( -
- -
- - {command} - - -
-
- ); -} - -/** Custom Preset Dialog - Configure all model mappings at once */ -interface CustomPresetDialogProps { - open: boolean; - onClose: () => void; - currentValues: { - default: string; - opus: string; - sonnet: string; - haiku: string; - }; - onApply: ( - values: { default: string; opus: string; sonnet: string; haiku: string }, - presetName?: string - ) => void; - onSave?: ( - values: { default: string; opus: string; sonnet: string; haiku: string }, - presetName?: string - ) => void; - isSaving?: boolean; - catalog?: ProviderCatalog; - allModels: { id: string; owned_by: string }[]; -} - -function CustomPresetDialog({ - open, - onClose, - currentValues, - onApply, - onSave, - isSaving, - catalog, - allModels, -}: CustomPresetDialogProps) { - const [values, setValues] = useState(currentValues); - const [presetName, setPresetName] = useState(''); - - // Reset values when dialog opens with current values - const handleOpenChange = (isOpen: boolean) => { - if (isOpen) { - setValues(currentValues); - setPresetName(''); - } else { - onClose(); - } - }; - - return ( - - - - - - Custom Preset - - -
-
- - setPresetName(e.target.value)} - placeholder="e.g., My Custom Config" - className="text-sm" - /> -
- - setValues({ ...values, default: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, opus: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, sonnet: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, haiku: model })} - catalog={catalog} - allModels={allModels} - /> -
- - - {onSave && ( - - )} - - -
-
- ); -} +export { ProviderEditor } from './provider-editor/index'; +export type { ProviderEditorProps, ModelMappingValues } from './provider-editor/types'; diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx new file mode 100644 index 00000000..589353ee --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -0,0 +1,88 @@ +/** + * Account Item Component + * Displays a single OAuth account with actions + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { User, Star, MoreHorizontal, Clock, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import type { AccountItemProps } from './types'; + +export function AccountItem({ + account, + onSetDefault, + onRemove, + isRemoving, + privacyMode, +}: AccountItemProps) { + return ( +
+
+
+ +
+
+
+ + {account.email || account.id} + + {account.isDefault && ( + + + Default + + )} +
+ {account.lastUsedAt && ( +
+ + Last used: {new Date(account.lastUsedAt).toLocaleDateString()} +
+ )} +
+
+ + + + + + + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + + +
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx new file mode 100644 index 00000000..7d398ac3 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -0,0 +1,69 @@ +/** + * Accounts Section Component + * Manages connected OAuth accounts for a provider + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { User, Plus } from 'lucide-react'; +import { AccountItem } from './account-item'; +import type { OAuthAccount } from '@/lib/api-client'; + +interface AccountsSectionProps { + accounts: OAuthAccount[]; + onAddAccount: () => void; + onSetDefault: (accountId: string) => void; + onRemoveAccount: (accountId: string) => void; + isRemovingAccount?: boolean; + privacyMode?: boolean; +} + +export function AccountsSection({ + accounts, + onAddAccount, + onSetDefault, + onRemoveAccount, + isRemovingAccount, + privacyMode, +}: AccountsSectionProps) { + return ( +
+
+

+ + Accounts + {accounts.length > 0 && ( + + {accounts.length} + + )} +

+ +
+ + {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( + onSetDefault(account.id)} + onRemove={() => onRemoveAccount(account.id)} + isRemoving={isRemovingAccount} + privacyMode={privacyMode} + /> + ))} +
+ ) : ( +
+ +

No accounts connected

+

Add an account to get started

+
+ )} +
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx new file mode 100644 index 00000000..a078dd6b --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -0,0 +1,125 @@ +/** + * Custom Preset Dialog + * Configure all model mappings at once with save option + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Sparkles, Loader2, Star, Zap } from 'lucide-react'; +import { FlexibleModelSelector } from '../provider-model-selector'; +import type { CustomPresetDialogProps, ModelMappingValues } from './types'; + +export function CustomPresetDialog({ + open, + onClose, + currentValues, + onApply, + onSave, + isSaving, + catalog, + allModels, +}: CustomPresetDialogProps) { + const [values, setValues] = useState(currentValues); + const [presetName, setPresetName] = useState(''); + + // Reset values when dialog opens with current values + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + setValues(currentValues); + setPresetName(''); + } else { + onClose(); + } + }; + + return ( + + + + + + Custom Preset + + +
+
+ + setPresetName(e.target.value)} + placeholder="e.g., My Custom Config" + className="text-sm" + /> +
+ + setValues({ ...values, default: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, opus: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, sonnet: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, haiku: model })} + catalog={catalog} + allModels={allModels} + /> +
+ + + {onSave && ( + + )} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx new file mode 100644 index 00000000..2d9f14ff --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -0,0 +1,250 @@ +/** + * Provider Editor Component + * Split-view editor for CLIProxy provider settings + */ + +/* eslint-disable react-refresh/only-export-components */ +import { useMemo, useState } from 'react'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Loader2, Code2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + useCliproxyModels, + usePresets, + useCreatePreset, + useDeletePreset, +} from '@/hooks/use-cliproxy'; +import { CLIPROXY_PORT } from '@/lib/preset-utils'; +import { usePrivacy } from '@/contexts/privacy-context'; +import { useProviderEditor } from './use-provider-editor'; +import { CustomPresetDialog } from './custom-preset-dialog'; +import { RawEditorSection } from './raw-editor-section'; +import { ProviderInfoTab } from './provider-info-tab'; +import { ProviderEditorHeader } from './provider-editor-header'; +import { ModelConfigTab } from './model-config-tab'; +import type { ProviderEditorProps, ModelMappingValues } from './types'; + +export function ProviderEditor({ + provider, + displayName, + authStatus, + catalog, + logoProvider, + onAddAccount, + onSetDefault, + onRemoveAccount, + isRemovingAccount, +}: ProviderEditorProps) { + const [customPresetOpen, setCustomPresetOpen] = useState(false); + const { privacyMode } = usePrivacy(); + + const { data: modelsData } = useCliproxyModels(); + const { data: presetsData } = usePresets(provider); + const createPresetMutation = useCreatePreset(); + const deletePresetMutation = useDeletePreset(); + const savedPresets = presetsData?.presets || []; + + const providerModels = useMemo(() => { + if (!modelsData?.models) return []; + const ownerMap: Record = { + gemini: ['google'], + agy: ['antigravity'], + codex: ['openai'], + qwen: ['alibaba', 'qwen'], + iflow: ['iflow'], + }; + const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()]; + return modelsData.models.filter((m) => + owners.some((o) => m.owned_by.toLowerCase().includes(o)) + ); + }, [modelsData, provider]); + + const { + data, + isLoading, + refetch, + rawJsonContent, + rawJsonEdits, + isRawJsonValid, + hasChanges, + currentModel, + opusModel, + sonnetModel, + haikuModel, + handleRawJsonChange, + updateEnvValue, + updateEnvValues, + saveMutation, + conflictDialog, + handleConflictResolve, + } = useProviderEditor(provider); + + const accounts = authStatus.accounts || []; + + const handleApplyPreset = (updates: Record) => { + updateEnvValues({ + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ...updates, + }); + toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`); + }; + + const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { + updateEnvValues({ + ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: values.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku, + }); + toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`); + setCustomPresetOpen(false); + }; + + const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => { + if (!presetName) { + toast.error('Please enter a preset name to save'); + return; + } + createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } }); + setCustomPresetOpen(false); + }; + + return ( +
+ saveMutation.mutate()} + /> + + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( +
+
+ +
+ + + Model Config + + + Info & Usage + + +
+
+ + setCustomPresetOpen(true)} + onDeletePreset={(name) => + deletePresetMutation.mutate({ profile: provider, name }) + } + isDeletePending={deletePresetMutation.isPending} + accounts={accounts} + onAddAccount={onAddAccount} + onSetDefault={onSetDefault} + onRemoveAccount={onRemoveAccount} + isRemovingAccount={isRemovingAccount} + privacyMode={privacyMode} + /> + + + + +
+
+
+ +
+
+ + + Raw Configuration (JSON) + +
+ +
+
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> + + setCustomPresetOpen(false)} + currentValues={{ + default: currentModel || '', + opus: opusModel || '', + sonnet: sonnetModel || '', + haiku: haikuModel || '', + }} + onApply={handleCustomPresetApply} + onSave={handleCustomPresetSave} + isSaving={createPresetMutation.isPending} + catalog={catalog} + allModels={providerModels} + /> +
+ ); +} + +export type { ProviderEditorProps, ModelMappingValues } from './types'; +export { AccountItem } from './account-item'; +export { UsageCommand } from './usage-command'; +export { CustomPresetDialog } from './custom-preset-dialog'; +export { ModelConfigSection } from './model-config-section'; +export { RawEditorSection } from './raw-editor-section'; +export { AccountsSection } from './accounts-section'; +export { ProviderInfoTab } from './provider-info-tab'; +export { ProviderEditorHeader } from './provider-editor-header'; +export { ModelConfigTab } from './model-config-tab'; +export { useProviderEditor } from './use-provider-editor'; diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx new file mode 100644 index 00000000..ac43a978 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -0,0 +1,158 @@ +/** + * Model Config Section + * Presets and model mapping configuration UI + */ + +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; +import { FlexibleModelSelector } from '../provider-model-selector'; +import type { ModelConfigSectionProps } from './types'; + +export function ModelConfigSection({ + catalog, + savedPresets, + currentModel, + opusModel, + sonnetModel, + haikuModel, + providerModels, + onApplyPreset, + onUpdateEnvValue, + onOpenCustomPreset, + onDeletePreset, + isDeletePending, +}: ModelConfigSectionProps) { + const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0; + + return ( + <> + {/* Quick Presets */} + {showPresets && ( +
+

+ + Presets +

+

Apply pre-configured model mappings

+
+ {/* Recommended presets from catalog */} + {catalog?.models.slice(0, 3).map((model) => ( + + ))} + + {/* User saved presets */} + {savedPresets.map((preset) => ( +
+ + +
+ ))} + + +
+
+ )} + + + + {/* Model Mapping */} +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ onUpdateEnvValue('ANTHROPIC_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> +
+
+ + ); +} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx new file mode 100644 index 00000000..c6f286a8 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -0,0 +1,89 @@ +/** + * Model Config Tab + * Contains model config section and accounts section + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { ModelConfigSection } from './model-config-section'; +import { AccountsSection } from './accounts-section'; +import type { ProviderCatalog } from '../provider-model-selector'; +import type { OAuthAccount } from '@/lib/api-client'; + +interface ModelConfigTabProps { + catalog?: ProviderCatalog; + savedPresets: Array<{ + name: string; + default: string; + opus: string; + sonnet: string; + haiku: string; + }>; + currentModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + providerModels: Array<{ id: string; owned_by: string }>; + onApplyPreset: (updates: Record) => void; + onUpdateEnvValue: (key: string, value: string) => void; + onOpenCustomPreset: () => void; + onDeletePreset: (name: string) => void; + isDeletePending?: boolean; + accounts: OAuthAccount[]; + onAddAccount: () => void; + onSetDefault: (accountId: string) => void; + onRemoveAccount: (accountId: string) => void; + isRemovingAccount?: boolean; + privacyMode?: boolean; +} + +export function ModelConfigTab({ + catalog, + savedPresets, + currentModel, + opusModel, + sonnetModel, + haikuModel, + providerModels, + onApplyPreset, + onUpdateEnvValue, + onOpenCustomPreset, + onDeletePreset, + isDeletePending, + accounts, + onAddAccount, + onSetDefault, + onRemoveAccount, + isRemovingAccount, + privacyMode, +}: ModelConfigTabProps) { + return ( + +
+ + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx new file mode 100644 index 00000000..f64f9605 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -0,0 +1,77 @@ +/** + * Provider Editor Header + * Header bar with provider info, refresh and save buttons + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Save, Loader2, RefreshCw } from 'lucide-react'; +import { ProviderLogo } from '../provider-logo'; +import type { SettingsResponse } from './types'; + +interface ProviderEditorHeaderProps { + provider: string; + displayName: string; + logoProvider?: string; + data?: SettingsResponse; + isLoading: boolean; + hasChanges: boolean; + isRawJsonValid: boolean; + isSaving: boolean; + onRefetch: () => void; + onSave: () => void; +} + +export function ProviderEditorHeader({ + displayName, + logoProvider, + provider, + data, + isLoading, + hasChanges, + isRawJsonValid, + isSaving, + onRefetch, + onSave, +}: ProviderEditorHeaderProps) { + return ( +
+
+ +
+
+

{displayName}

+ {data?.path && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+
+ + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx new file mode 100644 index 00000000..20d9e40f --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -0,0 +1,85 @@ +/** + * Provider Info Tab + * Displays provider information and quick usage commands + */ + +import { Badge } from '@/components/ui/badge'; +import { CopyButton } from '@/components/ui/copy-button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Info, Shield } from 'lucide-react'; +import { UsageCommand } from './usage-command'; +import type { SettingsResponse } from './types'; +import type { AuthStatus } from '@/lib/api-client'; + +interface ProviderInfoTabProps { + provider: string; + displayName: string; + data?: SettingsResponse; + authStatus: AuthStatus; +} + +export function ProviderInfoTab({ provider, displayName, data, authStatus }: ProviderInfoTabProps) { + return ( + +
+ {/* Provider Information */} +
+

+ + Provider Information +

+
+
+ Provider + {displayName} +
+ {data && ( + <> +
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+ Status + {authStatus.authenticated ? ( + + + Authenticated + + ) : ( + + Not connected + + )} +
+
+
+ + {/* Quick Usage */} +
+

Quick Usage

+
+ + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx new file mode 100644 index 00000000..4150fd43 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx @@ -0,0 +1,58 @@ +/** + * Raw Editor Section + * JSON editor panel with validation feedback + */ + +import { lazy, Suspense } from 'react'; +import { Loader2, X } from 'lucide-react'; +import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; +import type { RawEditorSectionProps } from './types'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +export function RawEditorSection({ + rawJsonContent, + isRawJsonValid, + rawJsonEdits, + onRawJsonChange, + profileEnv, +}: RawEditorSectionProps) { + return ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+ + ); +} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts new file mode 100644 index 00000000..4d1d87a6 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -0,0 +1,108 @@ +/** + * Type definitions for ProviderEditor components + */ + +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import type { ProviderCatalog } from '../provider-model-selector'; + +export interface SettingsResponse { + profile: string; + settings: { + env?: Record; + }; + mtime: number; + path: string; +} + +export interface ProviderEditorProps { + provider: string; + displayName: string; + authStatus: AuthStatus; + catalog?: ProviderCatalog; + /** Provider type for logo display (defaults to provider) */ + logoProvider?: string; + onAddAccount: () => void; + onSetDefault: (accountId: string) => void; + onRemoveAccount: (accountId: string) => void; + isRemovingAccount?: boolean; +} + +export interface AccountItemProps { + account: OAuthAccount; + onSetDefault: () => void; + onRemove: () => void; + isRemoving?: boolean; + privacyMode?: boolean; +} + +export interface ModelMappingValues { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +export interface CustomPresetDialogProps { + open: boolean; + onClose: () => void; + currentValues: ModelMappingValues; + onApply: (values: ModelMappingValues, presetName?: string) => void; + onSave?: (values: ModelMappingValues, presetName?: string) => void; + isSaving?: boolean; + catalog?: ProviderCatalog; + allModels: { id: string; owned_by: string }[]; +} + +export interface RawEditorSectionProps { + rawJsonContent: string; + isRawJsonValid: boolean; + rawJsonEdits: string | null; + onRawJsonChange: (value: string) => void; + profileEnv?: Record; +} + +export interface ModelConfigSectionProps { + catalog?: ProviderCatalog; + savedPresets: Array<{ + name: string; + default: string; + opus: string; + sonnet: string; + haiku: string; + }>; + currentModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + providerModels: Array<{ id: string; owned_by: string }>; + onApplyPreset: (updates: Record) => void; + onUpdateEnvValue: (key: string, value: string) => void; + onOpenCustomPreset: () => void; + onDeletePreset: (name: string) => void; + isDeletePending?: boolean; +} + +export interface UseProviderEditorReturn { + data: SettingsResponse | undefined; + isLoading: boolean; + refetch: () => void; + rawJsonContent: string; + rawJsonEdits: string | null; + isRawJsonValid: boolean; + hasChanges: boolean; + currentSettings: { env?: Record }; + currentModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + handleRawJsonChange: (value: string) => void; + updateEnvValue: (key: string, value: string) => void; + updateEnvValues: (updates: Record) => void; + saveMutation: { + mutate: () => void; + isPending: boolean; + }; + conflictDialog: boolean; + setConflictDialog: (open: boolean) => void; + handleConflictResolve: (overwrite: boolean) => Promise; +} diff --git a/ui/src/components/cliproxy/provider-editor/usage-command.tsx b/ui/src/components/cliproxy/provider-editor/usage-command.tsx new file mode 100644 index 00000000..f720cbf8 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/usage-command.tsx @@ -0,0 +1,25 @@ +/** + * Usage Command Component + * Displays a CLI command with copy button + */ + +import { CopyButton } from '@/components/ui/copy-button'; + +interface UsageCommandProps { + label: string; + command: string; +} + +export function UsageCommand({ label, command }: UsageCommandProps) { + return ( +
+ +
+ + {command} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts new file mode 100644 index 00000000..7968a5b2 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -0,0 +1,163 @@ +/** + * useProviderEditor Hook + * Manages query, mutation, and state logic for ProviderEditor + */ + +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import type { SettingsResponse, UseProviderEditorReturn } from './types'; + +export function useProviderEditor(provider: string): UseProviderEditorReturn { + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [conflictDialog, setConflictDialog] = useState(false); + const queryClient = useQueryClient(); + + // Fetch settings for this provider + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', provider], + queryFn: async () => { + const res = await fetch(`/api/settings/${provider}/raw`); + if (!res.ok) { + // Return empty settings for unconfigured providers + return { + profile: provider, + settings: { env: {} }, + mtime: Date.now(), + path: `~/.ccs/profiles/${provider}/settings.json`, + }; + } + return res.json(); + }, + }); + + const settings = data?.settings; + + // Derive raw JSON content + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits; + if (settings) return JSON.stringify(settings, null, 2); + return '{\n "env": {}\n}'; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Parse current settings from JSON + const currentSettings = useMemo(() => { + try { + return JSON.parse(rawJsonContent); + } catch { + return settings || { env: {} }; + } + }, [rawJsonContent, settings]); + + // Extract model values from settings + const currentModel = currentSettings?.env?.ANTHROPIC_MODEL; + const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL; + const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; + const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; + + // Update a single setting value + const updateEnvValue = useCallback( + (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }, + [currentSettings] + ); + + // Batch update multiple env values at once + const updateEnvValues = useCallback( + (updates: Record) => { + const newEnv = { ...(currentSettings?.env || {}), ...updates }; + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }, + [currentSettings] + ); + + // Check if JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check for unsaved changes + const hasChanges = useMemo(() => { + if (rawJsonEdits === null) return false; + return rawJsonEdits !== JSON.stringify(settings, null, 2); + }, [rawJsonEdits, settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + const settingsToSave = JSON.parse(rawJsonContent); + const res = await fetch(`/api/settings/${provider}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) throw new Error('CONFLICT'); + if (!res.ok) throw new Error('Failed to save'); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', provider] }); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setRawJsonEdits(null); + } + }; + + return { + data, + isLoading, + refetch, + rawJsonContent, + rawJsonEdits, + isRawJsonValid, + hasChanges, + currentSettings, + currentModel, + opusModel, + sonnetModel, + haikuModel, + handleRawJsonChange, + updateEnvValue, + updateEnvValues, + saveMutation: { + mutate: () => saveMutation.mutate(), + isPending: saveMutation.isPending, + }, + conflictDialog, + setConflictDialog, + handleConflictResolve, + }; +} diff --git a/ui/src/components/copilot/config-form/header-section.tsx b/ui/src/components/copilot/config-form/header-section.tsx new file mode 100644 index 00000000..f276cff5 --- /dev/null +++ b/ui/src/components/copilot/config-form/header-section.tsx @@ -0,0 +1,82 @@ +/** + * Header Section + * Top header with title, badge, last modified, and action buttons + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Save, RefreshCw } from 'lucide-react'; + +interface RawSettings { + path: string; + exists: boolean; + mtime: number; + settings?: Record; +} + +interface HeaderSectionProps { + rawSettings: RawSettings | undefined; + rawSettingsLoading: boolean; + isUpdating: boolean; + isSavingRawSettings: boolean; + hasChanges: boolean; + isRawJsonValid: boolean; + onRefresh: () => void; + onSave: () => void; +} + +export function HeaderSection({ + rawSettings, + rawSettingsLoading, + isUpdating, + isSavingRawSettings, + hasChanges, + isRawJsonValid, + onRefresh, + onSave, +}: HeaderSectionProps) { + return ( +
+
+
+
+

Copilot Configuration

+ {rawSettings && ( + + copilot.settings.json + + )} +
+ {rawSettings && ( +

+ Last modified:{' '} + {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'} +

+ )} +
+
+
+ + +
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/index.tsx b/ui/src/components/copilot/config-form/index.tsx new file mode 100644 index 00000000..ec95e3c3 --- /dev/null +++ b/ui/src/components/copilot/config-form/index.tsx @@ -0,0 +1,176 @@ +/** + * Copilot Config Form + * + * Form for configuring GitHub Copilot integration settings. + * Split-view layout matching CLIProxy provider editor: + * - Left (50%): Friendly UI with model mapping selectors + * - Right (50%): Raw JSON editor for copilot.settings.json + */ + +/* eslint-disable react-refresh/only-export-components */ +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Code2 } from 'lucide-react'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; + +import { HeaderSection } from './header-section'; +import { ModelConfigTab } from './model-config-tab'; +import { SettingsTab } from './settings-tab'; +import { InfoTab } from './info-tab'; +import { RawEditorSection } from './raw-editor-section'; +import { useCopilotConfigForm } from './use-copilot-config-form'; + +export function CopilotConfigForm() { + const { + configLoading, + rawSettingsLoading, + modelsLoading, + isUpdating, + isSavingRawSettings, + models, + rawSettings, + rawJsonContent, + rawJsonEdits, + enabled, + autoStart, + port, + accountType, + currentModel, + rateLimit, + waitOnLimit, + opusModel, + sonnetModel, + haikuModel, + isRawJsonValid, + hasChanges, + conflictDialog, + updateField, + applyPreset, + handleRawJsonChange, + handleSave, + handleConflictResolve, + refetchRawSettings, + } = useCopilotConfigForm(); + + if (configLoading || rawSettingsLoading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ refetchRawSettings()} + onSave={handleSave} + /> + + {/* Split Layout */} +
+ {/* Left Column: Friendly UI */} +
+
+ +
+ + + Model Config + + + Settings + + + Info + + +
+ +
+ updateField('model', model)} + onUpdateOpusModel={(model) => updateField('opusModel', model)} + onUpdateSonnetModel={(model) => updateField('sonnetModel', model)} + onUpdateHaikuModel={(model) => updateField('haikuModel', model)} + /> + + updateField('enabled', v)} + onUpdateAutoStart={(v) => updateField('autoStart', v)} + onUpdatePort={(v) => updateField('port', v)} + onUpdateAccountType={(v) => updateField('accountType', v)} + onUpdateRateLimit={(v) => updateField('rateLimit', v)} + onUpdateWaitOnLimit={(v) => updateField('waitOnLimit', v)} + /> + + +
+
+
+
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) + +
+ | undefined} + onChange={handleRawJsonChange} + /> +
+
+ + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} + +// Re-export components for external use +export { FlexibleModelSelector } from './model-selector'; +export { UsageCommand } from './usage-command'; +export { FREE_PRESETS, PAID_PRESETS } from './presets'; +export { ModelConfigTab } from './model-config-tab'; +export { SettingsTab } from './settings-tab'; +export { InfoTab } from './info-tab'; +export { RawEditorSection } from './raw-editor-section'; +export { HeaderSection } from './header-section'; +export { useCopilotConfigForm } from './use-copilot-config-form'; +export type { ModelPreset, FlexibleModelSelectorProps } from './types'; diff --git a/ui/src/components/copilot/config-form/info-tab.tsx b/ui/src/components/copilot/config-form/info-tab.tsx new file mode 100644 index 00000000..02973d7b --- /dev/null +++ b/ui/src/components/copilot/config-form/info-tab.tsx @@ -0,0 +1,81 @@ +/** + * Info Tab Content + * Configuration info and quick usage commands + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Badge } from '@/components/ui/badge'; +import { Info } from 'lucide-react'; +import { TabsContent } from '@/components/ui/tabs'; +import { UsageCommand } from './usage-command'; + +interface RawSettings { + path: string; + exists: boolean; + mtime: number; + settings?: Record; +} + +interface InfoTabProps { + rawSettings: RawSettings | undefined; +} + +export function InfoTab({ rawSettings }: InfoTabProps) { + return ( + + +
+
+

+ + Configuration Info +

+
+
+ Provider + GitHub Copilot +
+ {rawSettings && ( + <> +
+ File Path +
+ + {rawSettings.path} + + +
+
+
+ Status + + {rawSettings.exists ? 'File exists' : 'Using defaults'} + +
+ + )} +
+
+ +
+

Quick Usage

+
+ + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/model-config-tab.tsx b/ui/src/components/copilot/config-form/model-config-tab.tsx new file mode 100644 index 00000000..ab53250d --- /dev/null +++ b/ui/src/components/copilot/config-form/model-config-tab.tsx @@ -0,0 +1,167 @@ +/** + * Model Config Tab Content + * Presets and model mapping configuration + */ + +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; +import { Sparkles, Zap } from 'lucide-react'; +import { TabsContent } from '@/components/ui/tabs'; +import type { CopilotModel } from '@/hooks/use-copilot'; +import { FREE_PRESETS, PAID_PRESETS } from './presets'; +import { FlexibleModelSelector } from './model-selector'; +import type { ModelPreset } from './types'; + +interface ModelConfigTabProps { + currentModel: string; + opusModel: string; + sonnetModel: string; + haikuModel: string; + models: CopilotModel[]; + modelsLoading: boolean; + onApplyPreset: (preset: ModelPreset) => void; + onUpdateModel: (model: string) => void; + onUpdateOpusModel: (model: string) => void; + onUpdateSonnetModel: (model: string) => void; + onUpdateHaikuModel: (model: string) => void; +} + +export function ModelConfigTab({ + currentModel, + opusModel, + sonnetModel, + haikuModel, + models, + modelsLoading, + onApplyPreset, + onUpdateModel, + onUpdateOpusModel, + onUpdateSonnetModel, + onUpdateHaikuModel, +}: ModelConfigTabProps) { + return ( + + +
+ {/* Quick Presets */} +
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+ + {/* Free Tier Presets */} +
+
+ + Free Tier + + No premium usage count +
+
+ {FREE_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Paid Tier Presets */} +
+
+ + Pro+ Required + + + Uses premium request quota + +
+
+ {PAID_PRESETS.map((preset) => ( + + ))} +
+
+
+ + + + {/* Model Mapping */} +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx new file mode 100644 index 00000000..27638b42 --- /dev/null +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -0,0 +1,91 @@ +/** + * Flexible Model Selector Component + * Dropdown selector for Copilot models with plan badges + */ + +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/components/ui/select'; +import { Check } from 'lucide-react'; +import type { FlexibleModelSelectorProps } from './types'; +import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; + +export function FlexibleModelSelector({ + label, + description, + value, + onChange, + models, + disabled, +}: FlexibleModelSelectorProps) { + // Find current model for display + const currentModel = models.find((m) => m.id === value); + + return ( +
+
+ + {description &&

{description}

} +
+ +
+ ); +} diff --git a/ui/src/components/copilot/config-form/presets.ts b/ui/src/components/copilot/config-form/presets.ts new file mode 100644 index 00000000..e7aad884 --- /dev/null +++ b/ui/src/components/copilot/config-form/presets.ts @@ -0,0 +1,77 @@ +/** + * Model Presets for Copilot Configuration + * Grouped by tier: Free (available to all) and Paid (requires Pro+) + */ + +import type { ModelPreset } from './types'; + +// Note: ALL Claude models require paid Copilot subscription +export const FREE_PRESETS: ModelPreset[] = [ + { + name: 'GPT-4.1 (Free)', + description: 'Free tier - no premium usage', + default: 'gpt-4.1', + opus: 'gpt-4.1', + sonnet: 'gpt-4.1', + haiku: 'gpt-4.1', + }, + { + name: 'GPT-5 Mini (Free)', + description: 'Free tier - lightweight model', + default: 'gpt-5-mini', + opus: 'gpt-5-mini', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', + }, + { + name: 'Raptor Mini (Free)', + description: 'Free tier - fine-tuned for coding', + default: 'raptor-mini', + opus: 'raptor-mini', + sonnet: 'raptor-mini', + haiku: 'raptor-mini', + }, +]; + +export const PAID_PRESETS: ModelPreset[] = [ + { + name: 'Claude Opus 4.5', + description: 'Pro+ (3x) - Most capable reasoning', + default: 'claude-opus-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + { + name: 'Claude Sonnet 4.5', + description: 'Pro+ (1x) - Balanced performance', + default: 'claude-sonnet-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + { + name: 'GPT-5.2', + description: 'Pro+ (1x) - Latest OpenAI (Preview)', + default: 'gpt-5.2', + opus: 'gpt-5.2', + sonnet: 'gpt-5.1', + haiku: 'gpt-5-mini', + }, + { + name: 'GPT-5.1 Codex Max', + description: 'Pro+ (1x) - Best for coding', + default: 'gpt-5.1-codex-max', + opus: 'gpt-5.1-codex-max', + sonnet: 'gpt-5.1-codex', + haiku: 'gpt-5.1-codex-mini', + }, + { + name: 'Gemini 2.5 Pro', + description: 'Pro+ (1x) - Google latest', + default: 'gemini-2.5-pro', + opus: 'gemini-2.5-pro', + sonnet: 'gemini-2.5-pro', + haiku: 'gemini-3-flash', + }, +]; diff --git a/ui/src/components/copilot/config-form/raw-editor-section.tsx b/ui/src/components/copilot/config-form/raw-editor-section.tsx new file mode 100644 index 00000000..8c0694d6 --- /dev/null +++ b/ui/src/components/copilot/config-form/raw-editor-section.tsx @@ -0,0 +1,65 @@ +/** + * Raw Editor Section + * JSON editor panel for copilot settings + */ + +import { Suspense, lazy } from 'react'; +import { Loader2, X } from 'lucide-react'; +import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface RawEditorSectionProps { + rawJsonContent: string; + isRawJsonValid: boolean; + rawJsonEdits: string | null; + rawSettingsEnv: Record | undefined; + onChange: (value: string) => void; +} + +export function RawEditorSection({ + rawJsonContent, + isRawJsonValid, + rawJsonEdits, + rawSettingsEnv, + onChange, +}: RawEditorSectionProps) { + return ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+ + ); +} diff --git a/ui/src/components/copilot/config-form/settings-tab.tsx b/ui/src/components/copilot/config-form/settings-tab.tsx new file mode 100644 index 00000000..6f630422 --- /dev/null +++ b/ui/src/components/copilot/config-form/settings-tab.tsx @@ -0,0 +1,167 @@ +/** + * Settings Tab Content + * Enable toggle, port, account type, rate limiting, daemon settings + */ + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { TabsContent } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface SettingsTabProps { + enabled: boolean; + autoStart: boolean; + port: number; + accountType: 'individual' | 'business' | 'enterprise'; + rateLimit: string; + waitOnLimit: boolean; + onUpdateEnabled: (value: boolean) => void; + onUpdateAutoStart: (value: boolean) => void; + onUpdatePort: (value: number) => void; + onUpdateAccountType: (value: 'individual' | 'business' | 'enterprise') => void; + onUpdateRateLimit: (value: string) => void; + onUpdateWaitOnLimit: (value: boolean) => void; +} + +export function SettingsTab({ + enabled, + autoStart, + port, + accountType, + rateLimit, + waitOnLimit, + onUpdateEnabled, + onUpdateAutoStart, + onUpdatePort, + onUpdateAccountType, + onUpdateRateLimit, + onUpdateWaitOnLimit, +}: SettingsTabProps) { + return ( + + +
+ {/* Enable Toggle */} +
+
+ +

+ Allow using GitHub Copilot subscription +

+
+ +
+ + {/* Basic Settings */} +
+

Basic Settings

+ + {/* Port */} +
+ + onUpdatePort(parseInt(e.target.value, 10))} + min={1024} + max={65535} + className="max-w-[150px] h-8" + /> +
+ + {/* Account Type */} +
+ + +
+
+ + + + {/* Rate Limiting */} +
+

Rate Limiting

+ +
+ + onUpdateRateLimit(e.target.value)} + placeholder="No limit" + min={0} + className="max-w-[150px] h-8" + /> +
+ +
+
+ +

+ Wait instead of error when limit hit +

+
+ +
+
+ + + + {/* Daemon Settings */} +
+

Daemon Settings

+ +
+
+ +

+ Start copilot-api when using profile +

+
+ +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/types.ts b/ui/src/components/copilot/config-form/types.ts new file mode 100644 index 00000000..7c903c7b --- /dev/null +++ b/ui/src/components/copilot/config-form/types.ts @@ -0,0 +1,25 @@ +/** + * Types for Copilot Config Form + */ + +import type { CopilotModel, CopilotPlanTier } from '@/hooks/use-copilot'; + +export interface FlexibleModelSelectorProps { + label: string; + description?: string; + value: string | undefined; + onChange: (model: string) => void; + models: CopilotModel[]; + disabled?: boolean; +} + +export interface ModelPreset { + name: string; + description: string; + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +export type { CopilotModel, CopilotPlanTier }; diff --git a/ui/src/components/copilot/config-form/usage-command.tsx b/ui/src/components/copilot/config-form/usage-command.tsx new file mode 100644 index 00000000..2309af79 --- /dev/null +++ b/ui/src/components/copilot/config-form/usage-command.tsx @@ -0,0 +1,25 @@ +/** + * Usage Command Component + * Displays a command with copy button + */ + +import { CopyButton } from '@/components/ui/copy-button'; + +interface UsageCommandProps { + label: string; + command: string; +} + +export function UsageCommand({ label, command }: UsageCommandProps) { + return ( +
+ +
+ + {command} + + +
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/use-copilot-config-form.ts b/ui/src/components/copilot/config-form/use-copilot-config-form.ts new file mode 100644 index 00000000..2b6d6cb6 --- /dev/null +++ b/ui/src/components/copilot/config-form/use-copilot-config-form.ts @@ -0,0 +1,193 @@ +/** + * Copilot Config Form Hook + * State management for the copilot config form + */ + +import { useState, useMemo, useCallback } from 'react'; +import { useCopilot } from '@/hooks/use-copilot'; +import { toast } from 'sonner'; +import type { ModelPreset } from './types'; + +export function useCopilotConfigForm() { + const { + config, + configLoading, + models, + modelsLoading, + rawSettings, + rawSettingsLoading, + updateConfigAsync, + isUpdating, + saveRawSettingsAsync, + isSavingRawSettings, + refetchRawSettings, + } = useCopilot(); + + // Track local overrides for form fields + const [localOverrides, setLocalOverrides] = useState<{ + enabled?: boolean; + autoStart?: boolean; + port?: number; + accountType?: 'individual' | 'business' | 'enterprise'; + model?: string; + rateLimit?: string; + waitOnLimit?: boolean; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + }>({}); + + // Raw JSON editor state + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [conflictDialog, setConflictDialog] = useState(false); + + // Use local overrides if set, otherwise use config values + const enabled = localOverrides.enabled ?? config?.enabled ?? false; + const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false; + const port = localOverrides.port ?? config?.port ?? 4141; + const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual'; + const currentModel = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514'; + const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? ''; + const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true; + const opusModel = localOverrides.opusModel ?? config?.opus_model ?? ''; + const sonnetModel = localOverrides.sonnetModel ?? config?.sonnet_model ?? ''; + const haikuModel = localOverrides.haikuModel ?? config?.haiku_model ?? ''; + + const updateField = ( + key: K, + value: (typeof localOverrides)[K] + ) => { + setLocalOverrides((prev) => ({ ...prev, [key]: value })); + }; + + // Batch update for presets + const applyPreset = (preset: ModelPreset) => { + setLocalOverrides((prev) => ({ + ...prev, + model: preset.default, + opusModel: preset.opus, + sonnetModel: preset.sonnet, + haikuModel: preset.haiku, + })); + toast.success(`Applied "${preset.name}" preset`); + }; + + // Raw JSON content + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits; + if (rawSettings?.settings) return JSON.stringify(rawSettings.settings, null, 2); + return '{\n "env": {}\n}'; + }, [rawJsonEdits, rawSettings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Check if JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check for unsaved changes + const hasChanges = useMemo(() => { + const hasLocalChanges = Object.keys(localOverrides).length > 0; + const hasJsonChanges = + rawJsonEdits !== null && rawJsonEdits !== JSON.stringify(rawSettings?.settings, null, 2); + return hasLocalChanges || hasJsonChanges; + }, [localOverrides, rawJsonEdits, rawSettings]); + + const handleSave = async () => { + try { + // Save config changes + if (Object.keys(localOverrides).length > 0) { + await updateConfigAsync({ + enabled, + auto_start: autoStart, + port, + account_type: accountType, + model: currentModel, + rate_limit: rateLimit ? parseInt(rateLimit, 10) : null, + wait_on_limit: waitOnLimit, + opus_model: opusModel || undefined, + sonnet_model: sonnetModel || undefined, + haiku_model: haikuModel || undefined, + }); + } + + // Save raw JSON changes + if (rawJsonEdits !== null && isRawJsonValid) { + const settingsToSave = JSON.parse(rawJsonContent); + await saveRawSettingsAsync({ + settings: settingsToSave, + expectedMtime: rawSettings?.mtime, + }); + } + + // Clear local state + setLocalOverrides({}); + setRawJsonEdits(null); + toast.success('Copilot configuration saved'); + } catch (error) { + if ((error as Error).message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error('Failed to save settings'); + } + } + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetchRawSettings(); + handleSave(); + } else { + setRawJsonEdits(null); + } + }; + + return { + // Loading states + configLoading, + rawSettingsLoading, + modelsLoading, + isUpdating, + isSavingRawSettings, + + // Data + models, + rawSettings, + rawJsonContent, + rawJsonEdits, + + // Computed values + enabled, + autoStart, + port, + accountType, + currentModel, + rateLimit, + waitOnLimit, + opusModel, + sonnetModel, + haikuModel, + isRawJsonValid, + hasChanges, + + // Dialog state + conflictDialog, + + // Actions + updateField, + applyPreset, + handleRawJsonChange, + handleSave, + handleConflictResolve, + refetchRawSettings, + }; +} diff --git a/ui/src/components/copilot/config-form/utils.ts b/ui/src/components/copilot/config-form/utils.ts new file mode 100644 index 00000000..7fb71aad --- /dev/null +++ b/ui/src/components/copilot/config-form/utils.ts @@ -0,0 +1,32 @@ +/** + * Utility functions for Copilot Config Form + */ + +import type { CopilotPlanTier } from './types'; + +/** Get badge style for plan tier */ +export function getPlanBadgeStyle(plan?: CopilotPlanTier): string { + switch (plan) { + case 'free': + return 'bg-green-100 text-green-700 border-green-200'; + case 'pro': + return 'bg-blue-100 text-blue-700 border-blue-200'; + case 'pro+': + return 'bg-purple-100 text-purple-700 border-purple-200'; + case 'business': + return 'bg-orange-100 text-orange-700 border-orange-200'; + case 'enterprise': + return 'bg-red-100 text-red-700 border-red-200'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +/** Get multiplier display */ +export function getMultiplierDisplay(multiplier?: number): string | null { + if (multiplier === undefined || multiplier === null) return null; + if (multiplier === 0) return 'Free'; + if (multiplier < 1) return `${multiplier}x`; + if (multiplier === 1) return '1x'; + return `${multiplier}x`; +} diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 2311c6b2..e8aa108d 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -1,846 +1,15 @@ /** - * Copilot Config Form - * - * Form for configuring GitHub Copilot integration settings. - * Split-view layout matching CLIProxy provider editor: - * - Left (50%): Friendly UI with model mapping selectors - * - Right (50%): Raw JSON editor for copilot.settings.json + * Copilot Config Form - Re-export from modular structure + * @deprecated Import from '@/components/copilot/config-form' directly */ -import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { Separator } from '@/components/ui/separator'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectGroup, - SelectLabel, -} from '@/components/ui/select'; -import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use-copilot'; -import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; -import { toast } from 'sonner'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { GlobalEnvIndicator } from '@/components/global-env-indicator'; +/* eslint-disable react-refresh/only-export-components */ +export { + CopilotConfigForm, + FlexibleModelSelector, + UsageCommand, + FREE_PRESETS, + PAID_PRESETS, +} from './config-form'; -// Lazy load CodeEditor -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -// Model presets for quick configuration -// Grouped by tier: Free (available to all) and Paid (requires Pro+) -// Note: ALL Claude models require paid Copilot subscription -const FREE_PRESETS = [ - { - name: 'GPT-4.1 (Free)', - description: 'Free tier - no premium usage', - default: 'gpt-4.1', - opus: 'gpt-4.1', - sonnet: 'gpt-4.1', - haiku: 'gpt-4.1', - }, - { - name: 'GPT-5 Mini (Free)', - description: 'Free tier - lightweight model', - default: 'gpt-5-mini', - opus: 'gpt-5-mini', - sonnet: 'gpt-5-mini', - haiku: 'gpt-5-mini', - }, - { - name: 'Raptor Mini (Free)', - description: 'Free tier - fine-tuned for coding', - default: 'raptor-mini', - opus: 'raptor-mini', - sonnet: 'raptor-mini', - haiku: 'raptor-mini', - }, -]; - -const PAID_PRESETS = [ - { - name: 'Claude Opus 4.5', - description: 'Pro+ (3x) - Most capable reasoning', - default: 'claude-opus-4.5', - opus: 'claude-opus-4.5', - sonnet: 'claude-sonnet-4.5', - haiku: 'claude-haiku-4.5', - }, - { - name: 'Claude Sonnet 4.5', - description: 'Pro+ (1x) - Balanced performance', - default: 'claude-sonnet-4.5', - opus: 'claude-opus-4.5', - sonnet: 'claude-sonnet-4.5', - haiku: 'claude-haiku-4.5', - }, - { - name: 'GPT-5.2', - description: 'Pro+ (1x) - Latest OpenAI (Preview)', - default: 'gpt-5.2', - opus: 'gpt-5.2', - sonnet: 'gpt-5.1', - haiku: 'gpt-5-mini', - }, - { - name: 'GPT-5.1 Codex Max', - description: 'Pro+ (1x) - Best for coding', - default: 'gpt-5.1-codex-max', - opus: 'gpt-5.1-codex-max', - sonnet: 'gpt-5.1-codex', - haiku: 'gpt-5.1-codex-mini', - }, - { - name: 'Gemini 2.5 Pro', - description: 'Pro+ (1x) - Google latest', - default: 'gemini-2.5-pro', - opus: 'gemini-2.5-pro', - sonnet: 'gemini-2.5-pro', - haiku: 'gemini-3-flash', - }, -]; - -interface FlexibleModelSelectorProps { - label: string; - description?: string; - value: string | undefined; - onChange: (model: string) => void; - models: CopilotModel[]; - disabled?: boolean; -} - -/** Get badge style for plan tier */ -function getPlanBadgeStyle(plan?: CopilotPlanTier): string { - switch (plan) { - case 'free': - return 'bg-green-100 text-green-700 border-green-200'; - case 'pro': - return 'bg-blue-100 text-blue-700 border-blue-200'; - case 'pro+': - return 'bg-purple-100 text-purple-700 border-purple-200'; - case 'business': - return 'bg-orange-100 text-orange-700 border-orange-200'; - case 'enterprise': - return 'bg-red-100 text-red-700 border-red-200'; - default: - return 'bg-muted text-muted-foreground'; - } -} - -/** Get multiplier display */ -function getMultiplierDisplay(multiplier?: number): string | null { - if (multiplier === undefined || multiplier === null) return null; - if (multiplier === 0) return 'Free'; - if (multiplier < 1) return `${multiplier}x`; - if (multiplier === 1) return '1x'; - return `${multiplier}x`; -} - -function FlexibleModelSelector({ - label, - description, - value, - onChange, - models, - disabled, -}: FlexibleModelSelectorProps) { - // Find current model for display - const currentModel = models.find((m) => m.id === value); - - return ( -
-
- - {description &&

{description}

} -
- -
- ); -} - -export function CopilotConfigForm() { - const { - config, - configLoading, - models, - modelsLoading, - rawSettings, - rawSettingsLoading, - updateConfigAsync, - isUpdating, - saveRawSettingsAsync, - isSavingRawSettings, - refetchRawSettings, - } = useCopilot(); - - // Track local overrides for form fields - const [localOverrides, setLocalOverrides] = useState<{ - enabled?: boolean; - autoStart?: boolean; - port?: number; - accountType?: 'individual' | 'business' | 'enterprise'; - model?: string; - rateLimit?: string; - waitOnLimit?: boolean; - opusModel?: string; - sonnetModel?: string; - haikuModel?: string; - }>({}); - - // Raw JSON editor state - const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [conflictDialog, setConflictDialog] = useState(false); - - // Use local overrides if set, otherwise use config values - const enabled = localOverrides.enabled ?? config?.enabled ?? false; - const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false; - const port = localOverrides.port ?? config?.port ?? 4141; - const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual'; - const currentModel = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514'; - const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? ''; - const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true; - const opusModel = localOverrides.opusModel ?? config?.opus_model ?? ''; - const sonnetModel = localOverrides.sonnetModel ?? config?.sonnet_model ?? ''; - const haikuModel = localOverrides.haikuModel ?? config?.haiku_model ?? ''; - - const updateField = ( - key: K, - value: (typeof localOverrides)[K] - ) => { - setLocalOverrides((prev) => ({ ...prev, [key]: value })); - }; - - // Batch update for presets - const applyPreset = (preset: (typeof FREE_PRESETS)[0] | (typeof PAID_PRESETS)[0]) => { - setLocalOverrides((prev) => ({ - ...prev, - model: preset.default, - opusModel: preset.opus, - sonnetModel: preset.sonnet, - haikuModel: preset.haiku, - })); - toast.success(`Applied "${preset.name}" preset`); - }; - - // Raw JSON content - const rawJsonContent = useMemo(() => { - if (rawJsonEdits !== null) return rawJsonEdits; - if (rawSettings?.settings) return JSON.stringify(rawSettings.settings, null, 2); - return '{\n "env": {}\n}'; - }, [rawJsonEdits, rawSettings]); - - const handleRawJsonChange = useCallback((value: string) => { - setRawJsonEdits(value); - }, []); - - // Check if JSON is valid - const isRawJsonValid = useMemo(() => { - try { - JSON.parse(rawJsonContent); - return true; - } catch { - return false; - } - }, [rawJsonContent]); - - // Check for unsaved changes - const hasChanges = useMemo(() => { - const hasLocalChanges = Object.keys(localOverrides).length > 0; - const hasJsonChanges = - rawJsonEdits !== null && rawJsonEdits !== JSON.stringify(rawSettings?.settings, null, 2); - return hasLocalChanges || hasJsonChanges; - }, [localOverrides, rawJsonEdits, rawSettings]); - - const handleSave = async () => { - try { - // Save config changes - if (Object.keys(localOverrides).length > 0) { - await updateConfigAsync({ - enabled, - auto_start: autoStart, - port, - account_type: accountType, - model: currentModel, - rate_limit: rateLimit ? parseInt(rateLimit, 10) : null, - wait_on_limit: waitOnLimit, - opus_model: opusModel || undefined, - sonnet_model: sonnetModel || undefined, - haiku_model: haikuModel || undefined, - }); - } - - // Save raw JSON changes - if (rawJsonEdits !== null && isRawJsonValid) { - const settingsToSave = JSON.parse(rawJsonContent); - await saveRawSettingsAsync({ - settings: settingsToSave, - expectedMtime: rawSettings?.mtime, - }); - } - - // Clear local state - setLocalOverrides({}); - setRawJsonEdits(null); - toast.success('Copilot configuration saved'); - } catch (error) { - if ((error as Error).message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error('Failed to save settings'); - } - } - }; - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - await refetchRawSettings(); - handleSave(); - } else { - setRawJsonEdits(null); - } - }; - - if (configLoading || rawSettingsLoading) { - return ( -
- - - - -
- ); - } - - // Left column: Friendly UI - const renderFriendlyUI = () => ( -
- -
- - - Model Config - - - Settings - - - Info - - -
- -
- {/* Model Config Tab */} - - -
- {/* Quick Presets */} -
-

- - Presets -

-

- Apply pre-configured model mappings -

- - {/* Free Tier Presets */} -
-
- - Free Tier - - - No premium usage count - -
-
- {FREE_PRESETS.map((preset) => ( - - ))} -
-
- - {/* Paid Tier Presets */} -
-
- - Pro+ Required - - - Uses premium request quota - -
-
- {PAID_PRESETS.map((preset) => ( - - ))} -
-
-
- - - - {/* Model Mapping */} -
-

Model Mapping

-

- Configure which models to use for each tier -

-
- updateField('model', model)} - models={models} - disabled={modelsLoading} - /> - updateField('opusModel', model)} - models={models} - disabled={modelsLoading} - /> - updateField('sonnetModel', model)} - models={models} - disabled={modelsLoading} - /> - updateField('haikuModel', model)} - models={models} - disabled={modelsLoading} - /> -
-
-
-
-
- - {/* Settings Tab */} - - -
- {/* Enable Toggle */} -
-
- -

- Allow using GitHub Copilot subscription -

-
- updateField('enabled', v)} - /> -
- - {/* Basic Settings */} -
-

Basic Settings

- - {/* Port */} -
- - updateField('port', parseInt(e.target.value, 10))} - min={1024} - max={65535} - className="max-w-[150px] h-8" - /> -
- - {/* Account Type */} -
- - -
-
- - - - {/* Rate Limiting */} -
-

Rate Limiting

- -
- - updateField('rateLimit', e.target.value)} - placeholder="No limit" - min={0} - className="max-w-[150px] h-8" - /> -
- -
-
- -

- Wait instead of error when limit hit -

-
- updateField('waitOnLimit', v)} - /> -
-
- - - - {/* Daemon Settings */} -
-

Daemon Settings

- -
-
- -

- Start copilot-api when using profile -

-
- updateField('autoStart', v)} - /> -
-
-
-
-
- - {/* Info Tab */} - - -
-
-

- - Configuration Info -

-
-
- Provider - GitHub Copilot -
- {rawSettings && ( - <> -
- File Path -
- - {rawSettings.path} - - -
-
-
- Status - - {rawSettings.exists ? 'File exists' : 'Using defaults'} - -
- - )} -
-
- -
-

Quick Usage

-
- - - - -
-
-
-
-
-
-
-
- ); - - // Right column: Raw JSON Editor - const renderRawEditor = () => ( - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
- - ); - - return ( -
- {/* Header */} -
-
-
-
-

Copilot Configuration

- {rawSettings && ( - - copilot.settings.json - - )} -
- {rawSettings && ( -

- Last modified:{' '} - {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'} -

- )} -
-
-
- - -
-
- - {/* Split Layout - Left panel constrained, Right panel flexible */} -
- {/* Left Column: Friendly UI - constrained width */} -
- {renderFriendlyUI()} -
- - {/* Right Column: Raw Editor - takes remaining space */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> -
- ); -} - -/** Usage command with copy button */ -function UsageCommand({ label, command }: { label: string; command: string }) { - return ( -
- -
- - {command} - - -
-
- ); -} +export type { ModelPreset, FlexibleModelSelectorProps } from './config-form'; diff --git a/ui/src/components/copilot/index.ts b/ui/src/components/copilot/index.ts new file mode 100644 index 00000000..b1c174f5 --- /dev/null +++ b/ui/src/components/copilot/index.ts @@ -0,0 +1,10 @@ +/** + * Copilot Components Barrel Export + */ + +// Main copilot components +export { CopilotStatusCard } from './copilot-status-card'; + +// Config form (from subdirectory) +export { CopilotConfigForm } from './config-form'; +export type { ModelPreset, FlexibleModelSelectorProps } from './config-form'; diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx index eb704397..6974ec81 100644 --- a/ui/src/components/error-logs-monitor.tsx +++ b/ui/src/components/error-logs-monitor.tsx @@ -1,617 +1,18 @@ /** - * Error Logs Monitor Component - * - * Displays CLIProxyAPI error logs with master-detail split view. - * ETL: Parses raw logs into structured data for rich display. - * - Left panel: Log list with status code, provider, endpoint, relative time - * - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw) + * Error Logs Monitor - Re-export from modular structure + * @deprecated Import from '@/components/monitoring/error-logs' directly */ -import { useState, useMemo, useRef, useEffect } from 'react'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; -import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; -import { cn } from '@/lib/utils'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { ProviderIcon } from '@/components/provider-icon'; -import { CopyButton } from '@/components/ui/copy-button'; -import { - AlertTriangle, - FileWarning, - Clock, - FileText, - Terminal, - Info, - Code, - ArrowUpRight, - ArrowDownLeft, - GripVertical, - GripHorizontal, -} from 'lucide-react'; -import { - parseErrorLog, - parseFilename, - formatRelativeTime, - formatBytes, - getStatusColor, - getErrorTypeLabel, - type ParsedErrorLog, -} from '@/lib/error-log-parser'; +export { + ErrorLogsMonitor, + ErrorLogItem, + LogContentPanel, + TabButton, + StatusBadge, + OverviewTab, + HeadersTab, + BodyTab, + RawTab, +} from './monitoring/error-logs'; -type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw'; - -/** Tab button component */ -function TabButton({ - active, - onClick, - children, - icon: Icon, -}: { - active: boolean; - onClick: () => void; - children: React.ReactNode; - icon?: React.ComponentType<{ className?: string }>; -}) { - return ( - - ); -} - -/** Status badge component */ -function StatusBadge({ code }: { code: number }) { - const colorClass = getStatusColor(code); - return ( - - {code} - - ); -} - -/** Overview tab content */ -function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { - return ( -
- {/* Status row */} -
- - {parsed.statusText} - - {getErrorTypeLabel(parsed.errorType)} - -
- - {/* Key metrics grid */} -
-
-
Method
-
{parsed.method || 'N/A'}
-
-
-
Provider
-
{parsed.provider || 'N/A'}
-
-
-
Version
-
{parsed.version || 'N/A'}
-
-
-
Endpoint
-
- {parsed.endpoint || 'N/A'} -
-
-
- - {/* URL */} -
-
URL
-
- {parsed.url || 'N/A'} -
-
- - {/* Timestamp */} -
-
Timestamp
-
{parsed.timestamp || 'N/A'}
-
- - {/* Suggestion based on error type */} - {parsed.errorType !== 'unknown' && ( -
- -
- {parsed.errorType === 'rate_limit' && - 'Rate limited. Consider using multiple accounts or reducing request frequency.'} - {parsed.errorType === 'auth' && - 'Authentication failed. Check credentials or re-authenticate with the provider.'} - {parsed.errorType === 'not_found' && - 'Endpoint not found. This endpoint may not exist on this provider.'} - {parsed.errorType === 'server' && - 'Server error from upstream. Retry or check provider status.'} - {parsed.errorType === 'timeout' && - 'Request timed out. Check network or increase timeout settings.'} -
-
- )} -
- ); -} - -/** Headers tab content */ -function HeadersTab({ headers }: { headers: Record }) { - const entries = Object.entries(headers); - if (entries.length === 0) { - return
No headers available
; - } - - return ( - -
- {entries.map(([key, value]) => ( -
- {key}: - {value} -
- ))} -
-
- ); -} - -/** JSON/Body tab content */ -function BodyTab({ content, label }: { content: string; label: string }) { - if (!content || content.trim() === '') { - return
No {label.toLowerCase()} body
; - } - - // Try to format as JSON - let formatted = content; - let isJson = false; - try { - const parsed = JSON.parse(content); - formatted = JSON.stringify(parsed, null, 2); - isJson = true; - } catch { - // Not JSON, use as-is - } - - return ( - -
-        {formatted}
-      
-
- ); -} - -/** Raw tab content */ -function RawTab({ content }: { content: string }) { - return ( - -
-        {content}
-      
-
- ); -} - -/** Log content panel with tabs */ -function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) { - const [activeTab, setActiveTab] = useState('overview'); - const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); - - // Parse log content - const parsed = useMemo(() => { - if (!content) return null; - return parseErrorLog(content); - }, [content]); - - // No log selected - if (!name) { - return ( -
-
- -

Select a log to view details

-
-
- ); - } - - // Loading state - if (isLoading) { - return ( -
- - - - -
- ); - } - - // Error or no content - if (error || !content || !parsed) { - return ( -
-

Failed to load log content

-
- ); - } - - return ( -
- {/* Header with status */} -
-
- - - {parsed.provider}/{parsed.endpoint || 'unknown'} - - {/* Copy Absolute Path Button */} - {name && ( - - )} -
-
- {/* Copy Raw Content Button */} - {content && ( - - )} - - {parsed.method} - -
-
- - {/* Tabs */} -
- setActiveTab('overview')} - icon={Info} - > - Overview - - setActiveTab('headers')} - icon={Code} - > - Headers - - setActiveTab('request')} - icon={ArrowUpRight} - > - Request - - setActiveTab('response')} - icon={ArrowDownLeft} - > - Response - - setActiveTab('raw')} icon={FileText}> - Raw - -
- - {/* Tab content */} -
- {activeTab === 'overview' && } - {activeTab === 'headers' && } - {activeTab === 'request' && } - {activeTab === 'response' && } - {activeTab === 'raw' && } -
-
- ); -} - -/** Error log item in the list */ -interface ErrorLogItemProps { - name: string; - size: number; - modified: number; - isSelected: boolean; - onClick: () => void; -} - -function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { - const parsed = useMemo(() => parseFilename(name), [name]); - - return ( - - ); -} - -export function ErrorLogsMonitor() { - const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); - const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); - - // Vertical resize state - const [height, setHeight] = useState(500); - const [isResizing, setIsResizing] = useState(false); - const containerRef = useRef(null); - const scrollIntervalRef = useRef(null); - - // Auto-scroll handler - const stopAutoScroll = () => { - if (scrollIntervalRef.current) { - clearInterval(scrollIntervalRef.current); - scrollIntervalRef.current = null; - } - }; - - // Resize handlers - useEffect(() => { - if (!isResizing) return; - - const handleMouseMove = (e: MouseEvent) => { - const container = containerRef.current; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const containerTopDoc = rect.top + window.scrollY; - const newHeight = e.pageY - containerTopDoc; - - // Constrain height (min 300, no max) - setHeight(Math.max(300, newHeight)); - - // Auto-scroll logic - const viewportHeight = window.innerHeight; - const distFromBottom = viewportHeight - e.clientY; - const scrollSpeed = 15; - - stopAutoScroll(); - - if (distFromBottom < 50) { - scrollIntervalRef.current = setInterval(() => { - window.scrollBy(0, scrollSpeed); - }, 16); - } else if (e.clientY < 50) { - scrollIntervalRef.current = setInterval(() => { - window.scrollBy(0, -scrollSpeed); - }, 16); - } - }; - - const handleMouseUp = () => { - setIsResizing(false); - stopAutoScroll(); - document.body.style.cursor = 'default'; - document.body.style.userSelect = 'auto'; - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'default'; - document.body.style.userSelect = 'auto'; - stopAutoScroll(); - }; - }, [isResizing]); - - const startResizing = (e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - }; - - // Compute default selection (first log name or null) - const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]); - - // Use controlled selection that defaults to first log - const [selectedLog, setSelectedLog] = useState(null); - - // Effective selection: use user selection if available, otherwise default - const effectiveSelection = selectedLog ?? defaultLogName; - - // Get absolute path for the selected log - const selectedAbsolutePath = useMemo(() => { - if (!effectiveSelection || !logs) return undefined; - const log = logs.find((l) => l.name === effectiveSelection); - return log?.absolutePath; - }, [effectiveSelection, logs]); - - // Guards - if (isStatusLoading) return null; - if (!status?.running) return null; - if (isLoading) { - return ( -
-
- - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
-
- ); - } - if (!logs || logs.length === 0) return null; - - const errorCount = logs.length; - - return ( -
- {/* Header */} -
-
- - Error Logs - - {errorCount} failed request{errorCount !== 1 ? 's' : ''} - -
-
- - CLIProxy Diagnostics -
-
- - {/* Resizable Panel Layout */} -
- - {/* Left Panel: Log List */} - - -
- {logs.slice(0, 50).map((log) => ( - setSelectedLog(log.name)} - /> - ))} -
- {logs.length > 50 && ( -
- Showing 50 of {logs.length} logs -
- )} -
-
- - {/* Resize Handle */} - -
-
- -
- - - {/* Right Panel: Log Content */} - - - - -
- - {/* Use standard footer if error, otherwise show resize handle */} - {error ? ( -
- {error.message} -
- ) : ( -
- -
- )} -
- ); -} +export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './monitoring/error-logs'; diff --git a/ui/src/components/health-card.tsx b/ui/src/components/health/health-card.tsx similarity index 100% rename from ui/src/components/health-card.tsx rename to ui/src/components/health/health-card.tsx diff --git a/ui/src/components/health-check-item.tsx b/ui/src/components/health/health-check-item.tsx similarity index 100% rename from ui/src/components/health-check-item.tsx rename to ui/src/components/health/health-check-item.tsx diff --git a/ui/src/components/health-gauge.tsx b/ui/src/components/health/health-gauge.tsx similarity index 100% rename from ui/src/components/health-gauge.tsx rename to ui/src/components/health/health-gauge.tsx diff --git a/ui/src/components/health-group-section.tsx b/ui/src/components/health/health-group-section.tsx similarity index 98% rename from ui/src/components/health-group-section.tsx rename to ui/src/components/health/health-group-section.tsx index 9b0e4d30..1f7f1740 100644 --- a/ui/src/components/health-group-section.tsx +++ b/ui/src/components/health/health-group-section.tsx @@ -1,6 +1,6 @@ import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { HealthCheckItem } from '@/components/health-check-item'; +import { HealthCheckItem } from './health-check-item'; import { type HealthGroup } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { useState } from 'react'; diff --git a/ui/src/components/health-stats-bar.tsx b/ui/src/components/health/health-stats-bar.tsx similarity index 100% rename from ui/src/components/health-stats-bar.tsx rename to ui/src/components/health/health-stats-bar.tsx diff --git a/ui/src/components/health/index.ts b/ui/src/components/health/index.ts new file mode 100644 index 00000000..16eac15e --- /dev/null +++ b/ui/src/components/health/index.ts @@ -0,0 +1,9 @@ +/** + * Health Components Barrel Export + */ + +export { HealthCard } from './health-card'; +export { HealthCheckItem } from './health-check-item'; +export { HealthGauge } from './health-gauge'; +export { HealthGroupSection } from './health-group-section'; +export { HealthStatsBar } from './health-stats-bar'; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts new file mode 100644 index 00000000..a0dc4bb2 --- /dev/null +++ b/ui/src/components/index.ts @@ -0,0 +1,18 @@ +/** + * Components Main Barrel Export + * + * Aggregates all component domain exports. + * Note: ui/ (shadcn) components are imported directly, not through this barrel. + */ + +// Domain exports +export * from './account'; +export * from './analytics'; +export * from './cliproxy'; +export * from './copilot'; +export * from './health'; +export * from './layout'; +export * from './monitoring'; +export * from './profiles'; +export * from './setup'; +export * from './shared'; diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx similarity index 99% rename from ui/src/components/app-sidebar.tsx rename to ui/src/components/layout/app-sidebar.tsx index ccf4ced7..b00da9f4 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -28,7 +28,7 @@ import { SidebarTrigger, SidebarGroupContent, } from '@/components/ui/sidebar'; -import { CcsLogo } from '@/components/ccs-logo'; +import { CcsLogo } from '@/components/shared/ccs-logo'; import { useSidebar } from '@/hooks/use-sidebar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; diff --git a/ui/src/components/hero-section.tsx b/ui/src/components/layout/hero-section.tsx similarity index 91% rename from ui/src/components/hero-section.tsx rename to ui/src/components/layout/hero-section.tsx index 47b49b91..409900ff 100644 --- a/ui/src/components/hero-section.tsx +++ b/ui/src/components/layout/hero-section.tsx @@ -1,5 +1,5 @@ import { Badge } from '@/components/ui/badge'; -import { CcsLogo } from '@/components/ccs-logo'; +import { CcsLogo } from '@/components/shared/ccs-logo'; interface HeroSectionProps { version?: string; diff --git a/ui/src/components/hub-footer.tsx b/ui/src/components/layout/hub-footer.tsx similarity index 100% rename from ui/src/components/hub-footer.tsx rename to ui/src/components/layout/hub-footer.tsx diff --git a/ui/src/components/layout/index.ts b/ui/src/components/layout/index.ts new file mode 100644 index 00000000..d5ad15e9 --- /dev/null +++ b/ui/src/components/layout/index.ts @@ -0,0 +1,10 @@ +/** + * Layout Components Barrel Export + */ + +export { AppSidebar } from './app-sidebar'; +export { HeroSection } from './hero-section'; +export { HubFooter } from './hub-footer'; +export { Layout } from './layout'; +export { ThemeProvider } from './theme-provider'; +export { ThemeToggle } from './theme-toggle'; diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout/layout.tsx similarity index 73% rename from ui/src/components/layout.tsx rename to ui/src/components/layout/layout.tsx index 41002086..d04ec791 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -1,17 +1,17 @@ import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { SidebarProvider } from '@/components/ui/sidebar'; -import { AppSidebar } from '@/components/app-sidebar'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { PrivacyToggle } from '@/components/privacy-toggle'; -import { GitHubLink } from '@/components/github-link'; -import { DocsLink } from '@/components/docs-link'; -import { ConnectionIndicator } from '@/components/connection-indicator'; -import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; +import { AppSidebar } from './app-sidebar'; +import { ThemeToggle } from './theme-toggle'; +import { PrivacyToggle } from '@/components/shared/privacy-toggle'; +import { GitHubLink } from '@/components/shared/github-link'; +import { DocsLink } from '@/components/shared/docs-link'; +import { ConnectionIndicator } from '@/components/shared/connection-indicator'; +import { LocalhostDisclaimer } from '@/components/shared/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; -import { ClaudeKitBadge } from '@/components/claudekit-badge'; -import { SponsorButton } from '@/components/sponsor-button'; -import { ProjectSelectionDialog } from '@/components/project-selection-dialog'; +import { ClaudeKitBadge } from '@/components/shared/claudekit-badge'; +import { SponsorButton } from '@/components/shared/sponsor-button'; +import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; import { useProjectSelection } from '@/hooks/use-project-selection'; function PageLoader() { diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/layout/theme-provider.tsx similarity index 100% rename from ui/src/components/theme-provider.tsx rename to ui/src/components/layout/theme-provider.tsx diff --git a/ui/src/components/theme-toggle.tsx b/ui/src/components/layout/theme-toggle.tsx similarity index 100% rename from ui/src/components/theme-toggle.tsx rename to ui/src/components/layout/theme-toggle.tsx diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/monitoring/auth-monitor.tsx similarity index 99% rename from ui/src/components/auth-monitor.tsx rename to ui/src/components/monitoring/auth-monitor.tsx index f6bfccc1..61cbaf8b 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/monitoring/auth-monitor.tsx @@ -10,7 +10,7 @@ import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-s import { cn, STATUS_COLORS } from '@/lib/utils'; import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; import { Skeleton } from '@/components/ui/skeleton'; -import { ProviderIcon } from '@/components/provider-icon'; +import { ProviderIcon } from '@/components/shared/provider-icon'; import { AccountFlowViz } from '@/components/account-flow-viz'; import { usePrivacy } from '@/contexts/privacy-context'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; diff --git a/ui/src/components/monitoring/error-logs/error-log-item.tsx b/ui/src/components/monitoring/error-logs/error-log-item.tsx new file mode 100644 index 00000000..a4d4b2e1 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/error-log-item.tsx @@ -0,0 +1,73 @@ +/** + * Error Log Item Component + * Individual log entry in the list view + */ + +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { Clock, FileText } from 'lucide-react'; +import { ProviderIcon } from '@/components/shared/provider-icon'; +import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser'; +import type { ErrorLogItemProps } from './types'; + +export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { + const parsed = useMemo(() => parseFilename(name), [name]); + + return ( + + ); +} diff --git a/ui/src/components/monitoring/error-logs/index.tsx b/ui/src/components/monitoring/error-logs/index.tsx new file mode 100644 index 00000000..b96a65c7 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/index.tsx @@ -0,0 +1,219 @@ +/** + * Error Logs Monitor Component + * + * Displays CLIProxyAPI error logs with master-detail split view. + * ETL: Parses raw logs into structured data for rich display. + * - Left panel: Log list with status code, provider, endpoint, relative time + * - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw) + */ + +import { useState, useMemo, useRef, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useCliproxyErrorLogs } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { AlertTriangle, FileWarning, GripVertical, GripHorizontal } from 'lucide-react'; +import { ErrorLogItem } from './error-log-item'; +import { LogContentPanel } from './log-content-panel'; + +export function ErrorLogsMonitor() { + const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); + + // Vertical resize state + const [height, setHeight] = useState(500); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + const scrollIntervalRef = useRef(null); + + // Auto-scroll handler + const stopAutoScroll = () => { + if (scrollIntervalRef.current) { + clearInterval(scrollIntervalRef.current); + scrollIntervalRef.current = null; + } + }; + + // Resize handlers + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const containerTopDoc = rect.top + window.scrollY; + const newHeight = e.pageY - containerTopDoc; + + // Constrain height (min 300, no max) + setHeight(Math.max(300, newHeight)); + + // Auto-scroll logic + const viewportHeight = window.innerHeight; + const distFromBottom = viewportHeight - e.clientY; + const scrollSpeed = 15; + + stopAutoScroll(); + + if (distFromBottom < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, scrollSpeed); + }, 16); + } else if (e.clientY < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, -scrollSpeed); + }, 16); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + stopAutoScroll(); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + stopAutoScroll(); + }; + }, [isResizing]); + + const startResizing = (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }; + + // Compute default selection (first log name or null) + const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]); + + // Use controlled selection that defaults to first log + const [selectedLog, setSelectedLog] = useState(null); + + // Effective selection: use user selection if available, otherwise default + const effectiveSelection = selectedLog ?? defaultLogName; + + // Get absolute path for the selected log + const selectedAbsolutePath = useMemo(() => { + if (!effectiveSelection || !logs) return undefined; + const log = logs.find((l) => l.name === effectiveSelection); + return log?.absolutePath; + }, [effectiveSelection, logs]); + + // Guards + if (isStatusLoading) return null; + if (!status?.running) return null; + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; + + const errorCount = logs.length; + + return ( +
+ {/* Header */} +
+
+ + Error Logs + + {errorCount} failed request{errorCount !== 1 ? 's' : ''} + +
+
+ + CLIProxy Diagnostics +
+
+ + {/* Resizable Panel Layout */} +
+ + {/* Left Panel: Log List */} + + +
+ {logs.slice(0, 50).map((log) => ( + setSelectedLog(log.name)} + /> + ))} +
+ {logs.length > 50 && ( +
+ Showing 50 of {logs.length} logs +
+ )} +
+
+ + {/* Resize Handle */} + +
+
+ +
+ + + {/* Right Panel: Log Content */} + + + + +
+ + {/* Use standard footer if error, otherwise show resize handle */} + {error ? ( +
+ {error.message} +
+ ) : ( +
+ +
+ )} +
+ ); +} + +// Re-export components +export { ErrorLogItem } from './error-log-item'; +export { LogContentPanel } from './log-content-panel'; +export { TabButton, StatusBadge } from './ui-primitives'; +export { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components'; +export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './types'; diff --git a/ui/src/components/monitoring/error-logs/log-content-panel.tsx b/ui/src/components/monitoring/error-logs/log-content-panel.tsx new file mode 100644 index 00000000..45804590 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/log-content-panel.tsx @@ -0,0 +1,140 @@ +/** + * Log Content Panel Component + * Detail view with tabbed navigation for error log content + */ + +import { useState, useMemo } from 'react'; +import { useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Terminal, Info, Code, ArrowUpRight, ArrowDownLeft, FileText } from 'lucide-react'; +import { parseErrorLog } from '@/lib/error-log-parser'; +import type { TabType, LogContentPanelProps } from './types'; +import { TabButton, StatusBadge } from './ui-primitives'; +import { OverviewTab, HeadersTab, BodyTab, RawTab } from './tab-components'; + +export function LogContentPanel({ name, absolutePath }: LogContentPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); + + // Parse log content + const parsed = useMemo(() => { + if (!content) return null; + return parseErrorLog(content); + }, [content]); + + // No log selected + if (!name) { + return ( +
+
+ +

Select a log to view details

+
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + // Error or no content + if (error || !content || !parsed) { + return ( +
+

Failed to load log content

+
+ ); + } + + return ( +
+ {/* Header with status */} +
+
+ + + {parsed.provider}/{parsed.endpoint || 'unknown'} + + {/* Copy Absolute Path Button */} + {name && ( + + )} +
+
+ {/* Copy Raw Content Button */} + {content && ( + + )} + + {parsed.method} + +
+
+ + {/* Tabs */} +
+ setActiveTab('overview')} + icon={Info} + > + Overview + + setActiveTab('headers')} + icon={Code} + > + Headers + + setActiveTab('request')} + icon={ArrowUpRight} + > + Request + + setActiveTab('response')} + icon={ArrowDownLeft} + > + Response + + setActiveTab('raw')} icon={FileText}> + Raw + +
+ + {/* Tab content */} +
+ {activeTab === 'overview' && } + {activeTab === 'headers' && } + {activeTab === 'request' && } + {activeTab === 'response' && } + {activeTab === 'raw' && } +
+
+ ); +} diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx new file mode 100644 index 00000000..42043731 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -0,0 +1,149 @@ +/** + * Tab Content Components for Error Logs Monitor + * OverviewTab, HeadersTab, BodyTab, RawTab + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Info } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser'; +import { StatusBadge } from './ui-primitives'; + +/** Overview tab content */ +export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + return ( +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
+ + {/* Key metrics grid */} +
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'} +
+
+
+ + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'} +
+
+ + {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+ + {/* Suggestion based on error type */} + {parsed.errorType !== 'unknown' && ( +
+ +
+ {parsed.errorType === 'rate_limit' && + 'Rate limited. Consider using multiple accounts or reducing request frequency.'} + {parsed.errorType === 'auth' && + 'Authentication failed. Check credentials or re-authenticate with the provider.'} + {parsed.errorType === 'not_found' && + 'Endpoint not found. This endpoint may not exist on this provider.'} + {parsed.errorType === 'server' && + 'Server error from upstream. Retry or check provider status.'} + {parsed.errorType === 'timeout' && + 'Request timed out. Check network or increase timeout settings.'} +
+
+ )} +
+ ); +} + +/** Headers tab content */ +export function HeadersTab({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers available
; + } + + return ( + +
+ {entries.map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+
+ ); +} + +/** JSON/Body tab content */ +export function BodyTab({ content, label }: { content: string; label: string }) { + if (!content || content.trim() === '') { + return
No {label.toLowerCase()} body
; + } + + // Try to format as JSON + let formatted = content; + let isJson = false; + try { + const parsed = JSON.parse(content); + formatted = JSON.stringify(parsed, null, 2); + isJson = true; + } catch { + // Not JSON, use as-is + } + + return ( + +
+        {formatted}
+      
+
+ ); +} + +/** Raw tab content */ +export function RawTab({ content }: { content: string }) { + return ( + +
+        {content}
+      
+
+ ); +} diff --git a/ui/src/components/monitoring/error-logs/types.ts b/ui/src/components/monitoring/error-logs/types.ts new file mode 100644 index 00000000..0cae04f1 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/types.ts @@ -0,0 +1,18 @@ +/** + * Types for Error Logs Monitor + */ + +export type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw'; + +export interface ErrorLogItemProps { + name: string; + size: number; + modified: number; + isSelected: boolean; + onClick: () => void; +} + +export interface LogContentPanelProps { + name: string | null; + absolutePath?: string; +} diff --git a/ui/src/components/monitoring/error-logs/ui-primitives.tsx b/ui/src/components/monitoring/error-logs/ui-primitives.tsx new file mode 100644 index 00000000..535ad183 --- /dev/null +++ b/ui/src/components/monitoring/error-logs/ui-primitives.tsx @@ -0,0 +1,51 @@ +/** + * UI Primitives for Error Logs Monitor + * TabButton and StatusBadge components + */ + +import { cn } from '@/lib/utils'; +import { getStatusColor } from '@/lib/error-log-parser'; + +/** Tab button component */ +export function TabButton({ + active, + onClick, + children, + icon: Icon, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; +}) { + return ( + + ); +} + +/** Status badge component */ +export function StatusBadge({ code }: { code: number }) { + const colorClass = getStatusColor(code); + return ( + + {code} + + ); +} diff --git a/ui/src/components/monitoring/index.ts b/ui/src/components/monitoring/index.ts new file mode 100644 index 00000000..3d66966d --- /dev/null +++ b/ui/src/components/monitoring/index.ts @@ -0,0 +1,11 @@ +/** + * Monitoring Components Barrel Export + */ + +// Main monitoring components +export { AuthMonitor } from './auth-monitor'; +export { ProxyStatusWidget } from './proxy-status-widget'; + +// Error logs (from subdirectory) +export { ErrorLogsMonitor } from './error-logs'; +export type { TabType, ErrorLogItemProps, LogContentPanelProps } from './error-logs'; diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx similarity index 100% rename from ui/src/components/proxy-status-widget.tsx rename to ui/src/components/monitoring/proxy-status-widget.tsx diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index b582c00d..dfa4b522 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -1,531 +1,16 @@ /** - * Profile Editor Component - * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) + * Profile Editor - Re-export from modular structure + * @deprecated Import from '@/components/profiles/editor' directly */ -import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { MaskedInput } from '@/components/ui/masked-input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Badge } from '@/components/ui/badge'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react'; -import { toast } from 'sonner'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { GlobalEnvIndicator } from '@/components/global-env-indicator'; +/* eslint-disable react-refresh/only-export-components */ +export { + ProfileEditor, + EnvEditorSection, + InfoSection, + RawEditorSection, + useProfileEditor, + isSensitiveKey, +} from './profiles/editor'; -// Lazy load CodeEditor to reduce initial bundle size -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -interface Settings { - env?: Record; -} - -interface SettingsResponse { - profile: string; - settings: Settings; - mtime: number; - path: string; -} - -interface ProfileEditorProps { - profileName: string; - onDelete?: () => void; -} - -export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { - const [localEdits, setLocalEdits] = useState>({}); - const [conflictDialog, setConflictDialog] = useState(false); - const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [newEnvKey, setNewEnvKey] = useState(''); - const queryClient = useQueryClient(); - - // Fetch settings for selected profile - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['settings', profileName], - queryFn: async () => { - const res = await fetch(`/api/settings/${profileName}/raw`); - if (!res.ok) { - throw new Error(`Failed to load settings: ${res.status}`); - } - return res.json(); - }, - }); - - // Derive raw JSON content - const settings = data?.settings; - const rawJsonContent = useMemo(() => { - if (rawJsonEdits !== null) { - return rawJsonEdits; - } - if (settings) { - return JSON.stringify(settings, null, 2); - } - return ''; - }, [rawJsonEdits, settings]); - - const handleRawJsonChange = useCallback((value: string) => { - setRawJsonEdits(value); - }, []); - - // Derive current settings by merging original data with local edits - // Prioritize rawJsonEdits if available - const currentSettings = useMemo((): Settings | undefined => { - if (rawJsonEdits !== null) { - try { - return JSON.parse(rawJsonEdits); - } catch { - // If invalid JSON, fall back to undefined or partial state - // The UI will likely show empty or potentially broken state if JSON is invalid, - // but the Raw Editor will show the error. - } - } - - if (!settings) return undefined; - return { - ...settings, - env: { - ...settings.env, - ...localEdits, - }, - }; - }, [settings, localEdits, rawJsonEdits]); - - // Sync Visual Editor changes to Raw JSON - const updateEnvValue = (key: string, value: string) => { - const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - - // Update local edits - setLocalEdits((prev) => ({ - ...prev, - [key]: value, - })); - - // Update rawJsonEdits to keep sync - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - }; - - const addNewEnvVar = () => { - if (!newEnvKey.trim()) return; - const key = newEnvKey.trim(); - const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; - - setLocalEdits((prev) => ({ - ...prev, - [key]: '', - })); - - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - - setNewEnvKey(''); - }; - - // Check if raw JSON is valid - const isRawJsonValid = useMemo(() => { - try { - JSON.parse(rawJsonContent); - return true; - } catch { - return false; - } - }, [rawJsonContent]); - - // Check if there are unsaved changes - const hasChanges = useMemo(() => { - if (rawJsonEdits !== null) { - return rawJsonEdits !== JSON.stringify(settings, null, 2); - } - return Object.keys(localEdits).length > 0; - }, [rawJsonEdits, localEdits, settings]); - - // Save mutation - const saveMutation = useMutation({ - mutationFn: async () => { - let settingsToSave: Settings; - - try { - // Always save from rawJsonContent as it's the source of truth - settingsToSave = JSON.parse(rawJsonContent); - } catch { - // Fallback (should typically not happen if validation is correct) - settingsToSave = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; - } - - const res = await fetch(`/api/settings/${profileName}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - settings: settingsToSave, - expectedMtime: data?.mtime, - }), - }); - - if (res.status === 409) { - throw new Error('CONFLICT'); - } - - if (!res.ok) { - throw new Error('Failed to save'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); - queryClient.invalidateQueries({ queryKey: ['profiles'] }); - setLocalEdits({}); - setRawJsonEdits(null); - toast.success('Settings saved'); - }, - onError: (error: Error) => { - if (error.message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error(error.message); - } - }, - }); - - const handleSave = () => { - saveMutation.mutate(); - }; - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - await refetch(); - saveMutation.mutate(); - } else { - setLocalEdits({}); - setRawJsonEdits(null); - } - }; - - const isSensitiveKey = (key: string): boolean => { - const sensitivePatterns = [ - /^ANTHROPIC_AUTH_TOKEN$/, - /_API_KEY$/, - /_AUTH_TOKEN$/, - /^API_KEY$/, - /^AUTH_TOKEN$/, - /_SECRET$/, - /^SECRET$/, - ]; - return sensitivePatterns.some((pattern) => pattern.test(key)); - }; - - // Reset state when profile changes - const profileKey = profileName; - - // Render Left Column Content (Environment + Info + Usage) - const renderFriendlyUI = () => ( -
- -
- - - Environment Variables - - - Info & Usage - - -
- -
- - {/* Scrollable Environment Variables List */} - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - <> - {Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {isSensitiveKey(key) ? ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm h-8" - /> - ) : ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm h-8" - /> - )} -
- ))} - - ) : ( -
-

No environment variables configured.

-

- Add variables using the input below or edit the JSON directly. -

-
- )} -
-
- - {/* Fixed Add Input at Bottom */} -
- -
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm h-8" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
-
- - - -
- {/* Profile Information */} -
-

- - Profile Information -

-
- {data && ( - <> -
- Profile Name - {data.profile} -
-
- File Path -
- - {data.path} - - -
-
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
- - )} -
-
- - {/* Usage */} -
-

Quick Usage

-
-
- -
- - ccs {profileName} "prompt" - - -
-
-
- -
- - ccs default {profileName} - - -
-
-
-
-
-
-
-
-
-
- ); - - // Render Right Column Content (Raw JSON Editor) - const renderRawEditor = () => ( - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
- - ); - - return ( -
- {/* Header */} -
-
-
-

{profileName}

- {data?.path && ( - - {data.path.replace(/^.*\//, '')} - - )} -
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} -

- )} -
-
- - {onDelete && ( - - )} - -
-
- - {isLoading ? ( -
- - Loading settings... -
- ) : isError ? ( -
-
-

- Failed to load settings for this profile. -

- -
-
- ) : ( - // Split Layout (40% Left / 60% Right) -
- {/* Left Column: Friendly UI */} -
{renderFriendlyUI()}
- - {/* Right Column: Raw Editor */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- )} - - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> -
- ); -} +export type { Settings, SettingsResponse, ProfileEditorProps } from './profiles/editor'; diff --git a/ui/src/components/profiles/editor/env-editor-section.tsx b/ui/src/components/profiles/editor/env-editor-section.tsx new file mode 100644 index 00000000..2bee9534 --- /dev/null +++ b/ui/src/components/profiles/editor/env-editor-section.tsx @@ -0,0 +1,101 @@ +/** + * Environment Variables Editor Section + * Visual editor for profile environment variables + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { Plus } from 'lucide-react'; +import { isSensitiveKey } from './utils'; +import type { Settings } from './types'; + +interface EnvEditorSectionProps { + currentSettings: Settings | undefined; + newEnvKey: string; + onNewEnvKeyChange: (value: string) => void; + onEnvValueChange: (key: string, value: string) => void; + onAddEnvVar: () => void; +} + +export function EnvEditorSection({ + currentSettings, + newEnvKey, + onNewEnvKeyChange, + onEnvValueChange, + onAddEnvVar, +}: EnvEditorSectionProps) { + return ( + <> + {/* Scrollable Environment Variables List */} + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + onEnvValueChange(key, e.target.value)} + className="font-mono text-sm h-8" + /> + ) : ( + onEnvValueChange(key, e.target.value)} + className="font-mono text-sm h-8" + /> + )} +
+ ))} + + ) : ( +
+

No environment variables configured.

+

+ Add variables using the input below or edit the JSON directly. +

+
+ )} +
+
+ + {/* Fixed Add Input at Bottom */} +
+ +
+ onNewEnvKeyChange(e.target.value.toUpperCase())} + className="font-mono text-sm h-8" + onKeyDown={(e) => e.key === 'Enter' && onAddEnvVar()} + /> + +
+
+ + ); +} diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx new file mode 100644 index 00000000..d9af4c00 --- /dev/null +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -0,0 +1,68 @@ +/** + * Friendly UI Section + * Left column with environment variables and info tabs + */ + +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { EnvEditorSection } from './env-editor-section'; +import { InfoSection } from './info-section'; +import type { Settings, SettingsResponse } from './types'; + +interface FriendlyUISectionProps { + profileName: string; + data: SettingsResponse | undefined; + currentSettings: Settings | undefined; + newEnvKey: string; + onNewEnvKeyChange: (key: string) => void; + onEnvValueChange: (key: string, value: string) => void; + onAddEnvVar: () => void; +} + +export function FriendlyUISection({ + profileName, + data, + currentSettings, + newEnvKey, + onNewEnvKeyChange, + onEnvValueChange, + onAddEnvVar, +}: FriendlyUISectionProps) { + return ( +
+ +
+ + + Environment Variables + + + Info & Usage + + +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx new file mode 100644 index 00000000..db5d2466 --- /dev/null +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -0,0 +1,75 @@ +/** + * Profile Editor Header Section + * Top header with profile name, badge, last modified, and action buttons + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react'; + +interface HeaderSectionProps { + profileName: string; + data: { path?: string; mtime: number } | undefined; + isLoading: boolean; + isSaving: boolean; + hasChanges: boolean; + isRawJsonValid: boolean; + onRefresh: () => void; + onDelete?: () => void; + onSave: () => void; +} + +export function HeaderSection({ + profileName, + data, + isLoading, + isSaving, + hasChanges, + isRawJsonValid, + onRefresh, + onDelete, + onSave, +}: HeaderSectionProps) { + return ( +
+
+
+

{profileName}

+ {data?.path && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ ); +} diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx new file mode 100644 index 00000000..d15fb411 --- /dev/null +++ b/ui/src/components/profiles/editor/index.tsx @@ -0,0 +1,218 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) + */ + +/* eslint-disable react-refresh/only-export-components */ +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; +import { Loader2, Code2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; + +import { HeaderSection } from './header-section'; +import { FriendlyUISection } from './friendly-ui-section'; +import { RawEditorSection } from './raw-editor-section'; +import type { ProfileEditorProps, Settings, SettingsResponse } from './types'; + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/raw`); + if (!res.ok) throw new Error(`Failed to load settings: ${res.status}`); + return res.json(); + }, + }); + + const settings = data?.settings; + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // Fall back to settings merge + } + } + if (!settings) return undefined; + return { ...settings, env: { ...settings.env, ...localEdits } }; + }, [settings, localEdits, rawJsonEdits]); + + // Compute raw JSON content + const computedRawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits; + if (settings) return JSON.stringify(settings, null, 2); + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Sync Visual Editor changes to Raw JSON + const updateEnvValue = (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + setLocalEdits((prev) => ({ ...prev, [key]: value })); + setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2)); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + const key = newEnvKey.trim(); + const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; + setLocalEdits((prev) => ({ ...prev, [key]: '' })); + setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2)); + setNewEnvKey(''); + }; + + // Computed validity and changes check + const computedIsRawJsonValid = useMemo(() => { + try { + JSON.parse(computedRawJsonContent); + return true; + } catch { + return false; + } + }, [computedRawJsonContent]); + + const computedHasChanges = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits !== JSON.stringify(settings, null, 2); + return Object.keys(localEdits).length > 0; + }, [rawJsonEdits, localEdits, settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + try { + settingsToSave = JSON.parse(computedRawJsonContent); + } catch { + settingsToSave = { ...data?.settings, env: { ...data?.settings?.env, ...localEdits } }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: settingsToSave, expectedMtime: data?.mtime }), + }); + + if (res.status === 409) throw new Error('CONFLICT'); + if (!res.ok) throw new Error('Failed to save'); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') setConflictDialog(true); + else toast.error(error.message); + }, + }); + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + return ( +
+ refetch()} + onDelete={onDelete} + onSave={() => saveMutation.mutate()} + /> + + {isLoading ? ( +
+ + Loading settings... +
+ ) : isError ? ( +
+
+

Failed to load settings.

+ +
+
+ ) : ( +
+
+ +
+
+
+ + + Raw Configuration (JSON) + +
+ +
+
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} + +// Re-exports +export { EnvEditorSection } from './env-editor-section'; +export { InfoSection } from './info-section'; +export { RawEditorSection } from './raw-editor-section'; +export { HeaderSection } from './header-section'; +export { FriendlyUISection } from './friendly-ui-section'; +export { useProfileEditor } from './use-profile-editor'; +export { isSensitiveKey } from './utils'; +export type { Settings, SettingsResponse, ProfileEditorProps } from './types'; diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx new file mode 100644 index 00000000..9e148a42 --- /dev/null +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -0,0 +1,79 @@ +/** + * Profile Info Section + * Displays profile information and usage commands + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Label } from '@/components/ui/label'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Info } from 'lucide-react'; +import type { SettingsResponse } from './types'; + +interface InfoSectionProps { + profileName: string; + data: SettingsResponse | undefined; +} + +export function InfoSection({ profileName, data }: InfoSectionProps) { + return ( + +
+ {/* Profile Information */} +
+

+ + Profile Information +

+
+ {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+ + {/* Usage */} +
+

Quick Usage

+
+
+ +
+ + ccs {profileName} "prompt" + + +
+
+
+ +
+ + ccs default {profileName} + + +
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx new file mode 100644 index 00000000..318a3610 --- /dev/null +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -0,0 +1,66 @@ +/** + * Raw Editor Section + * JSON editor panel for profile settings + */ + +import { Suspense, lazy } from 'react'; +import { Loader2, X } from 'lucide-react'; +import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; +import type { Settings } from './types'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface RawEditorSectionProps { + rawJsonContent: string; + isRawJsonValid: boolean; + rawJsonEdits: string | null; + settings: Settings | undefined; + onChange: (value: string) => void; +} + +export function RawEditorSection({ + rawJsonContent, + isRawJsonValid, + rawJsonEdits, + settings, + onChange, +}: RawEditorSectionProps) { + return ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+ + ); +} diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts new file mode 100644 index 00000000..d7e36a13 --- /dev/null +++ b/ui/src/components/profiles/editor/types.ts @@ -0,0 +1,19 @@ +/** + * Types for Profile Editor + */ + +export interface Settings { + env?: Record; +} + +export interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +export interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} diff --git a/ui/src/components/profiles/editor/use-profile-editor.ts b/ui/src/components/profiles/editor/use-profile-editor.ts new file mode 100644 index 00000000..cceafb68 --- /dev/null +++ b/ui/src/components/profiles/editor/use-profile-editor.ts @@ -0,0 +1,139 @@ +/** + * Profile Editor Hook + * Query + mutation logic for profile settings + */ + +import { useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import type { Settings, SettingsResponse } from './types'; + +interface UseProfileEditorOptions { + profileName: string; + localEdits: Record; + rawJsonEdits: string | null; + rawJsonContent: string; + onSuccess: () => void; + onConflict: () => void; +} + +export function useProfileEditor({ + profileName, + localEdits, + rawJsonEdits, + rawJsonContent, + onSuccess, + onConflict, +}: UseProfileEditorOptions) { + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const query = useQuery({ + queryKey: ['settings', profileName], + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/raw`); + if (!res.ok) { + throw new Error(`Failed to load settings: ${res.status}`); + } + return res.json(); + }, + }); + + // Derive current settings by merging original data with local edits + // eslint-disable-next-line react-hooks/preserve-manual-memoization -- Intentional: merge raw JSON edits over query data + const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // If invalid JSON, fall back + } + } + + if (!query.data?.settings) return undefined; + return { + ...query.data.settings, + env: { + ...query.data.settings.env, + ...localEdits, + }, + }; + }, [query.data?.settings, localEdits, rawJsonEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits !== JSON.stringify(query.data?.settings, null, 2); + } + return Object.keys(localEdits).length > 0; + }, [rawJsonEdits, localEdits, query.data?.settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + settingsToSave = { + ...query.data?.settings, + env: { + ...query.data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: query.data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + onSuccess(); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + onConflict(); + } else { + toast.error(error.message); + } + }, + }); + + return { + query, + currentSettings, + isRawJsonValid, + hasChanges, + saveMutation, + }; +} diff --git a/ui/src/components/profiles/editor/utils.ts b/ui/src/components/profiles/editor/utils.ts new file mode 100644 index 00000000..bbbd6cdb --- /dev/null +++ b/ui/src/components/profiles/editor/utils.ts @@ -0,0 +1,17 @@ +/** + * Utility functions for Profile Editor + */ + +/** Check if a key is considered sensitive (API keys, tokens, etc.) */ +export function isSensitiveKey(key: string): boolean { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); +} diff --git a/ui/src/components/profiles/index.ts b/ui/src/components/profiles/index.ts new file mode 100644 index 00000000..366de67e --- /dev/null +++ b/ui/src/components/profiles/index.ts @@ -0,0 +1,14 @@ +/** + * Profiles Components Barrel Export + */ + +// Main profile components +export { ProfileCard } from './profile-card'; +export { ProfileCreateDialog } from './profile-create-dialog'; +export { ProfileDeck } from './profile-deck'; +export { ProfileDialog } from './profile-dialog'; +export { ProfilesTable } from './profiles-table'; + +// Profile editor (from subdirectory) +export { ProfileEditor } from './editor'; +export type { Settings, SettingsResponse, ProfileEditorProps } from './editor'; diff --git a/ui/src/components/profile-card.tsx b/ui/src/components/profiles/profile-card.tsx similarity index 100% rename from ui/src/components/profile-card.tsx rename to ui/src/components/profiles/profile-card.tsx diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx similarity index 98% rename from ui/src/components/profile-create-dialog.tsx rename to ui/src/components/profiles/profile-create-dialog.tsx index 5506bd45..802297b3 100644 --- a/ui/src/components/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -4,8 +4,9 @@ * Includes Quick Start templates and advanced model configuration */ +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -62,7 +63,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr register, handleSubmit, formState: { errors }, - watch, + control, reset, } = useForm({ resolver: zodResolver(schema), @@ -77,9 +78,10 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr }, }); - const baseUrlValue = watch('baseUrl'); + const baseUrlValue = useWatch({ control, name: 'baseUrl' }); // Reset form when dialog opens + useEffect(() => { if (open) { reset(); @@ -90,6 +92,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr }, [open, reset]); // Check for common URL mistakes + useEffect(() => { if (baseUrlValue) { const lowerUrl = baseUrlValue.toLowerCase(); diff --git a/ui/src/components/profile-deck.tsx b/ui/src/components/profiles/profile-deck.tsx similarity index 100% rename from ui/src/components/profile-deck.tsx rename to ui/src/components/profiles/profile-deck.tsx diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx similarity index 97% rename from ui/src/components/profile-dialog.tsx rename to ui/src/components/profiles/profile-dialog.tsx index 183f2f24..f02ed87b 100644 --- a/ui/src/components/profile-dialog.tsx +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -4,8 +4,9 @@ * Updated: Added model mapping fields for Opus/Sonnet/Haiku */ +/* eslint-disable react-hooks/set-state-in-effect */ import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -49,7 +50,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { handleSubmit, formState: { errors }, reset, - watch, + control, } = useForm({ resolver: zodResolver(schema), defaultValues: profile @@ -66,7 +67,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { }); // Watch model field to auto-expand model mapping when custom model is entered - const modelValue = watch('model'); + const modelValue = useWatch({ control, name: 'model' }); useEffect(() => { // Auto-show model mapping if user enters a custom model (not default) @@ -76,6 +77,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { }, [modelValue]); // Reset state when dialog opens/closes + useEffect(() => { if (!open) { setShowModelMapping(false); diff --git a/ui/src/components/profiles-table.tsx b/ui/src/components/profiles/profiles-table.tsx similarity index 100% rename from ui/src/components/profiles-table.tsx rename to ui/src/components/profiles/profiles-table.tsx diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx index 930446ab..420786e4 100644 --- a/ui/src/components/quick-setup-wizard.tsx +++ b/ui/src/components/quick-setup-wizard.tsx @@ -1,541 +1,30 @@ /** - * Quick Setup Wizard Component - * Phase 03: Multi-account dashboard support - * - * Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success + * Quick Setup Wizard - Re-export from modular structure + * @deprecated Import from '@/components/setup/wizard' directly */ -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent } from '@/components/ui/card'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Copy, - Check, - RefreshCw, - ChevronRight, - ArrowLeft, - Terminal, - User, - Sparkles, - ExternalLink, - Loader2, -} from 'lucide-react'; -import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; -import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; -import { applyDefaultPreset } from '@/lib/preset-utils'; -import { cn } from '@/lib/utils'; -import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { toast } from 'sonner'; +/* eslint-disable react-refresh/only-export-components */ +export { + QuickSetupWizard, + ProgressIndicator, + ProviderStep, + AuthStep, + AccountStep, + VariantStep, + SuccessStep, + PROVIDERS, + ALL_STEPS, + getStepProgress, +} from './setup/wizard'; -interface QuickSetupWizardProps { - open: boolean; - onClose: () => void; -} - -type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; - -const providers = [ - { id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, - { id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, - { id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' }, - { id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' }, - { id: 'iflow', name: 'iFlow', description: 'iFlow AI models' }, -]; - -export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { - const [step, setStep] = useState('provider'); - const [selectedProvider, setSelectedProvider] = useState(''); - const [selectedAccount, setSelectedAccount] = useState(null); - const [variantName, setVariantName] = useState(''); - const [modelName, setModelName] = useState(''); - const [copied, setCopied] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); // Track explicit "add account" action - - const { data: authData, refetch } = useCliproxyAuth(); - const createMutation = useCreateVariant(); - const startAuthMutation = useStartAuth(); - const { privacyMode } = usePrivacy(); - - // Get auth status for selected provider - const providerAuth = authData?.authStatus.find( - (s: AuthStatus) => s.provider === selectedProvider - ); - const accounts = providerAuth?.accounts || []; - - // Reset on close - use timeout to avoid synchronous setState in effect - useEffect(() => { - if (!open) { - const timer = setTimeout(() => { - setStep('provider'); - setSelectedProvider(''); - setSelectedAccount(null); - setVariantName(''); - setModelName(''); - setCopied(false); - setIsAddingNewAccount(false); - }, 0); - return () => clearTimeout(timer); - } - }, [open]); - - // Auto-advance from auth step when account detected - // BUT only if user didn't explicitly click "Add new account" - useEffect(() => { - if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) { - const timer = setTimeout(() => { - setStep('account'); - }, 0); - return () => clearTimeout(timer); - } - }, [step, accounts, isAddingNewAccount]); - - const copyCommand = async (cmd: string) => { - await navigator.clipboard.writeText(cmd); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleRefresh = async () => { - setIsRefreshing(true); - await refetch(); - setIsRefreshing(false); - }; - - const handleStartAuth = () => { - // Check if this is the first account before auth - const isFirstAccount = (providerAuth?.accounts?.length || 0) === 0; - - startAuthMutation.mutate( - { provider: selectedProvider }, - { - onSuccess: async (data) => { - // Apply default preset if this was the first account - if (isFirstAccount) { - const result = await applyDefaultPreset(selectedProvider); - if (result.success && result.presetName) { - toast.success(`Applied "${result.presetName}" preset`); - } else if (!result.success) { - toast.warning('Account added, but failed to apply default preset'); - } - } - - // Account created, select it and advance to variant step - if (data.account) { - setSelectedAccount(data.account as OAuthAccount); - setStep('variant'); - } - refetch(); // Refresh auth status - }, - } - ); - }; - - const handleProviderSelect = (providerId: string) => { - setSelectedProvider(providerId); - const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId); - const provAccounts = auth?.accounts || []; - - if (provAccounts.length === 0) { - // No accounts - go to auth step to add first account - setStep('auth'); - } else { - // Has accounts - always show account selection (includes "Add new account" option) - setStep('account'); - } - }; - - const handleAccountSelect = (account: OAuthAccount) => { - setSelectedAccount(account); - setStep('variant'); - }; - - const handleCreateVariant = async () => { - if (!variantName || !selectedProvider) return; - - try { - await createMutation.mutateAsync({ - name: variantName, - provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow', - model: modelName || undefined, - account: selectedAccount?.id, - }); - setStep('success'); - } catch (error) { - console.error('Failed to create variant:', error); - } - }; - - const authCommand = `ccs ${selectedProvider} --auth --add`; - - // Progress steps for indicator - const allSteps = ['provider', 'auth', 'variant', 'success']; - const getStepProgress = (s: WizardStep) => { - if (s === 'account') return 1; // Same as auth - return allSteps.indexOf(s); - }; - const currentProgress = getStepProgress(step); - - // Prevent accidental close when user has made progress - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen) { - // Allow closing from success step or provider step (no progress yet) - if (step === 'success' || step === 'provider') { - onClose(); - return; - } - // For other steps, require explicit close via Cancel/Back - // The X button still works, but clicking outside doesn't close - } - }; - - return ( - - { - // Prevent closing on outside click when user has made progress - if (step !== 'success' && step !== 'provider') { - e.preventDefault(); - } - }} - onEscapeKeyDown={(e) => { - // Prevent ESC close during auth or variant creation - if (startAuthMutation.isPending || createMutation.isPending) { - e.preventDefault(); - } - }} - > - - - - Quick Setup Wizard - - - {step === 'provider' && 'Select a provider to get started'} - {step === 'auth' && 'Authenticate with your provider'} - {step === 'account' && 'Select which account to use'} - {step === 'variant' && 'Create your custom variant'} - {step === 'success' && 'Setup complete!'} - - - -
- {/* Step: Provider Selection */} - {step === 'provider' && ( -
- {providers.map((p) => ( - - ))} -
- )} - - {/* Step: Authentication */} - {step === 'auth' && ( -
- {/* Primary: OAuth Button */} -
-

- Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add - an account -

- - {startAuthMutation.isPending && ( -

- Complete the OAuth flow in your browser... -

- )} -
- - {/* Divider */} -
-
- -
-
- Or use terminal -
-
- - {/* Secondary: CLI Command */} - - -
- - Run this command in your terminal: -
-
- - {authCommand} - - -
-
-
- -
- - -
-
- )} - - {/* Step: Account Selection */} - {step === 'account' && ( -
- {/* Existing accounts header */} -
- Select an account ({accounts.length}) -
- -
- {accounts.map((acc: OAuthAccount) => ( - - ))} -
- - {/* Divider */} -
-
- -
-
- Or -
-
- - {/* Add new account button - more prominent */} - - -
- -
-
- )} - - {/* Step: Create Variant */} - {step === 'variant' && ( -
- {selectedAccount && ( -
- - - Using:{' '} - - {selectedAccount.email || selectedAccount.id} - - -
- )} - -
- - setVariantName(e.target.value)} - placeholder="e.g., my-gemini, g3, flash" - /> -
- Use this name to invoke: ccs {variantName || ''} "prompt" -
-
- -
- - -
- Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'} -
-
- -
- -
- - -
-
-

- Skip if you just wanted to add an account without creating a variant -

-
- )} - - {/* Step: Success */} - {step === 'success' && ( -
-
-
- -
-
-
-
Variant Created!
-
- Your custom variant is ready to use -
-
- - -
Usage:
- - ccs {variantName} "your prompt here" - -
-
- -
- )} -
- - {/* Progress indicator */} -
- {allSteps.map((s, i) => ( -
= i ? 'bg-primary' : 'bg-muted' - }`} - /> - ))} -
- -
- ); -} +export type { + WizardStep, + QuickSetupWizardProps, + ProviderOption, + ProviderStepProps, + AuthStepProps, + AccountStepProps, + VariantStepProps, + SuccessStepProps, + ProgressIndicatorProps, +} from './setup/wizard'; diff --git a/ui/src/components/setup/index.ts b/ui/src/components/setup/index.ts new file mode 100644 index 00000000..763a70e8 --- /dev/null +++ b/ui/src/components/setup/index.ts @@ -0,0 +1,17 @@ +/** + * Setup Components Barrel Export + */ + +// Quick setup wizard (from subdirectory) +export { QuickSetupWizard } from './wizard'; +export type { + WizardStep, + QuickSetupWizardProps, + ProviderOption, + ProviderStepProps, + AuthStepProps, + AccountStepProps, + VariantStepProps, + SuccessStepProps, + ProgressIndicatorProps, +} from './wizard'; diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts new file mode 100644 index 00000000..6ac80cff --- /dev/null +++ b/ui/src/components/setup/wizard/constants.ts @@ -0,0 +1,20 @@ +/** + * Constants for Quick Setup Wizard + */ + +import type { ProviderOption } from './types'; + +export const PROVIDERS: ProviderOption[] = [ + { id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, + { id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, + { id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' }, + { id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' }, + { id: 'iflow', name: 'iFlow', description: 'iFlow AI models' }, +]; + +export const ALL_STEPS = ['provider', 'auth', 'variant', 'success']; + +export function getStepProgress(step: string): number { + if (step === 'account') return 1; // Same as auth + return ALL_STEPS.indexOf(step); +} diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx new file mode 100644 index 00000000..cea897c4 --- /dev/null +++ b/ui/src/components/setup/wizard/index.tsx @@ -0,0 +1,259 @@ +/** + * Quick Setup Wizard Component + * Phase 03: Multi-account dashboard support + * + * Step-by-step wizard: Provider -> Auth -> Account -> Variant -> Success + */ + +/* eslint-disable react-refresh/only-export-components */ +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Sparkles } from 'lucide-react'; +import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; +import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import { applyDefaultPreset } from '@/lib/preset-utils'; +import { usePrivacy } from '@/contexts/privacy-context'; +import { toast } from 'sonner'; + +import { PROVIDERS, ALL_STEPS, getStepProgress } from './constants'; +import { ProgressIndicator } from './progress-indicator'; +import { ProviderStep } from './steps/provider-step'; +import { AuthStep } from './steps/auth-step'; +import { AccountStep } from './steps/account-step'; +import { VariantStep } from './steps/variant-step'; +import { SuccessStep } from './steps/success-step'; +import type { WizardStep, QuickSetupWizardProps } from './types'; + +export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { + const [step, setStep] = useState('provider'); + const [selectedProvider, setSelectedProvider] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(null); + const [variantName, setVariantName] = useState(''); + const [modelName, setModelName] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); + + const { data: authData, refetch } = useCliproxyAuth(); + const createMutation = useCreateVariant(); + const startAuthMutation = useStartAuth(); + const { privacyMode } = usePrivacy(); + + // Get auth status for selected provider + const providerAuth = authData?.authStatus.find( + (s: AuthStatus) => s.provider === selectedProvider + ); + const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]); + + // Reset on close + useEffect(() => { + if (!open) { + const timer = setTimeout(() => { + setStep('provider'); + setSelectedProvider(''); + setSelectedAccount(null); + setVariantName(''); + setModelName(''); + setIsAddingNewAccount(false); + }, 0); + return () => clearTimeout(timer); + } + }, [open]); + + // Auto-advance from auth step when account detected + useEffect(() => { + if (step === 'auth' && accounts.length > 0 && !isAddingNewAccount) { + const timer = setTimeout(() => { + setStep('account'); + }, 0); + return () => clearTimeout(timer); + } + }, [step, accounts, isAddingNewAccount]); + + const handleRefresh = async () => { + setIsRefreshing(true); + await refetch(); + setIsRefreshing(false); + }; + + const handleStartAuth = () => { + const isFirstAccount = (providerAuth?.accounts?.length || 0) === 0; + + startAuthMutation.mutate( + { provider: selectedProvider }, + { + onSuccess: async (data) => { + if (isFirstAccount) { + const result = await applyDefaultPreset(selectedProvider); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } else if (!result.success) { + toast.warning('Account added, but failed to apply default preset'); + } + } + + if (data.account) { + setSelectedAccount(data.account as OAuthAccount); + setStep('variant'); + } + refetch(); + }, + } + ); + }; + + const handleProviderSelect = (providerId: string) => { + setSelectedProvider(providerId); + const auth = authData?.authStatus.find((s: AuthStatus) => s.provider === providerId); + const provAccounts = auth?.accounts || []; + + if (provAccounts.length === 0) { + setStep('auth'); + } else { + setStep('account'); + } + }; + + const handleAccountSelect = (account: OAuthAccount) => { + setSelectedAccount(account); + setStep('variant'); + }; + + const handleCreateVariant = async () => { + if (!variantName || !selectedProvider) return; + + try { + await createMutation.mutateAsync({ + name: variantName, + provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow', + model: modelName || undefined, + account: selectedAccount?.id, + }); + setStep('success'); + } catch (error) { + console.error('Failed to create variant:', error); + } + }; + + const authCommand = `ccs ${selectedProvider} --auth --add`; + const currentProgress = getStepProgress(step); + + // Prevent accidental close when user has made progress + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + if (step === 'success' || step === 'provider') { + onClose(); + return; + } + } + }; + + return ( + + { + if (step !== 'success' && step !== 'provider') { + e.preventDefault(); + } + }} + onEscapeKeyDown={(e) => { + if (startAuthMutation.isPending || createMutation.isPending) { + e.preventDefault(); + } + }} + > + + + + Quick Setup Wizard + + + {step === 'provider' && 'Select a provider to get started'} + {step === 'auth' && 'Authenticate with your provider'} + {step === 'account' && 'Select which account to use'} + {step === 'variant' && 'Create your custom variant'} + {step === 'success' && 'Setup complete!'} + + + +
+ {step === 'provider' && ( + + )} + + {step === 'auth' && ( + setStep('provider')} + onStartAuth={handleStartAuth} + onRefresh={handleRefresh} + /> + )} + + {step === 'account' && ( + { + setIsAddingNewAccount(true); + setStep('auth'); + }} + onBack={() => setStep('provider')} + /> + )} + + {step === 'variant' && ( + (accounts.length > 0 ? setStep('account') : setStep('provider'))} + onSkip={onClose} + onCreate={handleCreateVariant} + /> + )} + + {step === 'success' && } +
+ + +
+
+ ); +} + +// Re-exports +export { ProgressIndicator } from './progress-indicator'; +export { ProviderStep } from './steps/provider-step'; +export { AuthStep } from './steps/auth-step'; +export { AccountStep } from './steps/account-step'; +export { VariantStep } from './steps/variant-step'; +export { SuccessStep } from './steps/success-step'; +export { PROVIDERS, ALL_STEPS, getStepProgress } from './constants'; +export type { + WizardStep, + QuickSetupWizardProps, + ProviderOption, + ProviderStepProps, + AuthStepProps, + AccountStepProps, + VariantStepProps, + SuccessStepProps, + ProgressIndicatorProps, +} from './types'; diff --git a/ui/src/components/setup/wizard/progress-indicator.tsx b/ui/src/components/setup/wizard/progress-indicator.tsx new file mode 100644 index 00000000..cd643a51 --- /dev/null +++ b/ui/src/components/setup/wizard/progress-indicator.tsx @@ -0,0 +1,21 @@ +/** + * Progress Indicator Component + * Shows step dots for wizard progress + */ + +import type { ProgressIndicatorProps } from './types'; + +export function ProgressIndicator({ currentProgress, allSteps }: ProgressIndicatorProps) { + return ( +
+ {allSteps.map((s, i) => ( +
= i ? 'bg-primary' : 'bg-muted' + }`} + /> + ))} +
+ ); +} diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx new file mode 100644 index 00000000..3914db7e --- /dev/null +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -0,0 +1,84 @@ +/** + * Account Selection Step + */ + +import { Button } from '@/components/ui/button'; +import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import type { AccountStepProps } from '../types'; + +export function AccountStep({ + accounts, + privacyMode, + onSelect, + onAddNew, + onBack, +}: AccountStepProps) { + return ( +
+ {/* Existing accounts header */} +
+ Select an account ({accounts.length}) +
+ +
+ {accounts.map((acc) => ( + + ))} +
+ + {/* Divider */} +
+
+ +
+
+ Or +
+
+ + {/* Add new account button - more prominent */} + + +
+ +
+
+ ); +} diff --git a/ui/src/components/setup/wizard/steps/auth-step.tsx b/ui/src/components/setup/wizard/steps/auth-step.tsx new file mode 100644 index 00000000..6b200c83 --- /dev/null +++ b/ui/src/components/setup/wizard/steps/auth-step.tsx @@ -0,0 +1,97 @@ +/** + * Authentication Step + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Copy, Check, RefreshCw, ArrowLeft, Terminal, ExternalLink, Loader2 } from 'lucide-react'; +import type { AuthStepProps } from '../types'; + +export function AuthStep({ + selectedProvider, + providers, + authCommand, + isRefreshing, + isPending, + onBack, + onStartAuth, + onRefresh, +}: AuthStepProps) { + const [copied, setCopied] = useState(false); + + const copyCommand = async (cmd: string) => { + await navigator.clipboard.writeText(cmd); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {/* Primary: OAuth Button */} +
+

+ Authenticate with {providers.find((p) => p.id === selectedProvider)?.name} to add an + account +

+ + {isPending && ( +

+ Complete the OAuth flow in your browser... +

+ )} +
+ + {/* Divider */} +
+
+ +
+
+ Or use terminal +
+
+ + {/* Secondary: CLI Command */} + + +
+ + Run this command in your terminal: +
+
+ + {authCommand} + + +
+
+
+ +
+ + +
+
+ ); +} diff --git a/ui/src/components/setup/wizard/steps/provider-step.tsx b/ui/src/components/setup/wizard/steps/provider-step.tsx new file mode 100644 index 00000000..2f8e0901 --- /dev/null +++ b/ui/src/components/setup/wizard/steps/provider-step.tsx @@ -0,0 +1,26 @@ +/** + * Provider Selection Step + */ + +import { ChevronRight } from 'lucide-react'; +import type { ProviderStepProps } from '../types'; + +export function ProviderStep({ providers, onSelect }: ProviderStepProps) { + return ( +
+ {providers.map((p) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/setup/wizard/steps/success-step.tsx b/ui/src/components/setup/wizard/steps/success-step.tsx new file mode 100644 index 00000000..56bf23e9 --- /dev/null +++ b/ui/src/components/setup/wizard/steps/success-step.tsx @@ -0,0 +1,35 @@ +/** + * Success Step + */ + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Check } from 'lucide-react'; +import type { SuccessStepProps } from '../types'; + +export function SuccessStep({ variantName, onClose }: SuccessStepProps) { + return ( +
+
+
+ +
+
+
+
Variant Created!
+
Your custom variant is ready to use
+
+ + +
Usage:
+ + ccs {variantName} "your prompt here" + +
+
+ +
+ ); +} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx new file mode 100644 index 00000000..7eafe601 --- /dev/null +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -0,0 +1,104 @@ +/** + * Variant Creation Step + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ArrowLeft, User } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import type { VariantStepProps } from '../types'; + +export function VariantStep({ + selectedProvider, + selectedAccount, + variantName, + modelName, + isPending, + privacyMode, + onVariantNameChange, + onModelChange, + onBack, + onSkip, + onCreate, +}: VariantStepProps) { + return ( +
+ {selectedAccount && ( +
+ + + Using:{' '} + + {selectedAccount.email || selectedAccount.id} + + +
+ )} + +
+ + onVariantNameChange(e.target.value)} + placeholder="e.g., my-gemini, g3, flash" + /> +
+ Use this name to invoke: ccs {variantName || ''} "prompt" +
+
+ +
+ + +
+ Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'} +
+
+ +
+ +
+ + +
+
+

+ Skip if you just wanted to add an account without creating a variant +

+
+ ); +} diff --git a/ui/src/components/setup/wizard/types.ts b/ui/src/components/setup/wizard/types.ts new file mode 100644 index 00000000..cccbf332 --- /dev/null +++ b/ui/src/components/setup/wizard/types.ts @@ -0,0 +1,66 @@ +/** + * Types for Quick Setup Wizard + */ + +import type { OAuthAccount } from '@/lib/api-client'; + +export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; + +export interface QuickSetupWizardProps { + open: boolean; + onClose: () => void; +} + +export interface ProviderOption { + id: string; + name: string; + description: string; +} + +export interface ProviderStepProps { + providers: ProviderOption[]; + onSelect: (providerId: string) => void; +} + +export interface AuthStepProps { + selectedProvider: string; + providers: ProviderOption[]; + authCommand: string; + isRefreshing: boolean; + isPending: boolean; + onBack: () => void; + onStartAuth: () => void; + onRefresh: () => void; +} + +export interface AccountStepProps { + accounts: OAuthAccount[]; + privacyMode: boolean; + onSelect: (account: OAuthAccount) => void; + onAddNew: () => void; + onBack: () => void; +} + +export interface VariantStepProps { + selectedProvider: string; + selectedAccount: OAuthAccount | null; + variantName: string; + modelName: string; + isPending: boolean; + privacyMode: boolean; + onVariantNameChange: (value: string) => void; + onModelChange: (value: string) => void; + onBack: () => void; + onSkip: () => void; + onCreate: () => void; +} + +export interface SuccessStepProps { + variantName: string; + onClose: () => void; +} + +export interface ProgressIndicatorProps { + currentProgress: number; + allSteps: string[]; +} diff --git a/ui/src/components/ccs-logo.tsx b/ui/src/components/shared/ccs-logo.tsx similarity index 100% rename from ui/src/components/ccs-logo.tsx rename to ui/src/components/shared/ccs-logo.tsx diff --git a/ui/src/components/claudekit-badge.tsx b/ui/src/components/shared/claudekit-badge.tsx similarity index 100% rename from ui/src/components/claudekit-badge.tsx rename to ui/src/components/shared/claudekit-badge.tsx diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/shared/code-editor.tsx similarity index 100% rename from ui/src/components/code-editor.tsx rename to ui/src/components/shared/code-editor.tsx diff --git a/ui/src/components/command-builder.tsx b/ui/src/components/shared/command-builder.tsx similarity index 100% rename from ui/src/components/command-builder.tsx rename to ui/src/components/shared/command-builder.tsx diff --git a/ui/src/components/confirm-dialog.tsx b/ui/src/components/shared/confirm-dialog.tsx similarity index 100% rename from ui/src/components/confirm-dialog.tsx rename to ui/src/components/shared/confirm-dialog.tsx diff --git a/ui/src/components/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx similarity index 100% rename from ui/src/components/connection-indicator.tsx rename to ui/src/components/shared/connection-indicator.tsx diff --git a/ui/src/components/docs-link.tsx b/ui/src/components/shared/docs-link.tsx similarity index 100% rename from ui/src/components/docs-link.tsx rename to ui/src/components/shared/docs-link.tsx diff --git a/ui/src/components/github-link.tsx b/ui/src/components/shared/github-link.tsx similarity index 100% rename from ui/src/components/github-link.tsx rename to ui/src/components/shared/github-link.tsx diff --git a/ui/src/components/global-env-indicator.tsx b/ui/src/components/shared/global-env-indicator.tsx similarity index 100% rename from ui/src/components/global-env-indicator.tsx rename to ui/src/components/shared/global-env-indicator.tsx diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts new file mode 100644 index 00000000..2a181ec4 --- /dev/null +++ b/ui/src/components/shared/index.ts @@ -0,0 +1,22 @@ +/** + * Shared Components Barrel Export + */ + +export { CcsLogo } from './ccs-logo'; +export { ClaudeKitBadge } from './claudekit-badge'; +export { CodeEditor } from './code-editor'; +export { CommandBuilder } from './command-builder'; +export { ConfirmDialog } from './confirm-dialog'; +export { ConnectionIndicator } from './connection-indicator'; +export { DocsLink } from './docs-link'; +export { GitHubLink } from './github-link'; +export { GlobalEnvIndicator } from './global-env-indicator'; +export { LocalhostDisclaimer } from './localhost-disclaimer'; +export { PrivacyToggle } from './privacy-toggle'; +export { ProjectSelectionDialog } from './project-selection-dialog'; +export { ProviderIcon } from './provider-icon'; +export { QuickCommands } from './quick-commands'; +export { SettingsDialog } from './settings-dialog'; +export { SponsorButton } from './sponsor-button'; +export { StatCard } from './stat-card'; +export { ValueMetrics } from './value-metrics'; diff --git a/ui/src/components/localhost-disclaimer.tsx b/ui/src/components/shared/localhost-disclaimer.tsx similarity index 100% rename from ui/src/components/localhost-disclaimer.tsx rename to ui/src/components/shared/localhost-disclaimer.tsx diff --git a/ui/src/components/privacy-toggle.tsx b/ui/src/components/shared/privacy-toggle.tsx similarity index 100% rename from ui/src/components/privacy-toggle.tsx rename to ui/src/components/shared/privacy-toggle.tsx diff --git a/ui/src/components/project-selection-dialog.tsx b/ui/src/components/shared/project-selection-dialog.tsx similarity index 100% rename from ui/src/components/project-selection-dialog.tsx rename to ui/src/components/shared/project-selection-dialog.tsx diff --git a/ui/src/components/provider-icon.tsx b/ui/src/components/shared/provider-icon.tsx similarity index 100% rename from ui/src/components/provider-icon.tsx rename to ui/src/components/shared/provider-icon.tsx diff --git a/ui/src/components/quick-commands.tsx b/ui/src/components/shared/quick-commands.tsx similarity index 100% rename from ui/src/components/quick-commands.tsx rename to ui/src/components/shared/quick-commands.tsx diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx similarity index 98% rename from ui/src/components/settings-dialog.tsx rename to ui/src/components/shared/settings-dialog.tsx index 624ce907..f0f6c760 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -17,14 +17,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; -import { ConfirmDialog } from '@/components/confirm-dialog'; +import { ConfirmDialog } from './confirm-dialog'; import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; // Lazy load CodeEditor to reduce initial bundle size -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); +const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor }))); interface Settings { env?: Record; diff --git a/ui/src/components/sponsor-button.tsx b/ui/src/components/shared/sponsor-button.tsx similarity index 100% rename from ui/src/components/sponsor-button.tsx rename to ui/src/components/shared/sponsor-button.tsx diff --git a/ui/src/components/stat-card.tsx b/ui/src/components/shared/stat-card.tsx similarity index 100% rename from ui/src/components/stat-card.tsx rename to ui/src/components/shared/stat-card.tsx diff --git a/ui/src/components/value-metrics.tsx b/ui/src/components/shared/value-metrics.tsx similarity index 100% rename from ui/src/components/value-metrics.tsx rename to ui/src/components/shared/value-metrics.tsx diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index f88b252c..231dd545 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -3,7 +3,7 @@ * Phase 03: REST API Routes & CRUD */ -import { AccountsTable } from '@/components/accounts-table'; +import { AccountsTable } from '@/components/account/accounts-table'; import { useAccounts } from '@/hooks/use-accounts'; export function AccountsPage() { diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 62fe0840..27de98a5 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -22,9 +22,9 @@ import { RefreshCw, } from 'lucide-react'; import { ProfileEditor } from '@/components/profile-editor'; -import { ProfileCreateDialog } from '@/components/profile-create-dialog'; +import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; -import { ConfirmDialog } from '@/components/confirm-dialog'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import type { Profile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { CopyButton } from '@/components/ui/copy-button'; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 2a5a526d..46f668ac 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -12,10 +12,10 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react'; import { QuickSetupWizard } from '@/components/quick-setup-wizard'; -import { AddAccountDialog } from '@/components/add-account-dialog'; +import { AddAccountDialog } from '@/components/account/add-account-dialog'; import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; -import { ProxyStatusWidget } from '@/components/proxy-status-widget'; +import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { useCliproxy, useCliproxyAuth, @@ -191,8 +191,8 @@ export function CliproxyPage() { isFirstAccount: boolean; } | null>(null); - const providers = authData?.authStatus || []; - const variants = variantsData?.variants || []; + const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); // Auto-select first provider if nothing selected const effectiveProvider = useMemo(() => { diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index 7c8b5d94..df38ed06 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -2,9 +2,9 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react'; -import { HealthGauge } from '@/components/health-gauge'; -import { HealthStatsBar } from '@/components/health-stats-bar'; -import { HealthGroupSection } from '@/components/health-group-section'; +import { HealthGauge } from '@/components/health/health-gauge'; +import { HealthStatsBar } from '@/components/health/health-stats-bar'; +import { HealthGroupSection } from '@/components/health/health-group-section'; import { useHealth, type HealthGroup } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index e864b87a..04e8ac9c 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; -import { HeroSection } from '@/components/hero-section'; -import { AuthMonitor } from '@/components/auth-monitor'; +import { HeroSection } from '@/components/layout/hero-section'; +import { AuthMonitor } from '@/components/monitoring/auth-monitor'; import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index a30e46cb..07fdce31 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -41,7 +41,7 @@ import { Wifi, WifiOff, } from 'lucide-react'; -import { CodeEditor } from '@/components/code-editor'; +import { CodeEditor } from '@/components/shared/code-editor'; import { api } from '@/lib/api-client'; import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';