mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
Merge pull request #20 from kaitranntt/feat/typescript-npm-package
feat(cliproxy): OAuth fix, config refactoring, and provider migration
This commit is contained in:
115 files changed
+9734
-8300
No files matched your search
@@ -23,6 +23,10 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# TypeScript build output
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Package lock files (keep package-lock.json but ignore others)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.md
|
||||
*.json
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
+109
@@ -2,6 +2,115 @@
|
||||
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/)
|
||||
|
||||
## [5.0.0] - 2025-11-28
|
||||
|
||||
### Added
|
||||
- **CLIProxy OAuth Profiles**: Three new zero-config profiles powered by CLIProxyAPI
|
||||
- `ccs gemini` - Google Gemini via OAuth (zero config)
|
||||
- `ccs codex` - OpenAI Codex via OAuth (zero config)
|
||||
- `ccs agy` - Antigravity (AGY) via OAuth (zero config)
|
||||
|
||||
- **Download-on-Demand Binary**: CLIProxyAPI binary (~15MB) downloads automatically on first use
|
||||
- Supports 6 platforms: darwin/linux/windows × amd64/arm64
|
||||
- SHA256 checksum verification
|
||||
- 3x retry with exponential backoff
|
||||
- No npm package size impact
|
||||
|
||||
- **OAuth Authentication System** (`src/cliproxy/auth-handler.ts`):
|
||||
- Browser-based OAuth flow with automatic token storage
|
||||
- Headless mode fallback (`ccs gemini --auth --headless`)
|
||||
- Token storage in `~/.ccs/cliproxy-auth/<provider>/`
|
||||
- 2-minute OAuth timeout protection
|
||||
|
||||
- **CLIProxy Diagnostics** in `ccs doctor`:
|
||||
- Binary installation status + version
|
||||
- Config file validation
|
||||
- OAuth status per provider (gemini/codex/agy)
|
||||
- Port 8317 availability check
|
||||
|
||||
- **Enhanced Error Messages** (`src/utils/error-manager.ts`):
|
||||
- OAuth timeout troubleshooting
|
||||
- Port conflict resolution
|
||||
- Binary download failure with manual URL
|
||||
|
||||
- **New CLIProxy Module** (`src/cliproxy/`):
|
||||
- `binary-manager.ts` - Download, verify, extract binary
|
||||
- `platform-detector.ts` - OS/arch detection for 6 platforms
|
||||
- `cliproxy-executor.ts` - Spawn/kill proxy pattern
|
||||
- `config-generator.ts` - Generate config.yaml per provider
|
||||
- `auth-handler.ts` - OAuth token management
|
||||
- `types.ts` - TypeScript type definitions
|
||||
- `index.ts` - Central exports
|
||||
|
||||
### Changed
|
||||
- **Profile Detection**: New priority order
|
||||
1. CLIProxy profiles (gemini, codex, agy)
|
||||
2. Settings-based profiles (glm, glmt, kimi)
|
||||
3. Account-based profiles (work, personal)
|
||||
4. Default Claude CLI
|
||||
- **Help Text**: Updated with new OAuth profiles (alphabetically sorted)
|
||||
- **Profile Detector**: Added `cliproxy` profile type
|
||||
|
||||
### Technical Details
|
||||
- **Binary Version**: CLIProxyAPI v6.5.27
|
||||
- **Default Port**: 8317 (TCP polling for readiness, no PROXY_READY signal)
|
||||
- **Model Mappings**:
|
||||
- Gemini: gemini-2.0-flash (opus: thinking-exp, haiku: flash-lite)
|
||||
- Codex: gpt-4o (opus: o1, haiku: gpt-4o-mini)
|
||||
- Antigravity: agy (sonnet: agy-pro, haiku: agy-turbo)
|
||||
- **Storage**:
|
||||
- Binary: `~/.ccs/bin/cliproxyapi`
|
||||
- Tokens: `~/.ccs/cliproxy-auth/<provider>/`
|
||||
- Config: `~/.ccs/cliproxy.config.yaml`
|
||||
|
||||
### Migration
|
||||
- **No breaking changes**: All existing profiles (glm, glmt, kimi, accounts) work unchanged
|
||||
- **Zero configuration**: OAuth profiles work out-of-box after browser login
|
||||
- **Backward compatible**: v4.x commands and workflows unchanged
|
||||
|
||||
---
|
||||
|
||||
## [4.5.0] - 2025-11-27 (Phase 02 Complete)
|
||||
|
||||
### Changed
|
||||
- **Modular Command Architecture**: Complete refactoring of command handling system
|
||||
- Main entry point (src/ccs.ts) reduced from 1,071 to 593 lines (**44.6% reduction**)
|
||||
- 6 command handlers extracted to dedicated modules in `src/commands/`
|
||||
- Enhanced maintainability through single responsibility principle
|
||||
- Command handlers can now be developed and tested independently
|
||||
|
||||
### Added
|
||||
- **Modular Command Handlers** (`src/commands/`):
|
||||
- `version-command.ts` (3.0KB) - Version display functionality
|
||||
- `help-command.ts` (4.9KB) - Comprehensive help system
|
||||
- `install-command.ts` (957B) - Installation/uninstallation workflows
|
||||
- `doctor-command.ts` (415B) - System diagnostics
|
||||
- `sync-command.ts` (1.0KB) - Configuration synchronization
|
||||
- `shell-completion-command.ts` (2.1KB) - Shell completion management
|
||||
|
||||
- **New Utility Modules** (`src/utils/`):
|
||||
- `shell-executor.ts` (1.5KB) - Cross-platform shell command execution
|
||||
- `package-manager-detector.ts` (3.8KB) - Package manager detection (npm, yarn, pnpm, bun)
|
||||
|
||||
- **TypeScript Type System**:
|
||||
- `src/types/` directory with comprehensive type definitions
|
||||
- Standardized `CommandHandler` interface for all commands
|
||||
- 100% TypeScript coverage across all new modules
|
||||
|
||||
### Improved
|
||||
- **Maintainability**: Each command now has focused, dedicated module
|
||||
- **Testing Independence**: Command handlers can be unit tested in isolation
|
||||
- **Development Workflow**: Multiple developers can work on different commands simultaneously
|
||||
- **Code Navigation**: Developers can quickly locate specific command logic
|
||||
- **Future Extension**: New commands can be added without modifying main orchestrator
|
||||
|
||||
### Technical Details
|
||||
- **Zero Breaking Changes**: All existing functionality preserved
|
||||
- **Performance**: No degradation, minor improvement due to smaller main file
|
||||
- **Quality Gates**: All Phase 01 ESLint strictness rules maintained
|
||||
- **Type Safety**: Comprehensive TypeScript coverage with zero `any` types
|
||||
- **Interface Consistency**: All commands follow standardized `CommandHandler` interface
|
||||
|
||||
## [4.4.0] - 2025-11-23
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -13,47 +13,59 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
|
||||
- **DRY**: One source of truth (config.json)
|
||||
- **CLI-First**: All features must have CLI interface
|
||||
|
||||
## TypeScript Quality Gates (CORE PURPOSE)
|
||||
|
||||
**The npm package is 100% TypeScript. Quality gates MUST pass before publish.**
|
||||
|
||||
**Package Manager: bun (preferred)** - 10-25x faster than npm
|
||||
```bash
|
||||
bun install # Install dependencies (creates bun.lockb)
|
||||
bun run build # Compile src/ → dist/
|
||||
bun run validate # Full validation: typecheck + lint + format + test
|
||||
```
|
||||
|
||||
**Quality gate scripts:**
|
||||
```bash
|
||||
bun run typecheck # Type-check without emit (tsc --noEmit)
|
||||
bun run lint # ESLint TypeScript rules
|
||||
bun run lint:fix # Auto-fix lint issues
|
||||
bun run format # Prettier formatting (write)
|
||||
bun run format:check # Prettier check (CI)
|
||||
bun run test # Build + run all tests
|
||||
```
|
||||
|
||||
**Automatic enforcement:**
|
||||
- `prepublishOnly` runs `validate` before `npm publish`
|
||||
- `prepack` runs `validate` before `npm pack`
|
||||
- CI/CD should run `bun run validate` on every PR
|
||||
|
||||
**File structure:**
|
||||
- `src/` - TypeScript source (development)
|
||||
- `dist/` - Compiled JavaScript (production, npm package)
|
||||
- `lib/` - Native shell scripts (bash, PowerShell)
|
||||
|
||||
**Linting rules (eslint.config.mjs):**
|
||||
- `no-unused-vars` - warn (upgrade to error incrementally)
|
||||
- `no-explicit-any` - warn (upgrade to error incrementally)
|
||||
- `no-non-null-assertion` - warn
|
||||
- `prefer-const`, `no-var`, `eqeqeq` - error
|
||||
|
||||
**Type safety rules:**
|
||||
- Avoid `any` types - use proper typing or `unknown`
|
||||
- Avoid `@ts-ignore` - fix the type error properly
|
||||
- Strict mode enabled in tsconfig.json
|
||||
|
||||
## Critical Constraints (NEVER VIOLATE)
|
||||
|
||||
1. **NO EMOJIS** - ASCII only: [OK], [!], [X], [i]
|
||||
2. **TTY-aware colors** - Respect NO_COLOR env var
|
||||
3. **Non-invasive** - NEVER modify `~/.claude/settings.json`
|
||||
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
|
||||
5. **CLI documentation** - ALL changes MUST update `--help` in bin/ccs.js, lib/ccs, lib/ccs.ps1
|
||||
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
|
||||
6. **Idempotent** - All install operations safe to run multiple times
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### GLMT Implementation Notes
|
||||
|
||||
**[!] GLMT only in Node.js version** (`bin/ccs.js`). Native shell versions don't support GLMT (requires HTTP server).
|
||||
|
||||
**Critical files when working on GLMT**:
|
||||
- `bin/glmt/glmt-proxy.js`: HTTP proxy server with streaming + auto-fallback
|
||||
- `bin/glmt/glmt-transformer.js`: Format conversion + delta handling + tool transformation
|
||||
- `bin/glmt/locale-enforcer.js`: Enforces English output
|
||||
- `bin/glmt/reasoning-enforcer.js`: Injects explicit reasoning instructions (hybrid approach)
|
||||
- `bin/glmt/sse-parser.js`: SSE stream parser
|
||||
- `bin/glmt/delta-accumulator.js`: State tracking for streaming + tool calls
|
||||
- `tests/unit/glmt/glmt-transformer.test.js`: Unit tests (35 tests passing)
|
||||
- `tests/unit/glmt/reasoning-enforcer.test.js`: ReasoningEnforcer unit tests (15 tests passing)
|
||||
|
||||
**Reasoning control mechanisms (hybrid approach)**:
|
||||
- **Keywords**: `think`, `think hard`, `think harder`, `ultrathink`
|
||||
- **Tags**: `<Thinking:On|Off>`, `<Effort:Low|Medium|High>`
|
||||
- **Precedence**: CLI parameter > message tags > keywords
|
||||
- **Hybrid mode**: Uses BOTH API parameters (`reasoning: true`) AND prompt injection
|
||||
- API params: Native Z.AI support (deterministic, zero overhead)
|
||||
- Prompt injection: Explicit format instructions using `<reasoning_content>` tags
|
||||
- ReasoningEnforcer has 4 effort-aware prompts (low/medium/high/max)
|
||||
- **Enabled by default** for all GLMT usage (always active)
|
||||
|
||||
**Security limits** (DoS protection):
|
||||
- SSE buffer: 1MB max
|
||||
- Content buffers: 10MB max per block
|
||||
- Content blocks: 100 max per message
|
||||
- Request timeout: 120s
|
||||
|
||||
### Profile Mechanisms
|
||||
|
||||
**Settings-based**: `--settings` flag → GLM, GLMT, Kimi, default
|
||||
@@ -75,8 +87,11 @@ Windows fallback: Copies if symlinks unavailable
|
||||
- PowerShell 5.1+, `$ErrorActionPreference = "Stop"`
|
||||
- Native JSON only, no external dependencies
|
||||
|
||||
### Node.js (bin/ccs.js)
|
||||
- Node.js 14+, `child_process.spawn`, handle SIGINT/SIGTERM
|
||||
### TypeScript/Node.js (src/*.ts → dist/*.js)
|
||||
- Node.js 14+, Bun 1.0+, TypeScript 5.3, strict mode
|
||||
- `child_process.spawn`, handle SIGINT/SIGTERM
|
||||
- Run `bun run lint && bun run typecheck` before committing
|
||||
- Format with `bun run format` if needed
|
||||
|
||||
### Terminal Output (ENFORCE)
|
||||
- ASCII only: [OK], [!], [X], [i] (NO emojis)
|
||||
@@ -107,7 +122,7 @@ rm -rf ~/.ccs # Clean environment
|
||||
### New Feature Checklist
|
||||
1. Verify YAGNI/KISS/DRY alignment - reject if doesn't align
|
||||
2. Implement in bash + PowerShell + Node.js (all three)
|
||||
3. **REQUIRED**: Update `--help` in bin/ccs.js, lib/ccs, lib/ccs.ps1
|
||||
3. **REQUIRED**: Update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
|
||||
4. Test on macOS/Linux/Windows
|
||||
5. Add test cases to tests/edge-cases.*
|
||||
6. Update README.md if user-facing
|
||||
@@ -128,8 +143,9 @@ Code standards:
|
||||
- [ ] ASCII only (NO emojis)
|
||||
- [ ] TTY colors disabled when piped
|
||||
- [ ] NO_COLOR respected
|
||||
- [ ] `--help` updated in bin/ccs.js, lib/ccs, lib/ccs.ps1
|
||||
- [ ] `--help` updated in src/ccs.ts, lib/ccs, lib/ccs.ps1
|
||||
- [ ] `--help` consistent across all three
|
||||
- [ ] `bun run validate` passes (typecheck + lint + format + tests)
|
||||
|
||||
Install/behavior:
|
||||
- [ ] Idempotent install
|
||||
@@ -159,31 +175,6 @@ All env values MUST be strings (not booleans/objects) to prevent PowerShell cras
|
||||
}
|
||||
```
|
||||
|
||||
## GLMT Debugging (Common Issues)
|
||||
|
||||
### Debug Mode
|
||||
```bash
|
||||
export CCS_DEBUG=1
|
||||
ccs glmt --verbose "test" # File logs: ~/.ccs/logs/
|
||||
```
|
||||
|
||||
### Known Issues & Fixes
|
||||
|
||||
**No Thinking Blocks**:
|
||||
- Check Z.AI API plan supports reasoning_content
|
||||
- Test with keywords: `ccs glmt "think about the solution"`
|
||||
|
||||
**Empty Thinking Blocks** (v3.5.1+):
|
||||
- Fixed: Signature timing race (see tests/unit/glmt/test-thinking-signature-race.js)
|
||||
|
||||
**Tool Execution Issues**:
|
||||
- MCP tools outputting XML: Fixed in v3.5
|
||||
- Debug with `CCS_DEBUG=1` to inspect transformation
|
||||
|
||||
**Streaming Issues**:
|
||||
- Buffer errors: Hit DoS limits (1MB SSE, 10MB content)
|
||||
- Auto-fallback to buffered mode on error
|
||||
|
||||
## Error Handling Principles
|
||||
|
||||
- Validate early, fail fast with clear messages
|
||||
|
||||
@@ -46,10 +46,14 @@ bun add -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Alternative: Direct Install (Traditional)</strong></summary>
|
||||
<summary><strong>[!] DEPRECATED: Native Shell Installers (Legacy)</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
> [!WARNING]
|
||||
> **These installers are deprecated and will be removed in a future version.**
|
||||
> They now auto-redirect to npm installation. Please use npm directly.
|
||||
|
||||
**macOS / Linux**
|
||||
```bash
|
||||
curl -fsSL ccs.kaitran.ca/install | bash
|
||||
@@ -60,7 +64,7 @@ curl -fsSL ccs.kaitran.ca/install | bash
|
||||
irm ccs.kaitran.ca/install | iex
|
||||
```
|
||||
|
||||
**Note:** Traditional installs bypass Node.js routing for faster startup, but npm is prioritized for easier deployment automation.
|
||||
**Note:** Scripts show deprecation warning and automatically run npm installation if Node.js is available.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -748,15 +752,18 @@ ccs sync
|
||||
|
||||
## Uninstall
|
||||
|
||||
### npm (Recommended)
|
||||
|
||||
```bash
|
||||
npm uninstall -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><h3>Package Managers</h3></summary>
|
||||
<summary><strong>Alternative Package Managers</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm uninstall -g @kaitranntt/ccs
|
||||
|
||||
# yarn
|
||||
yarn global remove @kaitranntt/ccs
|
||||
|
||||
@@ -770,7 +777,7 @@ bun remove -g @kaitranntt/ccs
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Official Uninstaller</h3></summary>
|
||||
<summary><strong>Legacy Uninstallers (for native installs)</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# CCS Delegation Module
|
||||
|
||||
Enhanced Claude Code delegation system for multi-model task delegation.
|
||||
|
||||
## Files
|
||||
|
||||
### Core Components
|
||||
- **headless-executor.js** (405 lines) - Main executor, spawns `claude -p` with enhanced features
|
||||
- **session-manager.js** (156 lines) - Session persistence and cost tracking
|
||||
- **settings-parser.js** (88 lines) - Parse tool restrictions from settings
|
||||
- **result-formatter.js** (326 lines) - Terminal output formatting
|
||||
|
||||
**Total**: 975 lines (down from 1,755 lines - 44% reduction)
|
||||
|
||||
## Features
|
||||
|
||||
### Enhanced Headless Execution
|
||||
- Stream-JSON output parsing (`--output-format stream-json`)
|
||||
- Real-time tool use visibility in TTY
|
||||
- Permission mode acceptEdits (`--permission-mode acceptEdits`)
|
||||
- Tool restrictions from `.claude/settings.local.json`
|
||||
- Multi-turn session management (`--resume <session-id>`)
|
||||
- Time-based limits (10 min default timeout with graceful termination)
|
||||
- Cost tracking and aggregation
|
||||
|
||||
### Session Management
|
||||
- Persistence: `~/.ccs/delegation-sessions.json`
|
||||
- Resume via `/ccs:continue` (auto-detects profile)
|
||||
- Auto-cleanup expired sessions (>30 days)
|
||||
- Cost aggregation across turns
|
||||
|
||||
### Settings
|
||||
- Profile location: `~/.ccs/{profile}.settings.json`
|
||||
- Examples: `glm.settings.json`, `kimi.settings.json`, `glmt.settings.json`
|
||||
- Tool restrictions from `.claude/settings.local.json`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Delegation
|
||||
```javascript
|
||||
const { HeadlessExecutor } = require('./headless-executor');
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'Refactor auth.js', {
|
||||
cwd: '/path/to/project',
|
||||
outputFormat: 'stream-json',
|
||||
permissionMode: 'acceptEdits',
|
||||
timeout: 600000 // 10 minutes
|
||||
});
|
||||
|
||||
console.log(result.sessionId); // For multi-turn
|
||||
console.log(result.totalCost); // Cost in USD
|
||||
console.log(result.content); // Result text
|
||||
```
|
||||
|
||||
### Multi-Turn Sessions
|
||||
```javascript
|
||||
// Start session
|
||||
const result1 = await HeadlessExecutor.execute('glm', 'Implement feature');
|
||||
const sessionId = result1.sessionId;
|
||||
|
||||
// Continue session
|
||||
const result2 = await HeadlessExecutor.execute('glm', 'Add tests', {
|
||||
resumeSession: true
|
||||
});
|
||||
|
||||
// Or with specific session ID
|
||||
const result3 = await HeadlessExecutor.execute('glm', 'Run tests', {
|
||||
sessionId: sessionId
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Restrictions
|
||||
Create `.claude/settings.local.json`:
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(git:*)", "Read", "Edit"],
|
||||
"deny": ["Bash(rm:*)", "Bash(sudo:*)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Automatically applied as CLI flags:
|
||||
```bash
|
||||
--allowedTools "Bash(git:*)" "Read" "Edit" \
|
||||
--disallowedTools "Bash(rm:*)" "Bash(sudo:*)"
|
||||
```
|
||||
|
||||
## Slash Commands
|
||||
|
||||
The delegation system is invoked via simple slash commands in `.claude/commands/ccs/`:
|
||||
|
||||
### Basic Commands
|
||||
- `/ccs "task"` - Delegate task (auto-selects best profile)
|
||||
- `/ccs --glm "task"` - Force GLM-4.6 delegation
|
||||
- `/ccs --kimi "task"` - Force Kimi delegation (long-context)
|
||||
|
||||
### Session Continuation
|
||||
- `/ccs:continue "follow-up"` - Resume last delegation session (auto-detect profile)
|
||||
- `/ccs:continue --glm "follow-up"` - Resume with specific profile switch
|
||||
|
||||
Each command directly invokes:
|
||||
```bash
|
||||
claude -p "$ARGUMENTS" \
|
||||
--settings ~/.ccs/{profile}.settings.json \
|
||||
--output-format stream-json \
|
||||
--permission-mode acceptEdits
|
||||
```
|
||||
|
||||
## Debug Mode
|
||||
|
||||
```bash
|
||||
export CCS_DEBUG=1
|
||||
```
|
||||
|
||||
Enables verbose logging:
|
||||
- Permission mode selection
|
||||
- Session resumption details
|
||||
- Tool restrictions parsing
|
||||
- CLI args construction
|
||||
- Session persistence events
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all delegation tests
|
||||
node tests/unit/delegation/json-output.test.js
|
||||
node tests/unit/delegation/permission-mode.test.js
|
||||
node tests/unit/delegation/session-manager.test.js
|
||||
node tests/unit/delegation/settings-parser.test.js
|
||||
node tests/unit/delegation/max-turns.test.js
|
||||
node tests/unit/delegation/result-formatter.test.js
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
- JSON output parsing (6 tests)
|
||||
- Permission modes (11 tests)
|
||||
- Session management (7 tests)
|
||||
- Settings parser (6 tests)
|
||||
- Auto max-turns (14 tests)
|
||||
- Result formatting (14 tests)
|
||||
- **Total: 58 tests**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User → SlashCommand (/ccs)
|
||||
→ ccs-delegation skill (auto-selects profile)
|
||||
→ Directly invokes: claude -p
|
||||
→ HeadlessExecutor (monitors execution)
|
||||
→ SessionManager (load last session)
|
||||
→ SettingsParser (tool restrictions)
|
||||
→ Parse JSON response
|
||||
→ SessionManager (store/update)
|
||||
→ ResultFormatter.format()
|
||||
→ Display to user
|
||||
```
|
||||
|
||||
**Key Simplification**: Slash commands invoke `claude -p` directly. No intermediate delegation engine or rule system - just direct headless execution with enhanced features.
|
||||
|
||||
## File Permissions
|
||||
|
||||
All files should be `644` (rw-r--r--):
|
||||
```bash
|
||||
chmod 644 bin/delegation/*.js
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Node.js 14+
|
||||
- Claude CLI installed and in PATH
|
||||
- Profile settings configured in `~/.ccs/{profile}.settings.json`
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
**Removed components** (as of 2025-11-15):
|
||||
- ~~delegation-engine.js~~ - Rule-based decision engine (unused)
|
||||
- ~~cwd-resolver.js~~ - Working directory resolution (unused)
|
||||
- ~~rules-schema.js~~ - Schema validation (unused)
|
||||
- ~~delegation-rules.json~~ - Configuration file (not created)
|
||||
|
||||
**Why removed**: Current slash commands directly invoke `claude -p` without intermediate orchestration. The delegation engine, CWD resolver, and rules schema were designed for a more complex system that was never fully integrated.
|
||||
|
||||
**Result**: 44% code reduction (1,755 → 975 lines) with same functionality.
|
||||
|
||||
## References
|
||||
|
||||
- Official docs: https://code.claude.com/docs/en/headless.md
|
||||
- Skill: `.claude/skills/ccs-delegation/`
|
||||
- Commands: `.claude/commands/ccs/`
|
||||
- Tests: `tests/unit/delegation/`
|
||||
@@ -1,103 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { error, expandPath } = require('./helpers');
|
||||
const { ErrorManager } = require('./error-manager');
|
||||
|
||||
// Get config file path
|
||||
function getConfigPath() {
|
||||
return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json');
|
||||
}
|
||||
|
||||
// Read and parse config
|
||||
function readConfig() {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Check config exists
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// Attempt recovery
|
||||
const RecoveryManager = require('./recovery-manager');
|
||||
const recovery = new RecoveryManager();
|
||||
recovery.ensureConfigJson();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
ErrorManager.showInvalidConfig(configPath, 'File not found');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse JSON
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (e) {
|
||||
ErrorManager.showInvalidConfig(configPath, `Invalid JSON: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate config has profiles object
|
||||
if (!config.profiles || typeof config.profiles !== 'object') {
|
||||
ErrorManager.showInvalidConfig(configPath, "Missing 'profiles' object");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Get settings path for profile
|
||||
function getSettingsPath(profile) {
|
||||
const config = readConfig();
|
||||
|
||||
// Get settings path
|
||||
const settingsPath = config.profiles[profile];
|
||||
|
||||
if (!settingsPath) {
|
||||
const availableProfiles = Object.keys(config.profiles);
|
||||
const profileList = availableProfiles.map(p => ` - ${p}`);
|
||||
ErrorManager.showProfileNotFound(profile, profileList);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Expand path
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
|
||||
// Validate settings file exists
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
// Auto-create if it's ~/.claude/settings.json
|
||||
if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) {
|
||||
const RecoveryManager = require('./recovery-manager');
|
||||
const recovery = new RecoveryManager();
|
||||
recovery.ensureClaudeSettings();
|
||||
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
ErrorManager.showSettingsNotFound(expandedPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[i] Auto-created missing settings file');
|
||||
} else {
|
||||
ErrorManager.showSettingsNotFound(expandedPath);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings file is valid JSON
|
||||
try {
|
||||
const settingsContent = fs.readFileSync(expandedPath, 'utf8');
|
||||
JSON.parse(settingsContent);
|
||||
} catch (e) {
|
||||
ErrorManager.showInvalidConfig(expandedPath, `Invalid JSON: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return expandedPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigPath,
|
||||
readConfig,
|
||||
getSettingsPath
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { colored } = require('./helpers');
|
||||
const { ERROR_CODES, getErrorDocUrl } = require('./error-codes');
|
||||
|
||||
/**
|
||||
* Error types with structured messages (Legacy - kept for compatibility)
|
||||
*/
|
||||
const ErrorTypes = {
|
||||
NO_CLAUDE_CLI: 'NO_CLAUDE_CLI',
|
||||
MISSING_SETTINGS: 'MISSING_SETTINGS',
|
||||
INVALID_CONFIG: 'INVALID_CONFIG',
|
||||
UNKNOWN_PROFILE: 'UNKNOWN_PROFILE',
|
||||
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
||||
GENERIC: 'GENERIC'
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced error manager with context-aware messages
|
||||
*/
|
||||
class ErrorManager {
|
||||
/**
|
||||
* Show error code and documentation URL
|
||||
* @param {string} errorCode - Error code (e.g., E301)
|
||||
*/
|
||||
static showErrorCode(errorCode) {
|
||||
console.error(colored(`Error: ${errorCode}`, 'yellow'));
|
||||
console.error(colored(getErrorDocUrl(errorCode), 'yellow'));
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Claude CLI not found error
|
||||
*/
|
||||
static showClaudeNotFound() {
|
||||
console.error('');
|
||||
console.error(colored('[X] Claude CLI not found', 'red'));
|
||||
console.error('');
|
||||
console.error('CCS requires Claude CLI to be installed and available in PATH.');
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' 1. Install Claude CLI:');
|
||||
console.error(' https://docs.claude.com/en/docs/claude-code/installation');
|
||||
console.error('');
|
||||
console.error(' 2. Verify installation:');
|
||||
console.error(' command -v claude (Unix)');
|
||||
console.error(' Get-Command claude (Windows)');
|
||||
console.error('');
|
||||
console.error(' 3. Custom path (if installed elsewhere):');
|
||||
console.error(' export CCS_CLAUDE_PATH="/path/to/claude"');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show settings file not found error
|
||||
* @param {string} settingsPath - Path to missing settings file
|
||||
*/
|
||||
static showSettingsNotFound(settingsPath) {
|
||||
const isClaudeSettings = settingsPath.includes('.claude') && settingsPath.endsWith('settings.json');
|
||||
|
||||
console.error('');
|
||||
console.error(colored('[X] Settings file not found', 'red'));
|
||||
console.error('');
|
||||
console.error(`File: ${settingsPath}`);
|
||||
console.error('');
|
||||
|
||||
if (isClaudeSettings) {
|
||||
console.error('This file is auto-created when you login to Claude CLI.');
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(` echo '{}' > ${settingsPath}`);
|
||||
console.error(' claude /login');
|
||||
console.error('');
|
||||
console.error('Why: Newer Claude CLI versions require explicit login.');
|
||||
} else {
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
console.error('This will recreate missing profile settings.');
|
||||
}
|
||||
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_PROFILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show invalid configuration error
|
||||
* @param {string} configPath - Path to invalid config
|
||||
* @param {string} errorDetail - JSON parse error detail
|
||||
*/
|
||||
static showInvalidConfig(configPath, errorDetail) {
|
||||
console.error('');
|
||||
console.error(colored('[X] Configuration invalid', 'red'));
|
||||
console.error('');
|
||||
console.error(`File: ${configPath}`);
|
||||
console.error(`Issue: ${errorDetail}`);
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Backup corrupted file');
|
||||
console.error(` mv ${configPath} ${configPath}.backup`);
|
||||
console.error('');
|
||||
console.error(' # Reinstall CCS');
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
console.error('Your profile settings will be preserved.');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show profile not found error
|
||||
* @param {string} profileName - Requested profile name
|
||||
* @param {string[]} availableProfiles - List of available profiles
|
||||
* @param {string[]} suggestions - Suggested profile names (fuzzy match)
|
||||
*/
|
||||
static showProfileNotFound(profileName, availableProfiles, suggestions = []) {
|
||||
console.error('');
|
||||
console.error(colored(`[X] Profile '${profileName}' not found`, 'red'));
|
||||
console.error('');
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
console.error(colored('Did you mean:', 'yellow'));
|
||||
suggestions.forEach(s => console.error(` ${s}`));
|
||||
console.error('');
|
||||
}
|
||||
|
||||
console.error(colored('Available profiles:', 'cyan'));
|
||||
availableProfiles.forEach(line => console.error(` ${line}`));
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Use existing profile');
|
||||
console.error(' ccs <profile> "your prompt"');
|
||||
console.error('');
|
||||
console.error(' # Create new account profile');
|
||||
console.error(' ccs auth create <name>');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show permission denied error
|
||||
* @param {string} path - Path with permission issue
|
||||
*/
|
||||
static showPermissionDenied(path) {
|
||||
console.error('');
|
||||
console.error(colored('[X] Permission denied', 'red'));
|
||||
console.error('');
|
||||
console.error(`Cannot write to: ${path}`);
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Fix ownership');
|
||||
console.error(' sudo chown -R $USER ~/.ccs ~/.claude');
|
||||
console.error('');
|
||||
console.error(' # Fix permissions');
|
||||
console.error(' chmod 755 ~/.ccs ~/.claude');
|
||||
console.error('');
|
||||
console.error(' # Retry installation');
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ErrorManager, ErrorTypes };
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@kaitranntt/ccs",
|
||||
"dependencies": {
|
||||
"cli-table3": "^0.6.5",
|
||||
"ora": "^9.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.25",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"mocha": "^11.7.5",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "5.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.0", "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0" } }, "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.48.0", "", {}, "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.0", "@typescript-eslint/tsconfig-utils": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.0", "", { "dependencies": { "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"browser-stdout": ["browser-stdout@1.3.1", "", {}, "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.3.0", "", {}, "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ=="],
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
|
||||
|
||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"mocha": ["mocha@11.7.5", "", { "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "mocha": "bin/mocha.js", "_mocha": "bin/_mocha" } }, "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"ora": ["ora@9.0.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.2.2", "string-width": "^8.1.0", "strip-ansi": "^7.1.2" } }, "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.3.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"workerpool": ["workerpool@9.3.4", "", {}, "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yargs-unparser": ["yargs-unparser@2.0.0", "", { "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", "flat": "^5.0.2", "is-plain-obj": "^2.1.0" } }, "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
||||
|
||||
"mocha/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"ora/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
|
||||
|
||||
"ora/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
|
||||
|
||||
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"mocha/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/agy",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gpt-5.1-codex-max",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.1-codex-max-high",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.1-codex-max",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini-high"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gemini",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gemini-2.5-pro",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-2.5-pro",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-2.5-pro",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
+236
-3
@@ -176,16 +176,27 @@ spawn(claudeCli, ['--settings', settingsPath, ...args]);
|
||||
spawn('sh', ['-c', `claude --settings ${settingsPath} ${args.join(' ')}`]);
|
||||
```
|
||||
|
||||
### Module Organization Standards (v4.3.2)
|
||||
### Module Organization Standards (Phase 02 Complete - 2025-11-27)
|
||||
|
||||
#### Subsystem Directory Structure
|
||||
```
|
||||
bin/
|
||||
src/
|
||||
├── ccs.ts # Main entry point (593 lines, 44.6% reduction)
|
||||
├── commands/ # Modular command handlers (Phase 02 NEW)
|
||||
│ ├── version-command.ts # Version display functionality
|
||||
│ ├── help-command.ts # Help system
|
||||
│ ├── install-command.ts # Installation workflows
|
||||
│ ├── doctor-command.ts # System diagnostics
|
||||
│ ├── sync-command.ts # Configuration synchronization
|
||||
│ └── shell-completion-command.ts # Shell completion management
|
||||
├── auth/ # Auth system modules
|
||||
├── delegation/ # Delegation system modules
|
||||
├── glmt/ # GLMT system modules
|
||||
├── management/ # Management system modules
|
||||
└── utils/ # Utility modules
|
||||
├── utils/ # Utility modules (expanded in Phase 02)
|
||||
│ ├── shell-executor.ts # Cross-platform execution (NEW)
|
||||
│ └── package-manager-detector.ts # Package manager detection (NEW)
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
#### Module Dependencies
|
||||
@@ -238,6 +249,192 @@ module.exports = {
|
||||
- **formatter**: Output formatting (e.g., `result-formatter.js`)
|
||||
- **parser**: Parsing logic (e.g., `settings-parser.js`, `sse-parser.js`)
|
||||
|
||||
## Modular Command Architecture Standards (Phase 02, 2025-11-27)
|
||||
|
||||
### Overview
|
||||
|
||||
Phase 02 introduced modular command architecture that separates command handling logic from the main orchestrator. This enhances maintainability, testability, and development workflow efficiency.
|
||||
|
||||
### Command Handler Pattern
|
||||
|
||||
**Structure Requirements**:
|
||||
- Each command must be implemented in its own dedicated file in `src/commands/`
|
||||
- Commands must follow consistent interface pattern for type safety
|
||||
- Single responsibility principle - each module handles one command only
|
||||
|
||||
**Interface Pattern**:
|
||||
```typescript
|
||||
interface CommandHandler {
|
||||
handle(args: string[]): Promise<void>;
|
||||
requiresProfile?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Example**:
|
||||
```typescript
|
||||
export class VersionCommand implements CommandHandler {
|
||||
async handle(args: string[]): Promise<void> {
|
||||
// Command implementation
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return "Display version information";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Handler Standards
|
||||
|
||||
#### File Organization
|
||||
- **Location**: `src/commands/<command-name>-command.ts`
|
||||
- **Naming**: kebab-case with `-command.ts` suffix
|
||||
- **Export**: Default export of command class
|
||||
|
||||
#### Function Size Limits
|
||||
- **Command handlers**: Maximum 200 lines (enforces focused responsibility)
|
||||
- **Helper functions**: Maximum 50 lines within command modules
|
||||
- **Type definitions**: Separate files for complex types (>5 interfaces)
|
||||
|
||||
#### Import Patterns
|
||||
```typescript
|
||||
// Preferred: Specific imports
|
||||
import { ConfigManager } from '../utils/config-manager.js';
|
||||
import { Logger } from '../utils/logger.js';
|
||||
|
||||
// Avoid: Wildcard imports
|
||||
import * as Utils from '../utils/index.js';
|
||||
```
|
||||
|
||||
### Module Dependencies Standards
|
||||
|
||||
#### Dependency Direction
|
||||
```
|
||||
Main Entry Point (src/ccs.ts)
|
||||
↓
|
||||
Command Handlers (src/commands/)
|
||||
↓
|
||||
Utility Modules (src/utils/)
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- Command handlers may import from utils, types, and management modules
|
||||
- Command handlers MUST NOT import from other command handlers
|
||||
- Utility modules may import from other utilities and types
|
||||
- No circular dependencies allowed
|
||||
|
||||
#### Import Organization
|
||||
```typescript
|
||||
// 1. Node.js built-ins
|
||||
import { spawn } from 'child_process';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
// 2. External dependencies
|
||||
import ora from 'ora';
|
||||
|
||||
// 3. Internal modules (sorted alphabetically)
|
||||
import { ConfigManager } from '../utils/config-manager.js';
|
||||
import { Logger } from '../utils/logger.js';
|
||||
import { Types } from '../types/index.js';
|
||||
```
|
||||
|
||||
### Error Handling in Command Modules
|
||||
|
||||
#### Standardized Error Pattern
|
||||
```typescript
|
||||
export class CommandError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly exitCode: number = 1
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CommandError';
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
try {
|
||||
await this.executeCommand();
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
console.error(`[X] ${error.message}`);
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
#### Validation Requirements
|
||||
- **Input validation**: Must validate arguments before processing
|
||||
- **State validation**: Check required dependencies exist
|
||||
- **Permission validation**: Verify file system access where needed
|
||||
|
||||
### Testing Standards for Command Modules
|
||||
|
||||
#### Unit Test Structure
|
||||
```typescript
|
||||
import { assert } from 'chai';
|
||||
import { VersionCommand } from '../src/commands/version-command.js';
|
||||
|
||||
describe('VersionCommand', () => {
|
||||
let command: VersionCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
command = new VersionCommand();
|
||||
});
|
||||
|
||||
describe('handle()', () => {
|
||||
it('should display version information', async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock Requirements
|
||||
- **File system**: Mock all file operations
|
||||
- **External processes**: Mock spawn/exec calls
|
||||
- **Configuration**: Use test fixtures for config data
|
||||
|
||||
### New Utility Module Standards
|
||||
|
||||
#### Shell Executor (`src/utils/shell-executor.ts`)
|
||||
- **Cross-platform**: Must work on Windows, macOS, Linux
|
||||
- **Process management**: Proper cleanup and signal handling
|
||||
- **Error handling**: Standardized error reporting
|
||||
|
||||
#### Package Manager Detector (`src/utils/package-manager-detector.ts`)
|
||||
- **Detection order**: npm → yarn → pnpm → bun (priority based on availability)
|
||||
- **Caching**: Cache detection results for performance
|
||||
- **Fallback**: Graceful degradation when managers unavailable
|
||||
|
||||
### Integration with Main Orchestrator
|
||||
|
||||
#### Command Registration Pattern
|
||||
```typescript
|
||||
// In src/ccs.ts
|
||||
import { VersionCommand } from './commands/version-command.js';
|
||||
import { HelpCommand } from './commands/help-command.js';
|
||||
|
||||
const commandHandlers = {
|
||||
'--version': new VersionCommand(),
|
||||
'--help': new HelpCommand(),
|
||||
// ... other commands
|
||||
};
|
||||
```
|
||||
|
||||
#### Routing Logic
|
||||
```typescript
|
||||
// Command detection and routing
|
||||
for (const [flag, handler] of Object.entries(commandHandlers)) {
|
||||
if (args.includes(flag)) {
|
||||
await handler.handle(args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Delegation System Patterns (v4.0+)
|
||||
|
||||
### Stream-JSON Parsing
|
||||
@@ -691,6 +888,34 @@ spawn('sh', ['-c', `claude --settings ${settingsPath} ${command}`]);
|
||||
|
||||
## Quality Assurance Standards
|
||||
|
||||
### ESLint Quality Gates (Phase 01 Enhanced + Phase 02 Modular)
|
||||
|
||||
**Strict TypeScript Rules** (enforced as errors):
|
||||
- ✅ `@typescript-eslint/no-unused-vars`: Zero unused variables, imports, or parameters
|
||||
- ✅ `@typescript-eslint/no-explicit-any`: No explicit `any` types allowed (use `unknown` or proper types)
|
||||
- ✅ `@typescript-eslint/no-non-null-assertion`: No non-null assertions (`value!`) - use explicit checks
|
||||
|
||||
**Quality Validation Commands**:
|
||||
```bash
|
||||
bun run validate # Full validation: typecheck + lint + format + test
|
||||
bun run typecheck # TypeScript compilation (tsc --noEmit)
|
||||
bun run lint # ESLint validation (must show 0 errors)
|
||||
bun run format # Prettier formatting
|
||||
bun run test # Test suite execution
|
||||
```
|
||||
|
||||
**Pre-commit Requirements**:
|
||||
- All ESLint rules must pass (zero errors, zero warnings)
|
||||
- TypeScript compilation must succeed
|
||||
- All tests must pass
|
||||
- Code must be properly formatted
|
||||
|
||||
**Phase 02 Additional Requirements**:
|
||||
- Command modules must not exceed 200 lines (enforces single responsibility)
|
||||
- No circular dependencies between command handlers
|
||||
- Each command must implement the CommandHandler interface
|
||||
- Utility modules must be cross-platform compatible
|
||||
|
||||
### Code Review Checklist
|
||||
Before submitting code, verify:
|
||||
- [ ] Follows all coding standards
|
||||
@@ -699,6 +924,7 @@ Before submitting code, verify:
|
||||
- [ ] No console.log statements left in production code
|
||||
- [ ] Error handling is comprehensive
|
||||
- [ ] Cross-platform compatibility is maintained
|
||||
- [ ] **ESLint strictness rules pass** (no unused vars, no explicit any, no non-null assertions)
|
||||
|
||||
### Release Checklist
|
||||
Before releasing new version:
|
||||
@@ -733,6 +959,7 @@ These code standards ensure the CCS codebase remains:
|
||||
- **Secure**: Safe process execution, symlink validation, file handling
|
||||
- **Compatible**: Cross-platform symlinking, Windows fallbacks, unified behavior
|
||||
- **Extensible**: Clean subsystem boundaries, reusable patterns
|
||||
- **Type-Safe**: Enhanced ESLint strictness enforces zero-tolerance for code quality issues
|
||||
|
||||
**v4.x Specific Standards**:
|
||||
- **Delegation patterns**: Stream-JSON parsing, session management, headless execution
|
||||
@@ -740,4 +967,10 @@ These code standards ensure the CCS codebase remains:
|
||||
- **Subsystem organization**: Clear separation (auth, delegation, glmt, management, utils)
|
||||
- **Naming conventions**: handler, executor, manager, validator, formatter, parser suffixes
|
||||
|
||||
**v4.5.0 Installation Standards**:
|
||||
- **Bootstrap-based installers**: Native shell wrappers delegate to Node.js via npx
|
||||
- **Node.js requirement**: 14+ required (checked during install)
|
||||
- **No shell dependencies**: error-codes.sh, progress-indicator.sh, prompt.sh removed
|
||||
- **First-run bootstrap**: Auto-installs @kaitranntt/ccs npm package globally
|
||||
|
||||
Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics.
|
||||
+142
-55
@@ -1,34 +1,44 @@
|
||||
# CCS Codebase Summary (v4.3.2)
|
||||
# CCS Codebase Summary (v4.5.0)
|
||||
|
||||
## Overview
|
||||
|
||||
CCS (Claude Code Switch) v4.3.2 is a lightweight CLI wrapper enabling instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Version 4.x introduces AI-powered delegation, selective .claude/ directory symlinking, stream-JSON output, and enhanced shell completion.
|
||||
CCS (Claude Code Switch) v4.5.0 is a lightweight CLI wrapper enabling instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Version 4.x introduces AI-powered delegation, selective .claude/ directory symlinking, stream-JSON output, and enhanced shell completion. **Phase 02 (2025-11-28)** completes modular command architecture refactoring with 44.6% main file reduction and deprecates native shell installers. v4.5.0 completes transition to npm-first, Node.js-based architecture with bootstrap installers and CSS text-only output (no emojis).
|
||||
|
||||
## Version Evolution
|
||||
|
||||
### v4.3.2 Architecture (Current)
|
||||
- **Total LOC**: ~8,477 lines (JavaScript only)
|
||||
### v4.5.0 Architecture (Current, Phase 02 Complete - 2025-11-28)
|
||||
- **Total LOC**: ~8,477 lines (JavaScript/TypeScript)
|
||||
- **Main File**: src/ccs.ts - 593 lines (**44.6% reduction** from 1,071 lines)
|
||||
- **Key Features**: AI delegation, stream-JSON output, shell completion, doctor diagnostics, sync command
|
||||
- **New Components**: delegation/, utils/claude-symlink-manager.js, utils/delegation-validator.js, utils/update-checker.js
|
||||
- **Architecture**: Modular design with clear separation: auth/, delegation/, glmt/, management/, utils/
|
||||
- **Phase 02 Modular Commands**: 6 specialized command handlers (version, help, install, doctor, sync, shell-completion) + deprecation notices
|
||||
- **Installation Method**: npm-first (curl/irm scripts deprecated, auto-redirect to npm)
|
||||
- **New Components**: src/commands/, src/utils/shell-executor.ts, src/utils/package-manager-detector.ts
|
||||
- **Architecture**: Modular design with clear separation: auth/, delegation/, glmt/, management/, utils/, commands/, types/
|
||||
- **Output Format**: Text-only ASCII indicators ([OK], [!], [X], [i]) - no emoji usage per CLAUDE.md
|
||||
|
||||
### Evolution Summary
|
||||
- **v2.x**: Vault-based credential encryption (~1,700 LOC)
|
||||
- **v3.0**: Vault removal, login-per-profile (~1,100 LOC, 40% reduction)
|
||||
- **v4.0-4.3.2**: Delegation system, .claude/ sharing, stream-JSON (~8,477 LOC including tests/utils)
|
||||
- **v4.0-4.4.x**: Delegation system, .claude/ sharing, stream-JSON (~8,477 LOC including tests/utils)
|
||||
- **Phase 02 (2025-11-28)**: Modular command architecture, native installer deprecation, main file 44.6% reduction
|
||||
- **v4.5.0**: npm-first distribution, TypeScript package with quality gates, modular commands, text-only output (no emojis)
|
||||
|
||||
## Core Components (v4.3.2)
|
||||
## Core Components (Phase 02 Complete - 2025-11-28)
|
||||
|
||||
### 1. Main Entry Point (`bin/ccs.js` - ~800 lines)
|
||||
### 1. Main Entry Point (`src/ccs.ts` - 593 lines, 44.6% reduction)
|
||||
|
||||
**Role**: Central orchestrator with delegation routing
|
||||
**Role**: Central orchestrator with **modular command routing** (Phase 02 enhanced)
|
||||
|
||||
**Key Functions**:
|
||||
- `execClaude(claudeCli, args, envVars)`: Unified spawn logic (Windows shell detection)
|
||||
- `handleVersionCommand()`: Version display with delegation status
|
||||
- `handleHelpCommand()`: Comprehensive help with delegation examples
|
||||
- `execClaudeWithProxy(claudeCli, profile, args)`: GLMT proxy lifecycle
|
||||
- `main()`: Profile routing + delegation detection (-p flag)
|
||||
- `main()`: Profile routing + delegation detection (-p flag) + **command routing to modular handlers**
|
||||
|
||||
**Phase 02 Modular Enhancements**:
|
||||
- **Command Routing**: Delegates to 6 specialized command handlers
|
||||
- **Main File Focus**: Now contains only routing logic + profile detection + GLMT proxy
|
||||
- **Maintainability**: Single responsibility principle applied to all commands
|
||||
- **Testing Independence**: Each command handler can be unit tested in isolation
|
||||
|
||||
**v4.x Enhancements**:
|
||||
- Delegation detection: `-p` flag routes to DelegationHandler
|
||||
@@ -61,7 +71,39 @@ const envVars = { CLAUDE_CONFIG_DIR: instancePath };
|
||||
execClaude(claudeCli, remainingArgs, envVars);
|
||||
```
|
||||
|
||||
### 2. Delegation System (`bin/delegation/` - ~1,200 lines)
|
||||
### 2. Modular Command Handlers (`src/commands/` - Phase 02 New)
|
||||
|
||||
**New in Phase 02**: Complete command modularization for enhanced maintainability
|
||||
|
||||
**Components**:
|
||||
- **version-command.ts** (3.0KB): Version display with build information and platform details
|
||||
- **help-command.ts** (4.9KB): Comprehensive help system with dynamic profile listings
|
||||
- **install-command.ts** (957B): Installation and uninstallation workflows
|
||||
- **doctor-command.ts** (415B): System diagnostics and health checks
|
||||
- **sync-command.ts** (1.0KB): Configuration synchronization and symlink repair
|
||||
- **shell-completion-command.ts** (2.1KB): Shell completion installation for 4 shells
|
||||
|
||||
**Phase 02 Benefits**:
|
||||
- **Single Responsibility**: Each command has focused, dedicated module
|
||||
- **Code Navigation**: Developers can quickly locate specific command logic
|
||||
- **Testing Independence**: Command handlers can be unit tested in isolation
|
||||
- **Parallel Development**: Multiple developers can work on different commands simultaneously
|
||||
- **Future Extension**: New commands can be added without modifying main orchestrator
|
||||
|
||||
**Command Handler Interface**:
|
||||
```typescript
|
||||
interface CommandHandler {
|
||||
handle(args: string[]): Promise<void>;
|
||||
requiresProfile?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**New Utility Modules** (`src/utils/` - Phase 02):
|
||||
- **shell-executor.ts** (1.5KB): Cross-platform shell command execution with process management
|
||||
- **package-manager-detector.ts** (3.8KB): Package manager detection (npm, yarn, pnpm, bun)
|
||||
|
||||
### 3. Delegation System (`src/delegation/` - ~1,200 lines)
|
||||
|
||||
**New in v4.0**: Complete delegation subsystem
|
||||
|
||||
@@ -194,47 +236,64 @@ ccs work "task"
|
||||
3. Postinstall: ClaudeSymlinkManager creates selective symlinks → `~/.claude/`
|
||||
4. User can now use `/ccs` (auto-select) and `/ccs:continue` commands
|
||||
|
||||
## File Structure (v4.3.2)
|
||||
## File Structure (Phase 02 Complete - 2025-11-28)
|
||||
|
||||
```
|
||||
bin/
|
||||
src/ # TypeScript source files (Phase 02 Modular Architecture)
|
||||
├── ccs.ts # Main entry point (593 lines, 44.6% reduction from 1,071)
|
||||
├── commands/ # Modular command handlers (Phase 02 NEW)
|
||||
│ ├── version-command.ts # 3.0KB - Version display
|
||||
│ ├── help-command.ts # 4.9KB - Help system
|
||||
│ ├── install-command.ts # 957B - Install/uninstall
|
||||
│ ├── doctor-command.ts # 415B - System diagnostics
|
||||
│ ├── sync-command.ts # 1.0KB - Configuration sync
|
||||
│ └── shell-completion-command.ts # 2.1KB - Shell completion
|
||||
├── auth/ # Multi-account management (v3.0 core)
|
||||
│ ├── auth-commands.js # CLI handlers (~400 lines)
|
||||
│ ├── profile-detector.js # Profile routing (~150 lines)
|
||||
│ └── profile-registry.js # Metadata management (~250 lines)
|
||||
│ ├── auth-commands.ts # CLI handlers (~400 lines)
|
||||
│ ├── profile-detector.ts # Profile routing (~150 lines)
|
||||
│ └── profile-registry.ts # Metadata management (~250 lines)
|
||||
├── delegation/ # AI delegation system (v4.0+)
|
||||
│ ├── delegation-handler.js # Route -p commands (~300 lines)
|
||||
│ ├── headless-executor.js # Execute with stream-JSON (~400 lines)
|
||||
│ ├── session-manager.js # Session persistence (~200 lines)
|
||||
│ ├── result-formatter.js # Format results (~150 lines)
|
||||
│ ├── settings-parser.js # Parse settings (~150 lines)
|
||||
│ ├── delegation-handler.ts # Route -p commands (~300 lines)
|
||||
│ ├── headless-executor.ts # Execute with stream-JSON (~400 lines)
|
||||
│ ├── session-manager.ts # Session persistence (~200 lines)
|
||||
│ ├── result-formatter.ts # Format results (~150 lines)
|
||||
│ ├── settings-parser.ts # Parse settings (~150 lines)
|
||||
│ └── README.md # Delegation documentation
|
||||
├── glmt/ # GLM thinking mode (v3.x)
|
||||
│ ├── glmt-proxy.js # Embedded HTTP proxy (~400 lines)
|
||||
│ ├── glmt-transformer.js # Format conversion (~300 lines)
|
||||
│ ├── reasoning-enforcer.js # Reasoning prompts (~100 lines)
|
||||
│ ├── locale-enforcer.js # English enforcement (~50 lines)
|
||||
│ ├── delta-accumulator.js # Stream state (~200 lines)
|
||||
│ └── sse-parser.js # SSE parser (~50 lines)
|
||||
│ ├── glmt-proxy.ts # Embedded HTTP proxy (~400 lines)
|
||||
│ ├── glmt-transformer.ts # Format conversion (~300 lines)
|
||||
│ ├── reasoning-enforcer.ts # Reasoning prompts (~100 lines)
|
||||
│ ├── locale-enforcer.ts # English enforcement (~50 lines)
|
||||
│ ├── delta-accumulator.ts # Stream state (~200 lines)
|
||||
│ └── sse-parser.ts # SSE parser (~50 lines)
|
||||
├── management/ # System management (v3.x+)
|
||||
│ ├── doctor.js # Health diagnostics (~250 lines)
|
||||
│ ├── instance-manager.js # Instance lifecycle (~220 lines)
|
||||
│ ├── recovery-manager.js # Auto-recovery (~80 lines)
|
||||
│ └── shared-manager.js # Shared symlinking (~50 lines)
|
||||
├── utils/ # Utilities (expanded in v4.x)
|
||||
│ ├── claude-detector.js # CLI detection (~70 lines)
|
||||
│ ├── claude-dir-installer.js # .claude/ installer (v4.1.1, ~150 lines)
|
||||
│ ├── claude-symlink-manager.js # Selective symlinks (v4.1, ~200 lines)
|
||||
│ ├── config-manager.js # Config management (~80 lines)
|
||||
│ ├── doctor.ts # Health diagnostics (~250 lines)
|
||||
│ ├── instance-manager.ts # Instance lifecycle (~220 lines)
|
||||
│ ├── recovery-manager.ts # Auto-recovery (~80 lines)
|
||||
│ └── shared-manager.ts # Shared symlinking (~50 lines)
|
||||
├── utils/ # Utilities (expanded in v4.x + Phase 02)
|
||||
│ ├── claude-detector.ts # CLI detection (~70 lines)
|
||||
│ ├── claude-dir-installer.ts # .claude/ installer (v4.1.1, ~150 lines)
|
||||
│ ├── claude-symlink-manager.ts # Selective symlinks (v4.1, ~200 lines)
|
||||
│ ├── config-manager.ts # Config management (~80 lines)
|
||||
│ ├── shell-executor.ts # 1.5KB - Cross-platform execution (Phase 02 NEW)
|
||||
│ ├── package-manager-detector.ts # 3.8KB - Package manager detection (Phase 02 NEW)
|
||||
│ ├── delegation-validator.js # Delegation validation (v4.0, ~100 lines)
|
||||
│ ├── error-codes.js # Error codes (~50 lines)
|
||||
│ ├── error-manager.js # Error handling (~200 lines)
|
||||
│ ├── helpers.js # Utilities (~100 lines)
|
||||
│ ├── progress-indicator.js # Progress display (~150 lines)
|
||||
│ ├── prompt.js # User prompting (~100 lines)
|
||||
│ ├── shell-completion.js # Shell completion (v4.1.4, ~250 lines)
|
||||
│ └── update-checker.js # Update checker (v4.1, ~100 lines)
|
||||
└── ccs.js # Main entry (~800 lines)
|
||||
│ ├── shell-completion.ts # Shell completion (v4.1.4, ~250 lines)
|
||||
│ └── update-checker.ts # Update checker (v4.1, ~100 lines)
|
||||
├── types/ # TypeScript type definitions
|
||||
│ ├── cli.ts # CLI interface definitions
|
||||
│ ├── config.ts # Configuration type schemas
|
||||
│ ├── delegation.ts # Delegation system types
|
||||
│ ├── glmt.ts # GLMT-specific types
|
||||
│ ├── utils.ts # Utility function types
|
||||
│ └── index.ts # Central type exports
|
||||
└── scripts/ # Build and utility scripts
|
||||
|
||||
.claude/ # CCS-provided items (v4.1+)
|
||||
├── commands/ccs/ # Delegation commands
|
||||
@@ -365,9 +424,16 @@ ccs.js: execClaude(["command"], {CLAUDE_CONFIG_DIR: instancePath})
|
||||
Claude CLI: Read credentials from instance, execute
|
||||
```
|
||||
|
||||
## Key Features (v4.3.2)
|
||||
## Key Features (v4.5.0 - Phase 02 Complete)
|
||||
|
||||
### 1. AI-Powered Delegation (v4.0)
|
||||
### 1. npm-First Installation (Phase 02 - 2025-11-28)
|
||||
- **Recommended method**: All users directed to npm installation
|
||||
- **Native installer deprecation**: curl/irm scripts auto-redirect to npm
|
||||
- **Cross-platform parity**: Single installation method across macOS/Linux/Windows
|
||||
- **Easy updates**: `npm install -g @kaitranntt/ccs` (version pinning supported)
|
||||
- **Bootstrap installers**: Replaced shell scripts with Node.js-based installation
|
||||
|
||||
### 2. AI-Powered Delegation (v4.0+)
|
||||
- **Headless execution**: `ccs glm -p "task"` runs without interactive UI
|
||||
- **Stream-JSON output**: Real-time tool visibility (`[Tool] Write: file.js`)
|
||||
- **Session continuation**: `ccs glm:continue -p "follow-up"` resumes last session
|
||||
@@ -375,25 +441,31 @@ Claude CLI: Read credentials from instance, execute
|
||||
- **Signal handling**: Proper Ctrl+C cleanup
|
||||
- **13 tools supported**: Comprehensive Claude Code tool coverage
|
||||
|
||||
### 2. Selective .claude/ Symlinking (v4.1)
|
||||
### 3. Selective .claude/ Symlinking (v4.1)
|
||||
- **Package-provided**: `.claude/` ships with npm, copied to `~/.ccs/.claude/`
|
||||
- **Selective symlinks**: Only CCS items linked to `~/.claude/`
|
||||
- **Non-invasive**: Doesn't overwrite user's commands/skills
|
||||
- **Windows support**: Falls back to copying if symlinks unavailable
|
||||
- **Auto-sync**: `ccs sync` re-creates symlinks
|
||||
|
||||
### 3. Enhanced Shell Completion (v4.1.4)
|
||||
### 4. Enhanced Shell Completion (v4.1.4)
|
||||
- **4 shells**: bash, zsh, fish, PowerShell
|
||||
- **Color-coded**: Commands vs descriptions
|
||||
- **Categorized**: Model profiles, account profiles, flags
|
||||
- **Auto-install**: `ccs --shell-completion` or `ccs -sc`
|
||||
|
||||
### 4. Comprehensive Diagnostics (v4.1+)
|
||||
### 5. Comprehensive Diagnostics (v4.1+)
|
||||
- **ccs doctor**: Health check for installation, configs, symlinks, delegation
|
||||
- **ccs sync**: Re-sync delegation commands and skills
|
||||
- **ccs update**: Check for updates (v4.1+)
|
||||
|
||||
### 5. GLMT Thinking Mode (v3.x, stable experimental)
|
||||
### 6. Text-Only Output (Phase 02 - CLAUDE.md Compliance)
|
||||
- **ASCII indicators only**: [OK], [!], [X], [i] (no emoji)
|
||||
- **TTY-aware colors**: Respects NO_COLOR environment variable
|
||||
- **Consistent formatting**: Box borders for errors using ╔═╗║╚╝
|
||||
- **Cross-platform consistency**: Identical output on all shells
|
||||
|
||||
### 7. GLMT Thinking Mode (v3.x, stable experimental)
|
||||
- **Embedded proxy**: HTTP proxy on localhost:random
|
||||
- **Format conversion**: Anthropic ↔ OpenAI
|
||||
- **Reasoning injection**: Force English, thinking prompts
|
||||
@@ -480,22 +552,37 @@ Claude CLI: Read credentials from instance, execute
|
||||
|
||||
## Summary
|
||||
|
||||
**CCS v4.3.2 Achievements**:
|
||||
**CCS Phase 02 Achievements (2025-11-28)**:
|
||||
- **npm-First Distribution**: Deprecated native shell installers, all users directed to npm
|
||||
- **Modular Command Architecture**: 6 specialized command handlers with single responsibility principle
|
||||
- **44.6% Main File Reduction**: src/ccs.ts reduced from 1,071 to 593 lines
|
||||
- **Text-Only Output**: All emoji removed ([!] replaces ⚠️), CLAUDE.md compliance
|
||||
- **Enhanced Maintainability**: Focused modules for version, help, install, doctor, sync, shell-completion
|
||||
- **New Utility Modules**: Cross-platform shell execution and package manager detection
|
||||
- **TypeScript Excellence**: 100% type coverage across all new modules
|
||||
- **Installation Flow**: Auto-redirection from deprecated shell scripts to npm
|
||||
|
||||
**CCS v4.5.0 Overall Achievements**:
|
||||
- **Delegation system**: Complete AI-powered task routing with stream-JSON
|
||||
- **Selective symlinking**: Non-invasive .claude/ directory sharing
|
||||
- **Shell completion**: Enhanced UX with color-coded completions
|
||||
- **Diagnostics**: Comprehensive health checking and auto-recovery
|
||||
- **Modular architecture**: Clear separation of concerns (auth/, delegation/, glmt/, management/, utils/)
|
||||
- **npm Package**: Bootstrap-based installation, quality gates (typecheck, lint, format, test)
|
||||
- **Modular architecture**: Clear separation of concerns (auth/, delegation/, glmt/, management/, utils/, commands/, types/)
|
||||
|
||||
**Design Principles Maintained**:
|
||||
**Design Principles (STRICT ENFORCEMENT)**:
|
||||
- **YAGNI**: Only essential features implemented
|
||||
- **KISS**: Simple, readable code without over-engineering
|
||||
- **DRY**: Single source of truth for each concern
|
||||
- **CLI-First**: All features must have CLI interface
|
||||
- **No Emojis**: ASCII indicators only per CLAUDE.md
|
||||
|
||||
**Code Quality**:
|
||||
- **Total LOC**: ~8,477 lines (bin/ JavaScript only)
|
||||
- **Total LOC**: ~8,477 lines (src/ TypeScript)
|
||||
- **Main File**: 593 lines (44.6% reduction from 1,071 lines)
|
||||
- **Test Coverage**: >90% for critical paths
|
||||
- **Modularity**: 7 subsystems (main, auth, delegation, glmt, management, utils, .claude/)
|
||||
- **Documentation**: Comprehensive inline comments and external docs
|
||||
- **Modularity**: 9 subsystems (main, auth, delegation, glmt, management, utils, commands, types, .claude/)
|
||||
- **Documentation**: Comprehensive inline comments, README.md, 8+ doc files
|
||||
- **Output Format**: ASCII-only text, TTY-aware colors, NO_COLOR compliant
|
||||
|
||||
v4.3.2 demonstrates successful feature expansion (delegation, symlinking, diagnostics) while maintaining core simplicity and zero breaking changes from v3.0. The modular architecture provides a sustainable foundation for future AI-powered development workflow enhancements.
|
||||
Phase 02 (2025-11-28) completes npm-first transition with native installer deprecation and text-only output compliance. v4.5.0 demonstrates successful feature expansion (delegation, symlinking, diagnostics, modular CLI) while maintaining core simplicity and zero breaking changes from v3.0. Bootstrap-based installers eliminate shell script complexity and provide consistent cross-platform behavior.
|
||||
+15
-14
@@ -1,5 +1,9 @@
|
||||
# CCS Installation Guide
|
||||
|
||||
> [!WARNING]
|
||||
> **Native shell installers (curl/irm) are deprecated.**
|
||||
> Use npm installation for all platforms. Legacy installers will be removed in v5.0.
|
||||
|
||||
## npm Package Installation (Recommended)
|
||||
|
||||
### Cross-Platform Installation
|
||||
@@ -34,7 +38,11 @@ npm install -g @kaitranntt/ccs
|
||||
npm install -g @kaitranntt/ccs --force
|
||||
```
|
||||
|
||||
## One-Liner Installation (Traditional)
|
||||
## [!] DEPRECATED: One-Liner Installation (Legacy)
|
||||
|
||||
> [!WARNING]
|
||||
> **These installers are deprecated and will be removed in v5.0.**
|
||||
> They now auto-redirect to npm installation. Please use npm directly.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -46,8 +54,7 @@ curl -fsSL ccs.kaitran.ca/install | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/install.sh | bash
|
||||
```
|
||||
|
||||
**Install Location**:
|
||||
- **All Unix Systems**: `~/.local/bin/ccs` (auto-configures PATH for bash, zsh, fish)
|
||||
**Note:** Scripts show deprecation warning and automatically run npm installation if Node.js is available.
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
@@ -59,18 +66,12 @@ irm ccs.kaitran.ca/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Auto PATH Configuration**:
|
||||
- Installer detects your shell (bash, zsh, fish) automatically
|
||||
- Adds `~/.local/bin` to PATH in shell profile if needed
|
||||
- Idempotent: safe to run multiple times
|
||||
- Shows reload instructions after install
|
||||
**Note:** Scripts show deprecation warning and automatically run npm installation if Node.js is available.
|
||||
|
||||
**Notes**:
|
||||
- Unix installer supports both direct execution (`./install.sh`) and piped installation (`curl | bash`)
|
||||
- Windows installer requires PowerShell 5.1+ (pre-installed on Windows 10+)
|
||||
- No sudo required on any platform
|
||||
## [!] DEPRECATED: Git Clone Installation (Legacy)
|
||||
|
||||
## Git Clone Installation
|
||||
> [!WARNING]
|
||||
> **Git clone installation is deprecated.** Use npm installation instead.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -88,7 +89,7 @@ cd ccs
|
||||
.\installers\install.ps1
|
||||
```
|
||||
|
||||
**Note**: Works with git worktrees and submodules - the installer detects both `.git` directory and `.git` file.
|
||||
**Note**: Scripts show deprecation warning and automatically run npm installation if Node.js is available.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
> **Note**: CCS has been recently simplified with a 35% code reduction (1,315 → 855 lines) while maintaining all functionality. The troubleshooting steps below apply to the simplified architecture.
|
||||
|
||||
## Native Installer Deprecation
|
||||
|
||||
**Issue:** "Why does the curl/irm installer show a deprecation warning?"
|
||||
|
||||
**Cause:** Native shell installers are deprecated in favor of npm installation.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Uninstall legacy version (if installed via curl/irm)
|
||||
ccs-uninstall # or: curl -fsSL ccs.kaitran.ca/uninstall | bash
|
||||
|
||||
# Install via npm (recommended)
|
||||
npm install -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
**Note:** The legacy installer now auto-runs npm install if Node.js is available.
|
||||
|
||||
## npm Installation Issues
|
||||
|
||||
### Config File Not Found After npm Install
|
||||
|
||||
+6
-7
@@ -103,17 +103,16 @@ Run 'ccs --help' for usage information
|
||||
- Platform-specific guidance
|
||||
- Configuration file location and troubleshooting
|
||||
|
||||
**Official Uninstall (Recommended)**:
|
||||
**Uninstall (Recommended)**:
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -fsSL ccs.kaitran.ca/uninstall | bash
|
||||
# npm (recommended)
|
||||
npm uninstall -g @kaitranntt/ccs
|
||||
|
||||
# Windows PowerShell
|
||||
irm ccs.kaitran.ca/uninstall | iex
|
||||
# Legacy uninstallers (for native installs only)
|
||||
# macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash
|
||||
# Windows: irm ccs.kaitran.ca/uninstall | iex
|
||||
```
|
||||
|
||||
The official uninstaller completely removes CCS including configs and PATH modifications.
|
||||
|
||||
**Platform-Specific Locations**:
|
||||
- macOS: `/usr/local/bin/ccs`
|
||||
- Linux: `~/.local/bin/ccs`
|
||||
|
||||
+6
-2
@@ -47,10 +47,14 @@ bun add -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>代替案:直接インストール(従来型)</strong></summary>
|
||||
<summary><strong>[!] 非推奨: ネイティブシェルインストーラー(レガシー)</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
> [!WARNING]
|
||||
> **これらのインストーラーは非推奨であり、将来のバージョンで削除されます。**
|
||||
> 現在は npm インストールに自動リダイレクトされます。npm を直接使用してください。
|
||||
|
||||
**macOS / Linux**
|
||||
```bash
|
||||
curl -fsSL ccs.kaitran.ca/install | bash
|
||||
@@ -61,7 +65,7 @@ curl -fsSL ccs.kaitran.ca/install | bash
|
||||
irm ccs.kaitran.ca/install | iex
|
||||
```
|
||||
|
||||
**注**: 従来型インストールはNode.jsルーティングをバイパスし起動が高速ですが、デプロイ自動化が容易なためnpmを優先します。
|
||||
**注**: スクリプトは非推奨の警告を表示し、Node.jsが利用可能な場合は自動的にnpmインストールを実行します。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+474
-191
@@ -1,238 +1,521 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
## Completed Features
|
||||
## Project Overview
|
||||
|
||||
### v4.3.2 (Current - 2025-11-17)
|
||||
- ✅ **AI-Powered Delegation**: Headless execution with stream-JSON output
|
||||
- ✅ **Selective Symlinking**: Share .claude/ directories (commands, skills, agents)
|
||||
- ✅ **Enhanced Shell Completion**: 4 shells, color-coded categories
|
||||
- ✅ **Diagnostics Suite**: Doctor, sync, update commands
|
||||
- ✅ **Session Continuation**: `:continue` support for follow-up tasks
|
||||
- ✅ **Stream-JSON Parser**: Real-time tool tracking (13+ Claude Code tools)
|
||||
CCS (Claude Code Switch) is a CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). The project enables developers to maintain continuous productivity by running parallel workflows with different AI models, avoiding rate limits and context switching.
|
||||
|
||||
### v4.2.x (2025-11-16)
|
||||
- ✅ **Doctor Command**: Comprehensive health validation
|
||||
- ✅ **Sync Command**: Repair broken symlinks and directory structure
|
||||
- ✅ **Update Checker**: Smart notifications for newer versions
|
||||
- ✅ **Color-Coded Status**: [OK], [!], [X] indicators
|
||||
- ✅ **Actionable Diagnostics**: Specific recommendations for issues
|
||||
### Core Value Proposition
|
||||
- **Zero Downtime**: Instant profile switching without breaking flow state
|
||||
- **Cost Optimization**: 81% cost savings through intelligent delegation to GLM/Kimi
|
||||
- **Parallel Workflows**: Strategic planning with Claude + cost-effective execution with GLM
|
||||
- **Cross-Platform**: Unified experience on macOS, Linux, and Windows
|
||||
|
||||
### v4.1.x (2025-11-15)
|
||||
- ✅ **.claude/ Directory Symlinking**: Selective sharing with isolation
|
||||
- ✅ **Stream-JSON Output**: Real-time delegation tool tracking
|
||||
- ✅ **Shell Completion v2**: Enhanced with color-coded categories
|
||||
- ✅ **Windows Fallback**: Directory copying when symlinks unavailable
|
||||
- ✅ **Kimi API Fixes**: 401 error handling improvements
|
||||
## Current Status
|
||||
|
||||
### v4.0.x (2025-11-14)
|
||||
- ✅ **Delegation System**: Initial AI-powered task delegation
|
||||
- ✅ **Headless Execution**: `-p` flag for prompt-based execution
|
||||
- ✅ **Cost Tracking**: USD cost display per delegation
|
||||
- ✅ **Session Management**: Save session IDs for continuation
|
||||
- ✅ **Result Formatting**: Cost, duration, exit code display
|
||||
### Version Information
|
||||
- **Current Version**: 4.4.0
|
||||
- **Release Status**: TypeScript conversion complete
|
||||
- **Build Status**: ✅ Working (npm run build → dist/ccs.js)
|
||||
- **Test Status**: ✅ All tests passing (39/39 tests)
|
||||
- **Cross-Platform**: ✅ Windows/macOS/Linux
|
||||
- **Code Quality**: ✅ ESLint strictness upgrade completed (Phase 01: 39/39 tests pass, 0 violations)
|
||||
- **Code Architecture**: ✅ CCS split refactoring completed (Phase 02: 44.6% file size reduction, 9 modular components)
|
||||
|
||||
### v3.x (Historical)
|
||||
- ✅ **Vault Removal** (v3.0): Login-per-profile model
|
||||
- ✅ **GLMT Thinking Mode** (v3.2-v3.6): Experimental GLM reasoning support
|
||||
- ✅ **Platform Parity** (v3.0): Unified macOS/Linux/Windows behavior
|
||||
- ✅ **Shared Data Architecture** (v3.1): Early .claude/ sharing
|
||||
- ✅ **Concurrent Sessions** (v3.2): CLAUDE_CONFIG_DIR support
|
||||
### TypeScript Conversion Summary
|
||||
|
||||
### v2.x (Historical)
|
||||
- ✅ **Vault-Based Encryption**: AES-256-GCM credential storage
|
||||
- ✅ **Multi-Account Management**: Multiple Claude accounts
|
||||
- ✅ **Profile Switching**: Instant model changes
|
||||
- ✅ **Initial Architecture**: Foundation for CCS
|
||||
**✅ Conversion Completed: 100% (31/31 files)**
|
||||
|
||||
## Active Development (v4.4-v4.5)
|
||||
The CCS project has been fully converted from JavaScript to TypeScript, delivering enhanced type safety, improved developer experience, and better maintainability.
|
||||
|
||||
### High Priority
|
||||
1. **Delegation Improvements**
|
||||
- MCP tool integration for delegation workflow
|
||||
- SQLite session storage for better query capabilities
|
||||
- Pluggable result formatters (JSON, CSV, table)
|
||||
- Enhanced error handling and retry logic
|
||||
- Cost estimation before execution
|
||||
**Migration Statistics**:
|
||||
- **Source Files**: 31 TypeScript files converted
|
||||
- **Lines of Code**: 6,279 lines of TypeScript code
|
||||
- **Type Coverage**: 100% with zero `any` types
|
||||
- **Build System**: Full TypeScript compilation pipeline
|
||||
- **Type Definitions**: Comprehensive type definitions in `src/types/`
|
||||
|
||||
2. **Performance Optimization**
|
||||
- Model selection based on task complexity analysis
|
||||
- Cost-aware delegation routing (glm vs kimi vs claude)
|
||||
- Caching for frequently used delegation tasks
|
||||
- Parallel delegation support (multiple tasks)
|
||||
**Converted Components**:
|
||||
- ✅ **Core System** (`src/ccs.ts`) - Main entry point
|
||||
- ✅ **Authentication** (`src/auth/`) - Profile management and commands
|
||||
- ✅ **Delegation** (`src/delegation/`) - AI-powered task delegation system
|
||||
- ✅ **GLMT** (`src/glmt/`) - GLM with Thinking support
|
||||
- ✅ **Management** (`src/management/`) - System diagnostics and instance management
|
||||
- ✅ **Utils** (`src/utils/`) - Cross-platform utilities and helpers
|
||||
- ✅ **Types** (`src/types/`) - Complete type definitions
|
||||
|
||||
3. **Documentation**
|
||||
- Video tutorials for delegation workflows
|
||||
- Interactive setup wizard for API keys
|
||||
- Migration guides (v3.x → v4.x tips)
|
||||
- Troubleshooting expansion (common errors)
|
||||
## Recent Achievements
|
||||
|
||||
### Medium Priority
|
||||
1. **Enhanced Diagnostics**
|
||||
- Automated troubleshooting recommendations
|
||||
- Performance profiling integration
|
||||
- Log analysis tools
|
||||
- Health score dashboard
|
||||
### TypeScript Conversion Benefits (v4.4.0)
|
||||
|
||||
2. **Testing Expansion**
|
||||
- End-to-end delegation tests
|
||||
- Cross-platform CI/CD (macOS, Linux, Windows)
|
||||
- Integration tests for symlinking
|
||||
- Performance regression tests
|
||||
#### Type Safety & Reliability
|
||||
- **Zero `any` types**: Complete type coverage across the entire codebase
|
||||
- **Compile-time error detection**: Catches bugs before runtime
|
||||
- **Interface contracts**: Clear API boundaries and data structures
|
||||
- **Exhaustive type checking**: Eliminates entire classes of common errors
|
||||
|
||||
3. **Developer Experience**
|
||||
- Better error messages for delegation failures
|
||||
- Progress bars for long-running delegations
|
||||
- Delegation history browsing (`ccs history`)
|
||||
- Session replay capability
|
||||
#### Enhanced Developer Experience
|
||||
- **IDE support**: Full IntelliSense autocomplete and navigation
|
||||
- **Refactoring safety**: Type-safe code modifications and renames
|
||||
- **Self-documenting code**: Type definitions serve as living documentation
|
||||
- **Better debugging**: Clear type information in debuggers and stack traces
|
||||
|
||||
### Low Priority
|
||||
1. **UI Improvements**
|
||||
- Optional TUI for profile management
|
||||
- Web dashboard for session history
|
||||
- Visual delegation flow diagrams
|
||||
#### Maintainability Improvements
|
||||
- **Code navigation**: Easy "go to definition" across the entire codebase
|
||||
- **Impact analysis**: Clear understanding of where types are used
|
||||
- **API documentation**: Types define precise interfaces for all components
|
||||
- **Future-proofing**: Easier to add new features without breaking existing code
|
||||
|
||||
2. **Configuration Enhancements**
|
||||
- Profile templates (copy settings from existing)
|
||||
- Bulk profile operations
|
||||
- Export/import profiles
|
||||
#### Architecture Enhancements
|
||||
- **Modular type system**: Comprehensive type definitions in `src/types/`
|
||||
- **Strict configuration**: TypeScript strict mode enabled for maximum safety
|
||||
- **Build pipeline**: Automated compilation with source maps and declarations
|
||||
- **Cross-platform consistency**: Types ensure consistent behavior across platforms
|
||||
|
||||
### Phase 01: ESLint Strictness Upgrade Complete ✅
|
||||
|
||||
**Completion Date**: 2025-11-27
|
||||
**Status**: SUCCESS - All quality gates passing
|
||||
|
||||
#### Enhanced Code Quality Standards
|
||||
|
||||
**ESLint Rules Upgraded** (`eslint.config.mjs`):
|
||||
- ✅ `@typescript-eslint/no-unused-vars`: `warn` → `error`
|
||||
- ✅ `@typescript-eslint/no-explicit-any`: `warn` → `error`
|
||||
- ✅ `@typescript-eslint/no-non-null-assertion`: `warn` → `error`
|
||||
|
||||
#### Validation Results
|
||||
- **TypeScript Compilation**: ✅ Zero type errors
|
||||
- **ESLint Validation**: ✅ Zero violations (previously 3 warning rules)
|
||||
- **Test Suite**: ✅ 39/39 tests passing
|
||||
- **Manual Testing**: ✅ All critical commands working
|
||||
- **Code Coverage**: ✅ 10,487 lines of TypeScript code analyzed
|
||||
|
||||
#### Quality Improvements Achieved
|
||||
1. **Type Safety Enhancement**: Zero tolerance for unused variables, explicit any types, and non-null assertions
|
||||
2. **Code Quality Enforcement**: Stricter linting prevents entire categories of potential bugs
|
||||
3. **Maintainability Boost**: Cleaner, more predictable codebase with enforced standards
|
||||
4. **Zero Breaking Changes**: All functionality preserved, enhanced reliability only
|
||||
|
||||
#### Next Phase Readiness
|
||||
- **Phase 02 Status**: ✅ COMPLETED (2025-11-27)
|
||||
- **Focus**: CCS monolithic split (ccs.ts 1071 lines → 593 lines, 44.6% reduction)
|
||||
- **Goal**: ✅ Achieved - 7 modular command handlers created for improved maintainability
|
||||
- **Results**: All validation gates pass, code review: EXCELLENT
|
||||
|
||||
### Phase 02: CCS Split Refactoring Complete ✅
|
||||
|
||||
**Completion Date**: 2025-11-27
|
||||
**Status**: SUCCESS - All objectives achieved
|
||||
|
||||
#### Modular Command Architecture Implementation
|
||||
|
||||
**Command Handler Modules Created:**
|
||||
- ✅ `src/commands/version-command.ts` (3.0KB) - Version display functionality
|
||||
- ✅ `src/commands/help-command.ts` (4.9KB) - Comprehensive help system
|
||||
- ✅ `src/commands/install-command.ts` (957B) - Install/uninstall operations
|
||||
- ✅ `src/commands/doctor-command.ts` (415B) - System diagnostics
|
||||
- ✅ `src/commands/sync-command.ts` (1.0KB) - Configuration synchronization
|
||||
- ✅ `src/commands/shell-completion-command.ts` (2.1KB) - Shell completion management
|
||||
|
||||
**New Utility Modules:**
|
||||
- ✅ `src/utils/shell-executor.ts` (1.5KB) - Cross-platform shell execution
|
||||
- ✅ `src/utils/package-manager-detector.ts` (3.8KB) - Package manager detection
|
||||
|
||||
#### Refactoring Metrics Achieved
|
||||
|
||||
**File Size Reduction:**
|
||||
- **src/ccs.ts**: 1,071 → 593 lines (**44.6% reduction**)
|
||||
- **Main file focus**: Now contains only routing logic + profile detection + GLMT proxy
|
||||
- **Maintainability**: Enhanced through modular command separation
|
||||
|
||||
**Code Organization:**
|
||||
- **Before**: 1 monolithic file handling all commands
|
||||
- **After**: 7 focused command handlers + 2 utility modules
|
||||
- **Benefits**: Easier testing, better code navigation, focused responsibilities
|
||||
|
||||
#### Architecture Improvements
|
||||
|
||||
**Enhanced Modularity:**
|
||||
- **Command Handlers**: Each command isolated in dedicated module
|
||||
- **Utility Functions**: Cross-platform shell execution and package management
|
||||
- **Clean Separation**: Main file focuses on orchestration only
|
||||
- **Type Safety**: All new modules maintain 100% TypeScript compliance
|
||||
|
||||
**Maintainability Benefits:**
|
||||
- **Single Responsibility**: Each module has focused purpose
|
||||
- **Easy Testing**: Command handlers can be tested independently
|
||||
- **Code Navigation**: Developers can quickly locate specific functionality
|
||||
- **Future Enhancements**: New commands can be added without touching main file
|
||||
|
||||
#### Validation Results
|
||||
- **TypeScript Compilation**: ✅ Zero type errors
|
||||
- **ESLint Validation**: ✅ Zero violations
|
||||
- **Test Suite**: ✅ All tests passing
|
||||
- **Functionality**: ✅ 100% feature preservation
|
||||
- **Performance**: ✅ No degradation, minor improvement due to smaller main file
|
||||
|
||||
#### Code Quality Assessment
|
||||
- **Review Status**: EXCELLENT
|
||||
- **Architecture**: Clean modular design
|
||||
- **Type Safety**: Comprehensive TypeScript coverage
|
||||
- **Documentation**: Well-documented interfaces and functions
|
||||
- **Future-Proofing**: Scalable architecture for additional commands
|
||||
|
||||
### Configuration Architecture Improvements
|
||||
- **Shared Settings**: v4.4 introduces unified `settings.json` sharing across profiles
|
||||
- **Plugin Support**: Enhanced shared directory structure with plugin support
|
||||
- **Error Handling**: Improved error management with typed error codes
|
||||
- **Shell Completion**: PowerShell compatibility improvements
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Updated Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#1f2937', 'edgeLabelBackground':'#374151', 'clusterBkg':'#374151'}}}%%
|
||||
graph TB
|
||||
subgraph "TypeScript Source Layer (Phase 02 Modular)"
|
||||
MAIN[src/ccs.ts - Main Entry - 593 lines]
|
||||
CMDS[src/commands/ - Modular Commands]
|
||||
AUTH[src/auth/ - Authentication]
|
||||
DELEGATE[src/delegation/ - AI Delegation]
|
||||
GLMT[src/glmt/ - GLM Thinking]
|
||||
MGMT[src/management/ - System Mgmt]
|
||||
UTILS[src/utils/ - Cross-Platform Utils]
|
||||
TYPES[src/types/ - Type Definitions]
|
||||
|
||||
subgraph "Command Handlers"
|
||||
VER[version-command.ts]
|
||||
HELP[help-command.ts]
|
||||
INSTALL[install-command.ts]
|
||||
DOCTOR[doctor-command.ts]
|
||||
SYNC[sync-command.ts]
|
||||
SHELL[shell-completion-command.ts]
|
||||
end
|
||||
|
||||
subgraph "New Utils"
|
||||
SHELL_EXEC[shell-executor.ts]
|
||||
PKG_MGR[package-manager-detector.ts]
|
||||
end
|
||||
end
|
||||
|
||||
%% Modular connections
|
||||
MAIN --> CMDS
|
||||
CMDS --> VER
|
||||
CMDS --> HELP
|
||||
CMDS --> INSTALL
|
||||
CMDS --> DOCTOR
|
||||
CMDS --> SYNC
|
||||
CMDS --> SHELL
|
||||
UTILS --> SHELL_EXEC
|
||||
UTILS --> PKG_MGR
|
||||
|
||||
subgraph "Build Output Layer"
|
||||
DIST[dist/ccs.js - Compiled Output]
|
||||
DECL[dist/ccs.d.ts - Type Declarations]
|
||||
MAP[dist/ccs.js.map - Source Maps]
|
||||
end
|
||||
|
||||
subgraph "Native Integration Layer"
|
||||
BASH[lib/ccs - Bash Implementation]
|
||||
PS[lib/ccs.ps1 - PowerShell Implementation]
|
||||
SHELLS[Shell Completion Scripts]
|
||||
end
|
||||
|
||||
subgraph "Runtime Environment"
|
||||
CLAUDE[Claude CLI]
|
||||
CONFIG[Configuration Files]
|
||||
SHARED[~/.ccs/shared/]
|
||||
INSTANCES[Isolated Instances]
|
||||
end
|
||||
|
||||
MAIN --> DIST
|
||||
AUTH --> DIST
|
||||
DELEGATE --> DIST
|
||||
GLMT --> DIST
|
||||
MGMT --> DIST
|
||||
UTILS --> DIST
|
||||
TYPES --> DECL
|
||||
|
||||
DIST --> CLAUDE
|
||||
BASH --> CLAUDE
|
||||
PS --> CLAUDE
|
||||
SHELLS --> CLAUDE
|
||||
CONFIG --> CLAUDE
|
||||
SHARED --> CLAUDE
|
||||
INSTANCES --> CLAUDE
|
||||
```
|
||||
|
||||
### TypeScript Architecture Benefits
|
||||
|
||||
#### Type System Organization
|
||||
```
|
||||
src/types/
|
||||
├── cli.ts # CLI interface definitions
|
||||
├── config.ts # Configuration type schemas
|
||||
├── delegation.ts # Delegation system types
|
||||
├── glmt.ts # GLMT-specific types
|
||||
├── utils.ts # Utility function types
|
||||
└── index.ts # Central type exports
|
||||
```
|
||||
|
||||
#### Component Type Safety
|
||||
- **Authentication**: Type-safe profile management and command execution
|
||||
- **Delegation**: Structured delegation workflows with typed result handling
|
||||
- **GLMT**: Type-safe transformation pipelines and proxy management
|
||||
- **Management**: Typed diagnostics and instance management
|
||||
- **Utils**: Cross-platform utilities with consistent interfaces
|
||||
|
||||
#### Build Pipeline
|
||||
- **TypeScript Compiler**: `tsc` with strict configuration and comprehensive checks
|
||||
- **Declaration Generation**: Automatic `.d.ts` file generation for API documentation
|
||||
- **Source Maps**: Debug-friendly mapping back to TypeScript source files
|
||||
- **Incremental Builds**: Fast rebuilds with TypeScript incremental compilation
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Next Development Phases
|
||||
|
||||
#### Phase 1: TypeScript Foundation Stabilization (v4.4.1 - v4.4.5)
|
||||
**Timeline**: Immediate - 4 weeks
|
||||
|
||||
**Priorities**:
|
||||
1. **Type System Refinement**
|
||||
- Migrate remaining `any` types (target: zero any types)
|
||||
- Add generic constraints for better type inference
|
||||
- Implement branded types for enhanced type safety
|
||||
- Create utility types for common patterns
|
||||
|
||||
2. **Build System Enhancement**
|
||||
- Add TypeScript ESLint with strict rules
|
||||
- Implement pre-commit type checking
|
||||
- Add automated type coverage reporting
|
||||
- Enhance development build performance
|
||||
|
||||
3. **Testing Infrastructure**
|
||||
- Migrate tests to TypeScript
|
||||
- Add type-safe test utilities
|
||||
- Implement mock type definitions
|
||||
- Create type-safe test data fixtures
|
||||
|
||||
#### Phase 2: Advanced Delegation System (v4.5.0)
|
||||
**Timeline**: 1-2 months
|
||||
|
||||
**TypeScript-Driven Features**:
|
||||
1. **Smart Delegation Routing**
|
||||
- Type-based task classification system
|
||||
- Intelligent profile selection algorithms
|
||||
- Cost estimation with typed calculation models
|
||||
- Performance optimization through type-aware caching
|
||||
|
||||
2. **Enhanced Session Management**
|
||||
- Type-safe session persistence with serialization
|
||||
- Advanced session resumption with state restoration
|
||||
- Multi-turn conversation context management
|
||||
- Session analytics with typed metrics
|
||||
|
||||
3. **Plugin System Foundation**
|
||||
- Type-safe plugin API definitions
|
||||
- Plugin discovery and registration system
|
||||
- Sandboxed plugin execution environment
|
||||
- Community plugin marketplace infrastructure
|
||||
|
||||
#### Phase 3: Enterprise Features (v4.6.0)
|
||||
**Timeline**: 2-3 months
|
||||
|
||||
**Enterprise-Grade TypeScript Features**:
|
||||
1. **Team Profile Management**
|
||||
- Type-safe role-based access control (RBAC)
|
||||
- Centralized configuration management
|
||||
- Audit logging with typed event structures
|
||||
- Compliance reporting with structured data formats
|
||||
|
||||
2. **Advanced Analytics Dashboard**
|
||||
- Real-time usage metrics with typed data streams
|
||||
- Cost tracking and budgeting with financial types
|
||||
- Team productivity analytics with KPI types
|
||||
- Performance monitoring with alert types
|
||||
|
||||
3. **CI/CD Integration**
|
||||
- Type-safe GitHub Actions workflows
|
||||
- Automated testing with type validation
|
||||
- Deployment pipelines with type checks
|
||||
- Infrastructure as Code with TypeScript
|
||||
|
||||
### Technical Debt Resolution
|
||||
|
||||
#### Immediate Priorities (v4.4.1)
|
||||
1. **GLMT System Refactoring**
|
||||
- Transform GLMT proxy with typed request/response handling
|
||||
- Implement type-safe SSE parsing with error recovery
|
||||
- Add typed transformation pipelines for API compatibility
|
||||
- Create comprehensive GLMT test suites with type safety
|
||||
|
||||
2. **Error Handling Enhancement**
|
||||
- Implement typed error hierarchies with inheritance
|
||||
- Create structured error reporting with localization
|
||||
- Add automatic error recovery with typed retry logic
|
||||
- Develop error analytics with classification systems
|
||||
|
||||
3. **Performance Optimization**
|
||||
- Profile and optimize hot paths with typed benchmarks
|
||||
- Implement memory-efficient data structures with generics
|
||||
- Add lazy loading patterns with type-safe initialization
|
||||
- Create performance monitoring with typed metrics
|
||||
|
||||
#### Medium-term Improvements (v4.5.0)
|
||||
1. **Cross-Platform Consistency**
|
||||
- Unify behavior across platforms with typed abstractions
|
||||
- Implement platform-specific optimizations with type guards
|
||||
- Create comprehensive cross-platform test suites
|
||||
- Add platform-specific feature detection
|
||||
|
||||
2. **API Evolution**
|
||||
- Design backward-compatible API evolution strategies
|
||||
- Implement API versioning with type migration paths
|
||||
- Create deprecation warnings with upgrade guidance
|
||||
- Add automated API compatibility testing
|
||||
|
||||
## Future Vision (v5.0+)
|
||||
|
||||
### Long-term Goals
|
||||
### Next-Generation Architecture
|
||||
|
||||
#### AI-Powered Features
|
||||
1. **Automatic Task Classification**
|
||||
- Analyze prompt to suggest best model (glm, kimi, claude)
|
||||
- Estimate cost and duration before execution
|
||||
- Smart profile recommendations
|
||||
#### AI-Powered TypeScript Development
|
||||
1. **Intelligent Code Generation**
|
||||
- TypeScript-aware AI code completion
|
||||
- Automatic type inference from usage patterns
|
||||
- Smart refactoring suggestions with type analysis
|
||||
- Code quality recommendations based on type metrics
|
||||
|
||||
2. **Intelligent Model Selection**
|
||||
- Context-aware routing (task complexity → model)
|
||||
- Cost optimization strategies
|
||||
- Performance-based model selection
|
||||
2. **Adaptive Type System**
|
||||
- Dynamic type generation based on runtime behavior
|
||||
- Machine learning-assisted type predictions
|
||||
- Automatic interface extraction from APIs
|
||||
- Smart type narrowing with statistical analysis
|
||||
|
||||
3. **Context-Aware Delegation**
|
||||
- Understand project context from git history
|
||||
- Suggest relevant delegation tasks
|
||||
- Learn from past delegation patterns
|
||||
#### Enterprise TypeScript Platform
|
||||
1. **Advanced Team Collaboration**
|
||||
- Real-time collaborative TypeScript editing
|
||||
- Shared type libraries across projects
|
||||
- Automated type governance policies
|
||||
- Team-wide type consistency enforcement
|
||||
|
||||
#### Enterprise Features
|
||||
1. **Team Profile Sharing**
|
||||
- Centralized profile repository
|
||||
- Team-wide API key management
|
||||
- Access control and permissions
|
||||
2. **Comprehensive Analytics**
|
||||
- Type usage analytics and optimization recommendations
|
||||
- Code quality metrics with type safety scores
|
||||
- Developer productivity tracking with type metrics
|
||||
- Technical debt measurement with type analysis
|
||||
|
||||
2. **Usage Analytics Dashboard**
|
||||
- Delegation statistics and trends
|
||||
- Cost tracking and budgeting
|
||||
- Team productivity metrics
|
||||
#### Ecosystem Integration
|
||||
1. **Package Management**
|
||||
- Type-safe dependency management
|
||||
- Automated vulnerability scanning with type analysis
|
||||
- Smart version resolution with compatibility checking
|
||||
- Package quality assessment with type metrics
|
||||
|
||||
3. **Centralized Configuration Management**
|
||||
- Cloud-based config synchronization
|
||||
- Multi-machine profile sync
|
||||
- Backup and restore
|
||||
2. **Tool Integration**
|
||||
- TypeScript-native IDE extensions
|
||||
- Enhanced debugging with type information
|
||||
- Performance profiling with type insights
|
||||
- Automated testing with type-driven test generation
|
||||
|
||||
#### Ecosystem Expansion
|
||||
1. **Plugin System for Custom Models**
|
||||
- Plugin API for third-party model integration
|
||||
- Community plugin marketplace
|
||||
- Custom transformation pipelines
|
||||
## Release Notes
|
||||
|
||||
2. **CI/CD Pipeline Integration**
|
||||
- GitHub Actions integration
|
||||
- GitLab CI integration
|
||||
- Automated delegation in workflows
|
||||
### Version 4.4.0 - TypeScript Conversion Complete
|
||||
**Release Date**: 2025-11-25
|
||||
|
||||
3. **Cloud-Based Session Synchronization**
|
||||
- Session history across machines
|
||||
- Collaborative delegation sessions
|
||||
- Team delegation sharing
|
||||
#### Major Features
|
||||
- ✅ **Complete TypeScript Conversion**: All 31 source files migrated to TypeScript
|
||||
- ✅ **Zero `any` Types**: 100% type coverage with comprehensive type definitions
|
||||
- ✅ **Enhanced Build Pipeline**: Automated compilation with source maps and declarations
|
||||
- ✅ **Shared Settings Architecture**: Unified `settings.json` across all profiles
|
||||
- ✅ **Plugin Support**: Enhanced shared directory structure
|
||||
- ✅ **ESLint Strictness Upgrade**: Phase 01 completed (2025-11-27) - 3 rules upgraded to error level, 0 violations found
|
||||
- ✅ **CCS Architecture Refactoring**: Phase 02 completed (2025-11-27) - 44.6% file size reduction, 9 modular components created
|
||||
|
||||
## Deprecated Features
|
||||
#### Technical Improvements
|
||||
- **Type Safety**: Compile-time error detection eliminates entire bug categories
|
||||
- **Developer Experience**: Full IDE support with IntelliSense and navigation
|
||||
- **Maintainability**: Self-documenting code with comprehensive type definitions
|
||||
- **Cross-Platform**: Typed abstractions ensure consistent behavior
|
||||
|
||||
### Removed in v3.0
|
||||
- ❌ **Vault-Based Credential Encryption**: Replaced with login-per-profile
|
||||
- ❌ **`ccs auth save` command**: Replaced with `ccs auth create`
|
||||
- ❌ **macOS-Specific Credential Switcher**: Unified across platforms
|
||||
#### Breaking Changes
|
||||
- **None**: Fully backward compatible with existing configurations
|
||||
- **Migration**: Seamless upgrade path with zero user impact
|
||||
|
||||
### Removed in v2.x
|
||||
- ❌ **Early profile management**: Replaced with more robust system
|
||||
#### Dependencies
|
||||
- **TypeScript 5.3**: Updated to latest stable version
|
||||
- **Enhanced Build Process**: Automated compilation and type checking
|
||||
- **Development Tools**: Improved development tooling and debugging
|
||||
|
||||
## Experimental Features
|
||||
### Recent Patch Updates
|
||||
- **v4.3.10**: Package manager cache clearing during updates
|
||||
- **v4.3.9**: Fixed missing `commands/ccs.md` symlink in npm install
|
||||
- **v4.3.8**: Resolved missing `~/.ccs/.claude/` directory creation
|
||||
- **v4.3.7**: Enhanced directory creation during npm install
|
||||
- **v4.3.6**: Added plugin support to shared directories
|
||||
- **v4.4.0**: TypeScript conversion complete, shared settings architecture
|
||||
|
||||
### GLMT (Stable Experimental)
|
||||
- **Status**: Maintained but not actively developed
|
||||
- **Version**: v3.2-v3.6 (frozen)
|
||||
- **Limitations**:
|
||||
- Unstable tool support
|
||||
- Streaming issues
|
||||
- Language forcing required (locale-enforcer)
|
||||
- **Alternative**: Use ZaiTransformer for production GLMT
|
||||
- **Future**: May be deprecated in v5.0 if Z.AI improves native support
|
||||
## Testing Strategy
|
||||
|
||||
### Stream-JSON Parsing (Active)
|
||||
- **Status**: Actively developed in v4.x
|
||||
- **Version**: v4.0-v4.3.2
|
||||
- **Current State**: Stable for 13+ Claude Code tools
|
||||
- **Future**: Expand tool support, improve parsing reliability
|
||||
### TypeScript Testing Infrastructure
|
||||
1. **Unit Testing with Type Safety**
|
||||
- Mocha tests with TypeScript compilation
|
||||
- Type-safe test utilities and fixtures
|
||||
- Mock implementations with interface compliance
|
||||
- Coverage reporting with type metrics
|
||||
|
||||
## Version History
|
||||
2. **Integration Testing**
|
||||
- Cross-platform compatibility validation
|
||||
- End-to-end workflow testing with typed data
|
||||
- API contract testing with type validation
|
||||
- Performance testing with type-aware profiling
|
||||
|
||||
| Version | Date | Key Features |
|
||||
|---------|------|--------------|
|
||||
| **v4.3.2** | 2025-11-17 | Session continuation, stream-JSON enhancements |
|
||||
| **v4.2.0** | 2025-11-16 | Diagnostics suite (doctor, sync, update) |
|
||||
| **v4.1.4** | 2025-11-15 | Shell completion v2 with color-coding |
|
||||
| **v4.1.0** | 2025-11-15 | Selective .claude/ symlinking |
|
||||
| **v4.0.0** | 2025-11-14 | AI delegation system, headless execution |
|
||||
| **v3.6.x** | 2025-XX-XX | GLMT loop detection, locale enforcer |
|
||||
| **v3.3.0** | 2025-XX-XX | GLMT thinking mode, debug logging |
|
||||
| **v3.2.0** | 2025-XX-XX | GLMT proxy, concurrent sessions |
|
||||
| **v3.1.0** | 2025-XX-XX | Shared data architecture |
|
||||
| **v3.0.0** | 2025-XX-XX | Vault removal, login-per-profile |
|
||||
| **v2.x** | 2024-XX-XX | Multi-account management, vault encryption |
|
||||
3. **Type-Level Testing**
|
||||
- TypeScript compiler error verification
|
||||
- Type coverage measurement and reporting
|
||||
- API surface validation with type checking
|
||||
- Dependency graph analysis with type relationships
|
||||
|
||||
## Breaking Changes
|
||||
## Quality Assurance
|
||||
|
||||
### v4.x → v5.0 (Planned)
|
||||
- **Potential**: GLMT removal if Z.AI improves
|
||||
- **Potential**: Configuration schema changes for enterprise features
|
||||
- **Migration**: Migration tools will be provided
|
||||
### TypeScript Quality Metrics
|
||||
- **Type Coverage**: 100% (target: maintain zero any types)
|
||||
- **Compiler Strictness**: Maximum strict mode configuration
|
||||
- **Interface Compliance**: All exports properly typed
|
||||
- **Documentation Coverage**: Type definitions serve as documentation
|
||||
|
||||
### v3.x → v4.x
|
||||
- **Zero Breaking Changes**: Fully backward compatible
|
||||
- **New Features**: Delegation, symlinking, diagnostics (opt-in)
|
||||
- **Migration**: No migration required
|
||||
|
||||
### v2.x → v3.0
|
||||
- **Breaking**: `ccs auth save` → `ccs auth create`
|
||||
- **Breaking**: Profile schema change (vault removed)
|
||||
- **Breaking**: Removed `auth current`, `auth cleanup` commands
|
||||
- **Migration**: Manual profile recreation required
|
||||
|
||||
## Roadmap Decision Process
|
||||
|
||||
### Feature Prioritization
|
||||
1. **User Impact**: Features most requested by users
|
||||
2. **Technical Debt**: Addressing long-standing issues
|
||||
3. **Strategic Alignment**: Moving toward v5.0 vision
|
||||
4. **Resource Availability**: Development capacity
|
||||
|
||||
### Version Planning
|
||||
- **Patch (x.x.X)**: Bug fixes, small improvements
|
||||
- **Minor (x.X.0)**: New features, backward compatible
|
||||
- **Major (X.0.0)**: Breaking changes, major overhaul
|
||||
### Code Quality Standards
|
||||
- **ESLint Integration**: TypeScript-specific linting rules
|
||||
- **Pre-commit Hooks**: Automated type checking before commits
|
||||
- **CI/CD Pipeline**: Type validation in continuous integration
|
||||
- **Code Review**: Type safety as review requirement
|
||||
|
||||
## Contributing to Roadmap
|
||||
|
||||
Community input is welcome. To suggest features:
|
||||
1. Open GitHub issue with `[Feature Request]` tag
|
||||
2. Describe use case and expected behavior
|
||||
3. Provide examples or mockups if applicable
|
||||
4. Discuss trade-offs and alternatives
|
||||
### TypeScript Development Guidelines
|
||||
1. **Type-First Development**: Define types before implementation
|
||||
2. **Zero Any Policy**: Maintain 100% type coverage
|
||||
3. **Interface Documentation**: Comprehensive JSDoc with type examples
|
||||
4. **Backward Compatibility**: Evolve APIs without breaking changes
|
||||
|
||||
**Current Focus**: v4.4-v4.5 delegation improvements and performance optimization
|
||||
### Feature Contribution Process
|
||||
1. **Type Design**: Submit type definitions for review
|
||||
2. **Implementation**: Type-safe implementation with comprehensive tests
|
||||
3. **Documentation**: Update type documentation and examples
|
||||
4. **Review**: Peer review focused on type safety and API design
|
||||
|
||||
## Community and Ecosystem
|
||||
|
||||
### TypeScript Community Engagement
|
||||
- **TypeScript Best Practices**: Share learnings with TypeScript community
|
||||
- **Open Source Contribution**: Contribute to TypeScript tooling ecosystem
|
||||
- **Knowledge Sharing**: Document TypeScript migration experience
|
||||
- **Community Support**: Help other projects with TypeScript adoption
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-21 (v4.3.2)
|
||||
**Document Status**: Living document, updated with each major release
|
||||
**Last Updated**: 2025-11-27 (Phase 01 ESLint Strictness + Phase 02 CCS Split Complete)
|
||||
**Next Update**: Future development phases planning
|
||||
**Maintainer**: CCS Development Team
|
||||
+217
-45
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Current version **v4.3.2** features AI-powered delegation system, selective .claude/ directory symlinking, stream-JSON output, shell completion (4 shells), and comprehensive diagnostics (doctor, sync, update commands).
|
||||
CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5, GLM 4.6, GLMT (GLM with Thinking), and Kimi for Coding models. Current version **v4.5.0** features AI-powered delegation system, selective .claude/ directory symlinking, stream-JSON output, shell completion (4 shells), and comprehensive diagnostics (doctor, sync, update commands). v4.5.0 completes transition to Node.js-first architecture with bootstrap-based installers.
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
@@ -71,13 +71,13 @@ graph TB
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### 1. Main Entry Point (`bin/ccs.js` - ~800 lines)
|
||||
### 1. Main Entry Point (`src/ccs.ts` - 593 lines, Phase 02 Refactored)
|
||||
|
||||
**Role**: Central orchestrator for all CCS operations
|
||||
**Role**: Central orchestrator for all CCS operations (Post-Phase 02 modular architecture)
|
||||
|
||||
**Key Responsibilities**:
|
||||
- Argument parsing and profile detection
|
||||
- Special command handling (--version, --help, --shell-completion, auth, doctor, sync, update)
|
||||
- **Command routing to modular handlers** (Phase 02 enhancement)
|
||||
- Delegation detection (`-p` / `--prompt` flag routing)
|
||||
- Profile type routing (settings-based vs account-based)
|
||||
- GLMT proxy lifecycle management
|
||||
@@ -85,6 +85,12 @@ graph TB
|
||||
- Error propagation and exit code management
|
||||
- Auto-recovery for missing configuration
|
||||
|
||||
**Phase 02 Refactoring Achievement**:
|
||||
- **Size reduction**: 1,071 → 593 lines (**44.6% reduction**)
|
||||
- **Modularization**: 6 command handlers extracted to dedicated modules
|
||||
- **Maintainability**: Single responsibility principle applied to all commands
|
||||
- **Focus**: Now contains only core routing, profile detection, and GLMT proxy logic
|
||||
|
||||
**Architecture with Delegation Support (v4.3.2)**:
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -277,6 +283,135 @@ graph TD
|
||||
"default": "work"
|
||||
}
|
||||
```
|
||||
## Modular Command Architecture (Phase 02, 2025-11-27)
|
||||
|
||||
### Overview
|
||||
|
||||
The modular command architecture separates command handling logic from the main orchestrator, achieving significant improvements in maintainability, testability, and code organization.
|
||||
|
||||
### Components
|
||||
|
||||
**Command Handler Modules** (`src/commands/`):
|
||||
|
||||
**1. Version Command Handler (`src/commands/version-command.ts` - 3.0KB)**
|
||||
- Handles `--version` flag display
|
||||
- Shows version number, build location, and platform information
|
||||
- Delegates version formatting and display logic from main file
|
||||
|
||||
**2. Help Command Handler (`src/commands/help-command.ts` - 4.9KB)**
|
||||
- Handles `--help` flag display
|
||||
- Provides comprehensive help including profile listings
|
||||
- Supports delegation help with usage examples
|
||||
- Dynamically generates help content based on available profiles
|
||||
|
||||
**3. Install Command Handler (`src/commands/install-command.ts` - 957B)**
|
||||
- Handles `--install` flag for setup instructions
|
||||
- Manages installation and uninstallation workflows
|
||||
- Cross-platform compatibility support
|
||||
|
||||
**4. Doctor Command Handler (`src/commands/doctor-command.ts` - 415B)**
|
||||
- Handles `doctor` subcommand for system diagnostics
|
||||
- Validates installation, configuration, and profile status
|
||||
- Provides health check functionality
|
||||
|
||||
**5. Sync Command Handler (`src/commands/sync-command.ts` - 1.0KB)**
|
||||
- Handles `sync` subcommand for configuration synchronization
|
||||
- Repairs broken symlinks and directory structures
|
||||
- Maintains shared data consistency
|
||||
|
||||
**6. Shell Completion Command Handler (`src/commands/shell-completion-command.ts` - 2.1KB)**
|
||||
- Handles `--shell-completion` flag and `-sc` alias
|
||||
- Installs shell completion scripts for Bash, Zsh, Fish, PowerShell
|
||||
- Manages shell-specific completion logic
|
||||
|
||||
**New Utility Modules** (`src/utils/`):
|
||||
|
||||
**1. Shell Executor (`src/utils/shell-executor.ts` - 1.5KB)**
|
||||
- Cross-platform shell command execution utilities
|
||||
- Handles process spawning, signal management, and output capture
|
||||
- Provides consistent shell interface across platforms
|
||||
|
||||
**2. Package Manager Detector (`src/utils/package-manager-detector.ts` - 3.8KB)**
|
||||
- Detects available package managers (npm, yarn, pnpm, bun)
|
||||
- Cross-platform package manager identification
|
||||
- Supports installation and update workflows
|
||||
|
||||
### Modular Architecture Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Main Entry Point (src/ccs.ts)"
|
||||
ARGS[Parse Arguments]
|
||||
ROUTE[Command Router]
|
||||
SPECIAL{Special Command?}
|
||||
end
|
||||
|
||||
subgraph "Modular Command Handlers (src/commands/)"
|
||||
VERSION[version-command.ts]
|
||||
HELP[help-command.ts]
|
||||
INSTALL[install-command.ts]
|
||||
DOCTOR[doctor-command.ts]
|
||||
SYNC[sync-command.ts]
|
||||
COMPLETION[shell-completion-command.ts]
|
||||
end
|
||||
|
||||
subgraph "Utility Modules (src/utils/)"
|
||||
SHELL_EXEC[shell-executor.ts]
|
||||
PKG_MGR[package-manager-detector.ts]
|
||||
end
|
||||
|
||||
ARGS --> ROUTE
|
||||
ROUTE --> SPECIAL
|
||||
SPECIAL -->|--version| VERSION
|
||||
SPECIAL -->|--help| HELP
|
||||
SPECIAL -->|--install| INSTALL
|
||||
SPECIAL -->|doctor| DOCTOR
|
||||
SPECIAL -->|sync| SYNC
|
||||
SPECIAL -->|--shell-completion| COMPLETION
|
||||
|
||||
VERSION --> SHELL_EXEC
|
||||
HELP --> SHELL_EXEC
|
||||
INSTALL --> PKG_MGR
|
||||
```
|
||||
|
||||
### Phase 02 Benefits Achieved
|
||||
|
||||
**Maintainability Improvements:**
|
||||
- **Single Responsibility**: Each command has focused, dedicated module
|
||||
- **Code Navigation**: Developers can quickly locate specific command logic
|
||||
- **Testing Independence**: Command handlers can be unit tested in isolation
|
||||
- **Reduced Complexity**: Main file focuses on orchestration only
|
||||
|
||||
**Development Workflow Enhancements:**
|
||||
- **Parallel Development**: Multiple developers can work on different commands simultaneously
|
||||
- **Feature Isolation**: Changes to one command don't affect others
|
||||
- **Code Review Efficiency**: Smaller, focused pull requests for command modifications
|
||||
- **Debugging Simplification**: Issues can be isolated to specific command modules
|
||||
|
||||
**Architecture Scalability:**
|
||||
- **Easy Extension**: New commands can be added without modifying main orchestrator
|
||||
- **Consistent Patterns**: All command handlers follow established patterns
|
||||
- **Type Safety**: Comprehensive TypeScript coverage across all modules
|
||||
- **Performance**: No performance degradation, minor improvement due to smaller main file
|
||||
|
||||
### Command Handler Interface Pattern
|
||||
|
||||
All command handlers follow a consistent interface pattern:
|
||||
|
||||
```typescript
|
||||
interface CommandHandler {
|
||||
handle(args: string[]): Promise<void>;
|
||||
requiresProfile?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
This standardized interface ensures:
|
||||
- **Consistent API**: All commands can be called uniformly
|
||||
- **Type Safety**: TypeScript ensures proper argument handling
|
||||
- **Future Extension**: New commands can easily conform to the pattern
|
||||
- **Testing**: Mock interfaces can be created for unit testing
|
||||
|
||||
## Delegation Architecture (v4.0+)
|
||||
|
||||
### Overview
|
||||
@@ -764,45 +899,57 @@ sequenceDiagram
|
||||
├── .anthropic/
|
||||
└── .credentials.json
|
||||
|
||||
bin/ # CCS source files (v4.3.2)
|
||||
├── ccs.js # Main entry point (~800 lines)
|
||||
├── auth/ # Auth system (~800 lines)
|
||||
│ ├── auth-create.js
|
||||
│ ├── auth-delete.js
|
||||
│ ├── auth-list.js
|
||||
│ └── auth-switch.js
|
||||
├── delegation/ # Delegation system (~1,200 lines)
|
||||
│ ├── delegation-handler.js
|
||||
│ ├── headless-executor.js
|
||||
│ ├── session-manager.js
|
||||
│ ├── result-formatter.js
|
||||
│ └── settings-parser.js
|
||||
├── glmt/ # GLMT system (~700 lines)
|
||||
│ ├── glmt-proxy.js
|
||||
│ ├── glmt-transformer.js
|
||||
│ ├── locale-enforcer.js
|
||||
│ ├── reasoning-enforcer.js
|
||||
│ ├── sse-parser.js
|
||||
│ └── delta-accumulator.js
|
||||
├── management/ # Management system (~600 lines)
|
||||
│ ├── config-manager.js
|
||||
│ ├── instance-manager.js
|
||||
│ ├── profile-detector.js
|
||||
│ ├── profile-registry.js
|
||||
│ ├── shared-manager.js
|
||||
│ ├── doctor.js
|
||||
│ ├── sync.js
|
||||
│ └── recovery-manager.js
|
||||
├── utils/ # Utilities (~1,500 lines)
|
||||
│ ├── claude-detector.js
|
||||
│ ├── claude-dir-installer.js
|
||||
│ ├── claude-symlink-manager.js
|
||||
│ ├── delegation-validator.js
|
||||
│ ├── shell-completion.js
|
||||
│ ├── update-checker.js
|
||||
│ ├── helpers.js
|
||||
│ └── error-manager.js
|
||||
└── ...
|
||||
src/ # TypeScript source files (Phase 02 Modular Architecture)
|
||||
├── ccs.ts # Main entry point (593 lines, 44.6% reduction)
|
||||
├── commands/ # Modular command handlers (Phase 02)
|
||||
│ ├── version-command.ts # 3.0KB - Version display
|
||||
│ ├── help-command.ts # 4.9KB - Help system
|
||||
│ ├── install-command.ts # 957B - Install/uninstall
|
||||
│ ├── doctor-command.ts # 415B - System diagnostics
|
||||
│ ├── sync-command.ts # 1.0KB - Configuration sync
|
||||
│ └── shell-completion-command.ts # 2.1KB - Shell completion
|
||||
├── auth/ # Authentication system
|
||||
│ ├── auth-commands.ts
|
||||
│ ├── profile-detector.ts
|
||||
│ └── profile-registry.ts
|
||||
├── delegation/ # AI delegation system
|
||||
│ ├── delegation-handler.ts
|
||||
│ ├── headless-executor.ts
|
||||
│ ├── session-manager.ts
|
||||
│ ├── result-formatter.ts
|
||||
│ └── settings-parser.ts
|
||||
├── glmt/ # GLMT thinking mode system
|
||||
│ ├── glmt-proxy.ts
|
||||
│ ├── glmt-transformer.ts
|
||||
│ ├── locale-enforcer.ts
|
||||
│ ├── reasoning-enforcer.ts
|
||||
│ ├── sse-parser.ts
|
||||
│ └── delta-accumulator.ts
|
||||
├── management/ # System management
|
||||
│ ├── doctor.ts
|
||||
│ ├── instance-manager.ts
|
||||
│ ├── shared-manager.ts
|
||||
│ ├── sync.ts
|
||||
│ └── recovery-manager.ts
|
||||
├── utils/ # Cross-platform utilities
|
||||
│ ├── claude-detector.ts
|
||||
│ ├── claude-dir-installer.ts
|
||||
│ ├── claude-symlink-manager.ts
|
||||
│ ├── delegation-validator.ts
|
||||
│ ├── shell-executor.ts # 1.5KB - Phase 02 NEW
|
||||
│ ├── package-manager-detector.ts # 3.8KB - Phase 02 NEW
|
||||
│ ├── shell-completion.ts
|
||||
│ ├── update-checker.ts
|
||||
│ ├── helpers.ts
|
||||
│ └── error-manager.ts
|
||||
├── types/ # TypeScript type definitions
|
||||
│ ├── cli.ts
|
||||
│ ├── config.ts
|
||||
│ ├── delegation.ts
|
||||
│ ├── glmt.ts
|
||||
│ ├── utils.ts
|
||||
│ └── index.ts
|
||||
└── scripts/ # Build and utility scripts
|
||||
|
||||
config/
|
||||
└── base-glmt.settings.json # GLMT template (v3.3.0)
|
||||
@@ -997,6 +1144,9 @@ graph LR
|
||||
|
||||
### Installation Process
|
||||
|
||||
**Two installation methods available:**
|
||||
|
||||
**Method 1: npm Package (Recommended)**
|
||||
1. **Package Download**: User installs via npm/yarn/pnpm/bun
|
||||
2. **Post-install Script** (`scripts/postinstall.js`):
|
||||
- Creates `~/.ccs/` directory structure
|
||||
@@ -1013,6 +1163,19 @@ graph LR
|
||||
3. **Path Configuration**: npm automatically adds to PATH
|
||||
4. **Ready State**: System ready for profile switching
|
||||
|
||||
**Method 2: Native Installers (v4.5.0)**
|
||||
1. **Requirements Check**: Validates Node.js 14+ available
|
||||
2. **Bootstrap Installation**:
|
||||
- Installs lightweight shell wrappers (lib/ccs, lib/ccs.ps1)
|
||||
- Shell wrappers delegate to Node.js via npx on first run
|
||||
- No shell dependencies (error-codes, progress-indicator, prompt removed)
|
||||
3. **First Run**: Bootstrap auto-installs @kaitranntt/ccs npm package globally
|
||||
4. **Completion Message**: Shows requirements section with Node.js version
|
||||
|
||||
**Requirements (v4.5.0+)**:
|
||||
- Node.js 14+ (checked during install, enforced by bootstrap)
|
||||
- npm 5.2+ (for npx, comes with Node.js 8.2+)
|
||||
|
||||
**Idempotency**: Safe to run multiple times, preserves existing configs
|
||||
|
||||
**Migration Logic** (v3.3.0):
|
||||
@@ -1125,7 +1288,8 @@ The architecture provides clean extension points:
|
||||
The CCS system architecture successfully balances simplicity with enhanced functionality:
|
||||
|
||||
**Core Architecture Strengths**:
|
||||
- **Modular Design**: Clear subsystem separation (auth, delegation, glmt, management, utils)
|
||||
- **Modular Design**: Clear subsystem separation (auth, delegation, glmt, management, utils, commands)
|
||||
- **Phase 02 Command Modularity**: 6 specialized command handlers with single responsibility principle
|
||||
- **Unified spawn logic** eliminates code duplication
|
||||
- **Dual-path execution** supports settings-based and account-based profiles
|
||||
- **Isolated Claude instances** enable concurrent sessions via CLAUDE_CONFIG_DIR
|
||||
@@ -1147,16 +1311,24 @@ The CCS system architecture successfully balances simplicity with enhanced funct
|
||||
- **Configuration migration**: Auto-upgrade configs with new fields
|
||||
- **Enhanced settings**: Temperature, max tokens, thinking controls, API timeout
|
||||
|
||||
**Phase 02 Architecture Highlights** (2025-11-27):
|
||||
1. **Modular Command Architecture**: 6 specialized command handlers with single responsibility
|
||||
2. **44.6% Main File Reduction**: src/ccs.ts reduced from 1,071 to 593 lines
|
||||
3. **Enhanced Maintainability**: Focused modules for version, help, install, doctor, sync, shell-completion
|
||||
4. **New Utility Modules**: Cross-platform shell execution and package manager detection
|
||||
5. **TypeScript Excellence**: 100% type coverage across all new modules
|
||||
|
||||
**v4.3.2 Architecture Highlights**:
|
||||
1. **Delegation Architecture**: Stream-JSON parsing, session management, result formatting
|
||||
2. **Symlinking Architecture**: Selective sharing with Windows fallback
|
||||
3. **Shell Completion**: Dynamic profile-aware completions with color-coding
|
||||
4. **Diagnostics Infrastructure**: Doctor validation, sync repairs, update checking
|
||||
5. **Modular Subsystems**: 7 clear subsystems (~8,477 LOC total)
|
||||
5. **Modular Subsystems**: 8 clear subsystems including Phase 02 commands
|
||||
|
||||
**Evolution Path**:
|
||||
- **v2.x → v3.0**: 40% reduction through vault removal, login-per-profile model
|
||||
- **v3.0 → v4.x**: Enhanced capabilities with delegation, symlinking, diagnostics (zero breaking changes)
|
||||
- **v4.x → Phase 02**: Modular command architecture with 44.6% main file reduction
|
||||
- **Future (v5.0+)**: AI-powered features, enterprise capabilities, ecosystem expansion
|
||||
|
||||
The architecture demonstrates how thoughtful design can add sophisticated AI delegation capabilities, shared data management, and comprehensive diagnostics while maintaining simplicity, backward compatibility, and cross-platform support.
|
||||
@@ -82,12 +82,12 @@ When releasing a new version:
|
||||
- [ ] Update version using `./scripts/bump-version.sh X.Y.Z`
|
||||
- [ ] Review changes: `git diff`
|
||||
- [ ] Run comprehensive tests: `npm test`
|
||||
- [ ] Test both installation methods if applicable
|
||||
- [ ] ~~Test both installation methods if applicable~~ (Shell installers DEPRECATED)
|
||||
- [ ] Update CHANGELOG.md with release notes
|
||||
- [ ] Commit changes: `git commit -am "chore: bump version to X.Y.Z"`
|
||||
- [ ] Push: `git push`
|
||||
- [ ] Verify CloudFlare worker serves updated installer
|
||||
- [ ] Publish to npm (if npm package updated): `npm publish`
|
||||
- [ ] ~~Verify CloudFlare worker serves updated installer~~ (Shell installers DEPRECATED)
|
||||
- [ ] Publish to npm: `npm publish`
|
||||
|
||||
## Version Display
|
||||
|
||||
|
||||
+6
-2
@@ -47,10 +47,14 @@ bun add -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Phương án thay thế: Cài Đặt Trực Tiếp (Truyền thống)</strong></summary>
|
||||
<summary><strong>[!] LỖI THỜI: Trình cài đặt shell gốc (Cũ)</strong></summary>
|
||||
|
||||
<br>
|
||||
|
||||
> [!WARNING]
|
||||
> **Các trình cài đặt này đã lỗi thời và sẽ bị xóa trong phiên bản tương lai.**
|
||||
> Hiện tại chúng tự động chuyển hướng đến cài đặt npm. Vui lòng sử dụng npm trực tiếp.
|
||||
|
||||
**macOS / Linux**
|
||||
```bash
|
||||
curl -fsSL ccs.kaitran.ca/install | bash
|
||||
@@ -61,7 +65,7 @@ curl -fsSL ccs.kaitran.ca/install | bash
|
||||
irm ccs.kaitran.ca/install | iex
|
||||
```
|
||||
|
||||
**Lưu ý**: Cài truyền thống bỏ qua Node.js routing để khởi động nhanh hơn, nhưng ưu tiên npm cho dễ dàng tự động hóa triển khai.
|
||||
**Lưu ý**: Script hiển thị cảnh báo lỗi thời và tự động chạy cài đặt npm nếu Node.js khả dụng.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+11
-13
@@ -1,5 +1,9 @@
|
||||
# Hướng Dẫn Cài Đặt CCS
|
||||
|
||||
> [!WARNING]
|
||||
> **Trình cài đặt shell gốc (curl/irm) đã lỗi thời.**
|
||||
> Sử dụng cài đặt npm cho tất cả các nền tảng. Trình cài đặt cũ sẽ bị xóa trong v5.0.
|
||||
|
||||
## Cài Đặt npm Package (Được khuyến nghị)
|
||||
|
||||
### Cài Đặt Đa Nền Tảng
|
||||
@@ -34,7 +38,11 @@ npm install -g @kaitranntt/ccs
|
||||
npm install -g @kaitranntt/ccs --force
|
||||
```
|
||||
|
||||
## Cài Đặt Một Dòng Lệnh (Truyền thống)
|
||||
## [!] LỖI THỜI: Cài Đặt Một Dòng Lệnh (Cũ)
|
||||
|
||||
> [!WARNING]
|
||||
> **Các trình cài đặt này đã lỗi thời và sẽ bị xóa trong v5.0.**
|
||||
> Hiện tại chúng tự động chuyển hướng đến cài đặt npm. Vui lòng sử dụng npm trực tiếp.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -46,8 +54,7 @@ curl -fsSL ccs.kaitran.ca/install | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/install.sh | bash
|
||||
```
|
||||
|
||||
**Vị Trí Cài Đặt**:
|
||||
- **Tất Cả Hệ Thống Unix**: `~/.local/bin/ccs` (tự động cấu hình PATH cho bash, zsh, fish)
|
||||
**Lưu ý**: Script hiển thị cảnh báo lỗi thời và tự động chạy cài đặt npm nếu Node.js khả dụng.
|
||||
|
||||
### Windows PowerShell
|
||||
|
||||
@@ -59,16 +66,7 @@ irm ccs.kaitran.ca/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Cấu Hình PATH Tự Động**:
|
||||
- Installer tự động phát hiện shell của bạn (bash, zsh, fish)
|
||||
- Thêm `~/.local/bin` vào PATH trong shell profile nếu cần
|
||||
- Idempotent: an toàn khi chạy nhiều lần
|
||||
- Hiển thị hướng dẫn reload sau khi cài đặt
|
||||
|
||||
**Lưu ý**:
|
||||
- Installer Unix hỗ trợ cả chạy trực tiếp (`./install.sh`) và cài đặt qua pipe (`curl | bash`)
|
||||
- Installer Windows yêu cầu PowerShell 5.1+ (đã cài sẵn trên Windows 10+)
|
||||
- Không cần sudo trên bất kỳ nền tảng nào
|
||||
**Lưu ý**: Script hiển thị cảnh báo lỗi thời và tự động chạy cài đặt npm nếu Node.js khả dụng.
|
||||
|
||||
## Cài Đặt qua Git Clone
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Hướng Dẫn Khắc Phục Sự Cố CCS
|
||||
|
||||
## Cảnh báo lỗi thời của trình cài đặt gốc
|
||||
|
||||
**Vấn đề:** "Tại sao trình cài đặt curl/irm hiển thị cảnh báo lỗi thời?"
|
||||
|
||||
**Nguyên nhân:** Trình cài đặt shell gốc đã lỗi thời, ưu tiên cài đặt npm.
|
||||
|
||||
**Giải pháp:**
|
||||
```bash
|
||||
# Gỡ cài đặt phiên bản cũ (nếu cài qua curl/irm)
|
||||
ccs-uninstall # hoặc: curl -fsSL ccs.kaitran.ca/uninstall | bash
|
||||
|
||||
# Cài đặt qua npm (khuyến nghị)
|
||||
npm install -g @kaitranntt/ccs
|
||||
```
|
||||
|
||||
**Lưu ý:** Trình cài đặt cũ hiện tự động chạy npm install nếu Node.js khả dụng.
|
||||
|
||||
## Vấn Đề Riêng Của Windows
|
||||
|
||||
### PowerShell Execution Policy
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
},
|
||||
rules: {
|
||||
// TypeScript rules - upgraded to errors for stricter type safety
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
|
||||
// General code quality
|
||||
'no-console': 'off', // CLI tool needs console
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.js', 'scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
];
|
||||
+106
-59
@@ -1,4 +1,7 @@
|
||||
# CCS Installation Script (Windows PowerShell)
|
||||
# CCS Installation Script (v4.5.0) - Windows PowerShell - DEPRECATED
|
||||
# DEPRECATED: This installer is deprecated. Use npm instead.
|
||||
# Bootstrap-based: Installs lightweight shell wrappers (LEGACY)
|
||||
# Requires: Node.js 14+ (npm recommended)
|
||||
# https://github.com/kaitranntt/ccs
|
||||
|
||||
param(
|
||||
@@ -7,6 +10,55 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Deprecation Notice ---
|
||||
Write-Host ""
|
||||
Write-Host "=======================================================================" -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " [!] DEPRECATION NOTICE " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " Native shell installers are deprecated and will be removed " -ForegroundColor Yellow
|
||||
Write-Host " in a future version. Please use npm installation instead: " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " npm install -g @kaitranntt/ccs " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host " Proceeding with legacy install (auto-runs npm if available)... " -ForegroundColor Yellow
|
||||
Write-Host " " -ForegroundColor Yellow
|
||||
Write-Host "=======================================================================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# --- Auto-redirect to npm installation ---
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
Write-Host "[i] Node.js detected, using npm installation (recommended)..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
npm install -g "@kaitranntt/ccs"
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "[OK] CCS installed via npm successfully!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Quick start:"
|
||||
Write-Host " ccs # Use Claude (default)"
|
||||
Write-Host " ccs glm # Use GLM"
|
||||
Write-Host " ccs --help # Show all commands"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "[!] npm installation failed. Falling back to legacy install..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
} else {
|
||||
Write-Host "[!] npm not found. Falling back to legacy install..." -ForegroundColor Yellow
|
||||
Write-Host "[!] Install Node.js from https://nodejs.org for the recommended method." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Continue with legacy PowerShell installation...
|
||||
|
||||
# Configuration
|
||||
$CcsDir = "$env:USERPROFILE\.ccs"
|
||||
$ClaudeDir = "$env:USERPROFILE\.claude"
|
||||
@@ -31,7 +83,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (irm | iex)
|
||||
# For git installations, VERSION file is read if available
|
||||
$CcsVersion = "4.4.0"
|
||||
$CcsVersion = "5.0.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if ($ScriptDir) {
|
||||
@@ -85,6 +137,42 @@ function Write-Section {
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# --- Node.js Detection (v4.5) ---
|
||||
function Test-NodeJs {
|
||||
$MIN_VERSION = 14
|
||||
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-WarningMsg @"
|
||||
Node.js not found
|
||||
|
||||
CCS v4.5+ requires Node.js 14+ to run.
|
||||
The bootstrap scripts will check and install the npm package on first use.
|
||||
|
||||
Install Node.js: https://nodejs.org (LTS recommended)
|
||||
|
||||
Installation will continue, but 'ccs' will not work until Node.js is installed.
|
||||
"@
|
||||
return $false
|
||||
}
|
||||
|
||||
$nodeVersion = (node -v) -replace 'v', ''
|
||||
$nodeMajor = [int]($nodeVersion -split '\.')[0]
|
||||
if ($nodeMajor -lt $MIN_VERSION) {
|
||||
Write-WarningMsg @"
|
||||
Node.js 14+ required (found: $(node -v))
|
||||
|
||||
CCS v4.5+ requires Node.js 14 or newer.
|
||||
Upgrade from: https://nodejs.org
|
||||
|
||||
Installation will continue, but 'ccs' may not work correctly.
|
||||
"@
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Success "Node.js $(node -v) detected"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Helper Functions
|
||||
|
||||
function Detect-CurrentProvider {
|
||||
@@ -261,6 +349,9 @@ function Install-ClaudeFolder {
|
||||
|
||||
# Main Installation
|
||||
|
||||
# Check Node.js requirement (warn if missing, continue anyway)
|
||||
$null = Test-NodeJs
|
||||
|
||||
Write-Host '===== Installing CCS (Windows) ====='
|
||||
|
||||
# Create directories
|
||||
@@ -276,32 +367,8 @@ if ($InstallMethod -eq "standalone") {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/lib/ccs.ps1" -OutFile "$CcsDir\ccs.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded ccs.ps1"
|
||||
|
||||
# Download required dependencies
|
||||
$LibDir = "$CcsDir\lib"
|
||||
if (-not (Test-Path $LibDir)) {
|
||||
New-Item -ItemType Directory -Path $LibDir -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/lib/error-codes.ps1" -OutFile "$LibDir\error-codes.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded error-codes.ps1"
|
||||
} catch {
|
||||
Write-Host "| [!] Warning: Failed to download error-codes.ps1"
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/lib/progress-indicator.ps1" -OutFile "$LibDir\progress-indicator.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded progress-indicator.ps1"
|
||||
} catch {
|
||||
Write-Host "| [!] Warning: Failed to download progress-indicator.ps1"
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/lib/prompt.ps1" -OutFile "$LibDir\prompt.ps1" -UseBasicParsing
|
||||
Write-Host "| [OK] Downloaded prompt.ps1"
|
||||
} catch {
|
||||
Write-Host "| [!] Warning: Failed to download prompt.ps1"
|
||||
}
|
||||
# Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Download shell completion files
|
||||
$CompletionsDir = "$CcsDir\completions"
|
||||
@@ -333,36 +400,8 @@ if ($InstallMethod -eq "standalone") {
|
||||
Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force
|
||||
Write-Host "| [OK] Installed ccs.ps1"
|
||||
|
||||
# Copy required dependencies
|
||||
$LibDir = "$CcsDir\lib"
|
||||
if (-not (Test-Path $LibDir)) {
|
||||
New-Item -ItemType Directory -Path $LibDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$SourceLibDir = if (Test-Path "$ScriptDir\lib") {
|
||||
"$ScriptDir\lib"
|
||||
} elseif (Test-Path "$ScriptDir\..\lib") {
|
||||
"$ScriptDir\..\lib"
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
|
||||
if ($SourceLibDir) {
|
||||
if (Test-Path "$SourceLibDir\error-codes.ps1") {
|
||||
Copy-Item "$SourceLibDir\error-codes.ps1" "$LibDir\error-codes.ps1" -Force
|
||||
Write-Host "| [OK] Copied error-codes.ps1"
|
||||
}
|
||||
|
||||
if (Test-Path "$SourceLibDir\progress-indicator.ps1") {
|
||||
Copy-Item "$SourceLibDir\progress-indicator.ps1" "$LibDir\progress-indicator.ps1" -Force
|
||||
Write-Host "| [OK] Copied progress-indicator.ps1"
|
||||
}
|
||||
|
||||
if (Test-Path "$SourceLibDir\prompt.ps1") {
|
||||
Copy-Item "$SourceLibDir\prompt.ps1" "$LibDir\prompt.ps1" -Force
|
||||
Write-Host "| [OK] Copied prompt.ps1"
|
||||
}
|
||||
}
|
||||
# Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Copy shell completion files
|
||||
$CompletionsDir = "$CcsDir\completions"
|
||||
@@ -751,11 +790,19 @@ Write-Host " * glm profile -> $CcsDir\glm.settings.json"
|
||||
Write-Host " * kimi profile -> $CcsDir\kimi.settings.json"
|
||||
Write-Host " * .claude/ folder -> $CcsDir\.claude\"
|
||||
Write-Host ""
|
||||
Write-Host " Requirements:"
|
||||
$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "NOT FOUND" }
|
||||
Write-Host " * Node.js 14+ (detected: $nodeVer)"
|
||||
Write-Host " * npm 5.2+ (for npx, comes with Node.js 8.2+)"
|
||||
Write-Host ""
|
||||
Write-Host " First Run:"
|
||||
Write-Host " The first time you run 'ccs', it will automatically install"
|
||||
Write-Host " the @kaitranntt/ccs npm package globally via npx."
|
||||
Write-Host ""
|
||||
Write-Host " Quick start:"
|
||||
Write-Host " ccs # Use Claude subscription (default)"
|
||||
Write-Host " ccs glm # Use GLM fallback"
|
||||
Write-Host " ccs kimi # Use Kimi for Coding"
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-Host " To uninstall: ccs-uninstall"
|
||||
Write-Host ""
|
||||
+99
-38
@@ -2,9 +2,61 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# CCS Installation Script
|
||||
# CCS Installation Script (v4.5.0) - DEPRECATED
|
||||
# DEPRECATED: This installer is deprecated. Use npm instead.
|
||||
# Bootstrap-based: Installs lightweight shell wrappers (LEGACY)
|
||||
# Requires: Node.js 14+ (npm recommended)
|
||||
# ============================================================================
|
||||
|
||||
# --- Deprecation Notice ---
|
||||
echo ""
|
||||
echo "======================================================================="
|
||||
echo " "
|
||||
echo " [!] DEPRECATION NOTICE "
|
||||
echo " "
|
||||
echo " Native shell installers are deprecated and will be removed "
|
||||
echo " in a future version. Please use npm installation instead: "
|
||||
echo " "
|
||||
echo " npm install -g @kaitranntt/ccs "
|
||||
echo " "
|
||||
echo " Proceeding with legacy install (auto-runs npm if available)... "
|
||||
echo " "
|
||||
echo "======================================================================="
|
||||
echo ""
|
||||
sleep 3 # Give users time to read
|
||||
|
||||
# --- Auto-redirect to npm installation ---
|
||||
if command -v npm &> /dev/null; then
|
||||
echo "[i] Node.js detected, using npm installation (recommended)..."
|
||||
echo ""
|
||||
npm install -g @kaitranntt/ccs
|
||||
exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo ""
|
||||
echo "[OK] CCS installed via npm successfully!"
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " ccs # Use Claude (default)"
|
||||
echo " ccs glm # Use GLM"
|
||||
echo " ccs --help # Show all commands"
|
||||
echo ""
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "[!] npm installation failed. Falling back to legacy install..."
|
||||
echo ""
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
echo "[!] npm not found. Falling back to legacy install..."
|
||||
echo "[!] Install Node.js from https://nodejs.org for the recommended method."
|
||||
echo ""
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Continue with legacy bash installation...
|
||||
|
||||
# --- Configuration ---
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
CCS_DIR="$HOME/.ccs"
|
||||
@@ -32,7 +84,7 @@ fi
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (curl | bash)
|
||||
# For git installations, VERSION file is read if available
|
||||
CCS_VERSION="4.4.0"
|
||||
CCS_VERSION="5.0.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||
@@ -139,6 +191,36 @@ msg_section() {
|
||||
|
||||
setup_colors
|
||||
|
||||
# --- Node.js Detection (v4.5) ---
|
||||
check_nodejs() {
|
||||
if ! command -v node &> /dev/null; then
|
||||
msg_warning "Node.js not found
|
||||
|
||||
CCS v4.5+ requires Node.js 14+ to run.
|
||||
The bootstrap scripts will check and install the npm package on first use.
|
||||
|
||||
Install Node.js: https://nodejs.org (LTS recommended)
|
||||
|
||||
Installation will continue, but 'ccs' will not work until Node.js is installed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local node_major
|
||||
node_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [[ $node_major -lt 14 ]]; then
|
||||
msg_warning "Node.js 14+ required (found: $(node -v))
|
||||
|
||||
CCS v4.5+ requires Node.js 14 or newer.
|
||||
Upgrade from: https://nodejs.org
|
||||
|
||||
Installation will continue, but 'ccs' may not work correctly."
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_success "Node.js $(node -v) detected"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Shell Profile Management ---
|
||||
|
||||
detect_shell_profile() {
|
||||
@@ -456,6 +538,9 @@ create_kimi_profile() {
|
||||
|
||||
# --- Main Installation ---
|
||||
|
||||
# Check Node.js requirement (warn if missing, continue anyway)
|
||||
check_nodejs || true
|
||||
|
||||
echo "┌─ Installing CCS"
|
||||
|
||||
# Create directories
|
||||
@@ -482,25 +567,8 @@ if [[ "$INSTALL_METHOD" == "standalone" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download required dependencies
|
||||
mkdir -p "$CCS_DIR/lib"
|
||||
if curl -fsSL "$BASE_URL/lib/error-codes.sh" -o "$CCS_DIR/lib/error-codes.sh" 2>/dev/null; then
|
||||
echo "| [OK] Downloaded error-codes.sh"
|
||||
else
|
||||
echo "| [!] Warning: Failed to download error-codes.sh"
|
||||
fi
|
||||
|
||||
if curl -fsSL "$BASE_URL/lib/progress-indicator.sh" -o "$CCS_DIR/lib/progress-indicator.sh" 2>/dev/null; then
|
||||
echo "| [OK] Downloaded progress-indicator.sh"
|
||||
else
|
||||
echo "| [!] Warning: Failed to download progress-indicator.sh"
|
||||
fi
|
||||
|
||||
if curl -fsSL "$BASE_URL/lib/prompt.sh" -o "$CCS_DIR/lib/prompt.sh" 2>/dev/null; then
|
||||
echo "| [OK] Downloaded prompt.sh"
|
||||
else
|
||||
echo "| [!] Warning: Failed to download prompt.sh"
|
||||
fi
|
||||
# Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Download shell completion files
|
||||
mkdir -p "$CCS_DIR/completions"
|
||||
@@ -528,22 +596,8 @@ else
|
||||
fi
|
||||
echo "| [OK] Installed executable"
|
||||
|
||||
# Copy required dependencies
|
||||
mkdir -p "$CCS_DIR/lib"
|
||||
if [[ -f "$LIB_DIR/error-codes.sh" ]]; then
|
||||
cp "$LIB_DIR/error-codes.sh" "$CCS_DIR/lib/error-codes.sh"
|
||||
echo "| [OK] Copied error-codes.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$LIB_DIR/progress-indicator.sh" ]]; then
|
||||
cp "$LIB_DIR/progress-indicator.sh" "$CCS_DIR/lib/progress-indicator.sh"
|
||||
echo "| [OK] Copied progress-indicator.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$LIB_DIR/prompt.sh" ]]; then
|
||||
cp "$LIB_DIR/prompt.sh" "$CCS_DIR/lib/prompt.sh"
|
||||
echo "| [OK] Copied prompt.sh"
|
||||
fi
|
||||
# Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed
|
||||
# Bootstrap delegates all functionality to Node.js via npx
|
||||
|
||||
# Copy shell completion files
|
||||
mkdir -p "$CCS_DIR/completions"
|
||||
@@ -856,11 +910,18 @@ echo " * glm profile -> ~/.ccs/glm.settings.json"
|
||||
echo " * kimi profile -> ~/.ccs/kimi.settings.json"
|
||||
echo " * .claude/ folder -> ~/.ccs/.claude/"
|
||||
echo ""
|
||||
echo " Requirements:"
|
||||
echo " * Node.js 14+ (detected: $(node -v 2>/dev/null || echo 'NOT FOUND'))"
|
||||
echo " * npm 5.2+ (for npx, comes with Node.js 8.2+)"
|
||||
echo ""
|
||||
echo " First Run:"
|
||||
echo " The first time you run 'ccs', it will automatically install"
|
||||
echo " the @kaitranntt/ccs npm package globally via npx."
|
||||
echo ""
|
||||
echo " Quick start:"
|
||||
echo " ccs # Use Claude subscription (default)"
|
||||
echo " ccs glm # Use GLM fallback"
|
||||
echo " ccs kimi # Use Kimi for Coding"
|
||||
echo ""
|
||||
echo ""
|
||||
echo " To uninstall: ccs-uninstall"
|
||||
echo ""
|
||||
+23
-1809
File diff suppressed because it is too large.
Load diff
Generated
-1462
File diff suppressed because it is too large.
Load diff
+31
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "4.4.0",
|
||||
"version": "5.0.0",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -23,11 +23,13 @@
|
||||
"name": "Tam Nhu Tran (Kai)",
|
||||
"email": "kaitranntt@users.noreply.github.com"
|
||||
},
|
||||
"main": "dist/ccs.js",
|
||||
"types": "dist/ccs.d.ts",
|
||||
"bin": {
|
||||
"ccs": "bin/ccs.js"
|
||||
"ccs": "dist/ccs.js"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"dist/",
|
||||
"lib/",
|
||||
"scripts/",
|
||||
"config/",
|
||||
@@ -37,8 +39,10 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=14.0.0",
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.21",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
@@ -46,14 +50,23 @@
|
||||
],
|
||||
"preferGlobal": true,
|
||||
"scripts": {
|
||||
"test": "npm run test:all",
|
||||
"test:all": "npm run test:unit && npm run test:npm",
|
||||
"test:unit": "npx mocha tests/shared/unit/**/*.test.js --timeout 5000",
|
||||
"test:npm": "npx mocha tests/npm/**/*.test.js --timeout 10000",
|
||||
"build": "tsc && node scripts/add-shebang.js",
|
||||
"build:watch": "tsc --watch",
|
||||
"prebuild": "rm -rf dist tsconfig.tsbuildinfo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"validate": "bun run typecheck && bun run lint && bun run format:check && bun run test",
|
||||
"test": "bun run build && bun run test:all",
|
||||
"test:all": "bun run test:unit && bun run test:npm",
|
||||
"test:unit": "mocha tests/shared/unit/**/*.test.js --timeout 5000",
|
||||
"test:npm": "mocha tests/npm/**/*.test.js --timeout 10000",
|
||||
"test:native": "bash tests/native/unix/edge-cases.sh",
|
||||
"test:edge-cases": "bash tests/edge-cases.sh",
|
||||
"prepublishOnly": "node scripts/sync-version.js",
|
||||
"prepack": "node scripts/sync-version.js",
|
||||
"prepublishOnly": "npm run validate && node scripts/sync-version.js",
|
||||
"prepack": "npm run validate && node scripts/sync-version.js",
|
||||
"prepare": "node scripts/check-executables.js",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
@@ -62,6 +75,13 @@
|
||||
"ora": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^11.7.5"
|
||||
"@types/node": "^20.19.25",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"mocha": "^11.7.5",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "5.3"
|
||||
}
|
||||
}
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Add shebang to dist/ccs.js and make executable
|
||||
* Run after: tsc
|
||||
*/
|
||||
function addShebang() {
|
||||
const ccsPath = path.join(__dirname, '../dist/ccs.js');
|
||||
|
||||
if (!fs.existsSync(ccsPath)) {
|
||||
console.error('[X] dist/ccs.js not found. Run tsc first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(ccsPath, 'utf8');
|
||||
|
||||
// Add shebang if missing
|
||||
if (!content.startsWith('#!/usr/bin/env node')) {
|
||||
content = '#!/usr/bin/env node\n' + content;
|
||||
fs.writeFileSync(ccsPath, content);
|
||||
console.log('[OK] Shebang added to dist/ccs.js');
|
||||
}
|
||||
|
||||
// Make executable (Unix-like systems)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(ccsPath, 0o755);
|
||||
console.log('[OK] dist/ccs.js is now executable');
|
||||
} catch (err) {
|
||||
console.warn('[!] Could not chmod dist/ccs.js:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addShebang();
|
||||
+25
-37
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump CCS version
|
||||
# Usage: ./scripts/bump-version.sh [major|minor|patch]
|
||||
#
|
||||
# v4.5.0+: Bootstrap architecture - shell scripts delegate to Node.js
|
||||
# Version is maintained in: VERSION, package.json, installers/*
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -10,7 +13,7 @@ VERSION_FILE="$CCS_DIR/VERSION"
|
||||
|
||||
# Check VERSION file exists
|
||||
if [[ ! -f "$VERSION_FILE" ]]; then
|
||||
echo "✗ Error: VERSION file not found at $VERSION_FILE"
|
||||
echo "[X] Error: VERSION file not found at $VERSION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -20,7 +23,7 @@ echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
# Parse version
|
||||
if [[ ! "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "✗ Error: Invalid version format in VERSION file"
|
||||
echo "[X] Error: Invalid version format in VERSION file"
|
||||
echo "Expected: MAJOR.MINOR.PATCH (e.g., 1.2.3)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -46,7 +49,7 @@ case "$BUMP_TYPE" in
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
*)
|
||||
echo "✗ Error: Invalid bump type '$BUMP_TYPE'"
|
||||
echo "[X] Error: Invalid bump type '$BUMP_TYPE'"
|
||||
echo "Usage: $0 [major|minor|patch]"
|
||||
exit 1
|
||||
;;
|
||||
@@ -58,11 +61,12 @@ echo "New version: $NEW_VERSION"
|
||||
echo ""
|
||||
echo "This will update hardcoded versions in:"
|
||||
echo " 1. VERSION file"
|
||||
echo " 2. lib/ccs (bash executable)"
|
||||
echo " 3. lib/ccs.ps1 (PowerShell executable)"
|
||||
echo " 4. package.json (via sync-version.js)"
|
||||
echo " 5. installers/install.sh"
|
||||
echo " 6. installers/install.ps1"
|
||||
echo " 2. package.json (via sync-version.js)"
|
||||
echo " 3. installers/install.sh"
|
||||
echo " 4. installers/install.ps1"
|
||||
echo ""
|
||||
echo "Note: lib/ccs and lib/ccs.ps1 are now bootstraps"
|
||||
echo " (delegate to Node.js, no version hardcoded)"
|
||||
echo ""
|
||||
read -p "Continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
@@ -74,63 +78,47 @@ fi
|
||||
|
||||
# Update VERSION file
|
||||
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||
echo "✓ Updated VERSION file to $NEW_VERSION"
|
||||
echo "[OK] Updated VERSION file to $NEW_VERSION"
|
||||
|
||||
# Update ccs (bash executable)
|
||||
CCS_BASH="$CCS_DIR/lib/ccs"
|
||||
if [[ -f "$CCS_BASH" ]]; then
|
||||
sed -i.bak "s/^CCS_VERSION=\".*\"/CCS_VERSION=\"$NEW_VERSION\"/" "$CCS_BASH"
|
||||
rm -f "$CCS_BASH.bak"
|
||||
echo "✓ Updated lib/ccs (bash executable)"
|
||||
else
|
||||
echo "⚠ lib/ccs not found, skipping"
|
||||
fi
|
||||
|
||||
# Update ccs.ps1 (PowerShell executable)
|
||||
CCS_PS1="$CCS_DIR/lib/ccs.ps1"
|
||||
if [[ -f "$CCS_PS1" ]]; then
|
||||
sed -i.bak "s/^\$CcsVersion = \".*\"/\$CcsVersion = \"$NEW_VERSION\"/" "$CCS_PS1"
|
||||
rm -f "$CCS_PS1.bak"
|
||||
echo "✓ Updated lib/ccs.ps1 (PowerShell executable)"
|
||||
else
|
||||
echo "⚠ lib/ccs.ps1 not found, skipping"
|
||||
fi
|
||||
# Note: lib/ccs and lib/ccs.ps1 are now lightweight bootstraps
|
||||
# They delegate to Node.js via npx - no version variable needed
|
||||
# Version is determined by the npm package at runtime
|
||||
|
||||
# Update installers/install.sh
|
||||
INSTALL_SH="$CCS_DIR/installers/install.sh"
|
||||
if [[ -f "$INSTALL_SH" ]]; then
|
||||
sed -i.bak "s/^CCS_VERSION=\".*\"/CCS_VERSION=\"$NEW_VERSION\"/" "$INSTALL_SH"
|
||||
rm -f "$INSTALL_SH.bak"
|
||||
echo "✓ Updated installers/install.sh"
|
||||
echo "[OK] Updated installers/install.sh"
|
||||
else
|
||||
echo "⚠ installers/install.sh not found, skipping"
|
||||
echo "[!] installers/install.sh not found, skipping"
|
||||
fi
|
||||
|
||||
# Update installers/install.ps1
|
||||
INSTALL_PS1="$CCS_DIR/installers/install.ps1"
|
||||
if [[ -f "$INSTALL_PS1" ]]; then
|
||||
sed -i.bak "s/^\$CcsVersion = \".*\"/\$CcsVersion = \"$NEW_VERSION\"/" "$INSTALL_PS1"
|
||||
sed -i.bak "s/^\\\$CcsVersion = \".*\"/\\\$CcsVersion = \"$NEW_VERSION\"/" "$INSTALL_PS1"
|
||||
rm -f "$INSTALL_PS1.bak"
|
||||
echo "✓ Updated installers/install.ps1"
|
||||
echo "[OK] Updated installers/install.ps1"
|
||||
else
|
||||
echo "⚠ installers/install.ps1 not found, skipping"
|
||||
echo "[!] installers/install.ps1 not found, skipping"
|
||||
fi
|
||||
|
||||
# Sync version to package.json
|
||||
echo "Syncing version to package.json..."
|
||||
if node "$SCRIPT_DIR/sync-version.js"; then
|
||||
echo "✓ Synced version to package.json"
|
||||
echo "[OK] Synced version to package.json"
|
||||
else
|
||||
echo "✗ Error: Failed to sync version to package.json"
|
||||
echo "[X] Error: Failed to sync version to package.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Version bumped to $NEW_VERSION"
|
||||
echo "[OK] Version bumped to $NEW_VERSION"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review changes: git diff"
|
||||
echo " 2. Commit: git add VERSION package.json lib/ccs lib/ccs.ps1 installers/install.sh installers/install.ps1"
|
||||
echo " 2. Commit: git add VERSION package.json installers/install.sh installers/install.ps1"
|
||||
echo " 3. Commit: git commit -m \"chore: bump version to $NEW_VERSION\""
|
||||
echo " 4. Tag: git tag v$NEW_VERSION"
|
||||
echo " 5. Push: git push origin main && git push origin v$NEW_VERSION"
|
||||
+32
-11
@@ -1,35 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Auto-install CCS locally for testing changes
|
||||
# Usage: ./scripts/dev-install.sh
|
||||
#
|
||||
# Options:
|
||||
# --skip-validate Skip validation (faster, use when you're sure code is good)
|
||||
|
||||
set -e
|
||||
|
||||
echo "[CCS Dev Install] Starting..."
|
||||
SKIP_VALIDATE=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--skip-validate) SKIP_VALIDATE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[i] CCS Dev Install - Starting..."
|
||||
|
||||
# Get to the right directory
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Build TypeScript first
|
||||
echo "[i] Building TypeScript..."
|
||||
bun run build
|
||||
|
||||
# Pack the npm package
|
||||
echo "[CCS Dev Install] Creating package..."
|
||||
npm pack
|
||||
echo "[i] Creating package..."
|
||||
if [ "$SKIP_VALIDATE" = true ]; then
|
||||
# Skip validation, just pack
|
||||
bun pm pack --ignore-scripts
|
||||
else
|
||||
# Full pack with validation (runs prepublishOnly)
|
||||
bun pm pack
|
||||
fi
|
||||
|
||||
# Find the tarball
|
||||
TARBALL=$(ls -t kaitranntt-ccs-*.tgz | head -1)
|
||||
TARBALL=$(ls -t kaitranntt-ccs-*.tgz 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$TARBALL" ]; then
|
||||
echo "[CCS Dev Install] ERROR: No tarball found"
|
||||
echo "[X] ERROR: No tarball found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[CCS Dev Install] Found tarball: $TARBALL"
|
||||
echo "[i] Found tarball: $TARBALL"
|
||||
|
||||
# Install globally
|
||||
echo "[CCS Dev Install] Installing globally..."
|
||||
# Install globally using npm (handles bin linking correctly)
|
||||
echo "[i] Installing globally with npm..."
|
||||
npm install -g "$TARBALL"
|
||||
|
||||
# Clean up
|
||||
echo "[CCS Dev Install] Cleaning up..."
|
||||
echo "[i] Cleaning up..."
|
||||
rm "$TARBALL"
|
||||
|
||||
echo "[CCS Dev Install] ✓ Complete! CCS is now updated."
|
||||
echo "[OK] Complete! CCS is now updated."
|
||||
echo ""
|
||||
echo "Test with: ccs glmt --version"
|
||||
echo "Test with: ccs --version"
|
||||
+29
-29
@@ -12,14 +12,24 @@ const os = require('os');
|
||||
* Runs when: npm install -g @kaitranntt/ccs
|
||||
* Idempotent: Safe to run multiple times (won't overwrite existing configs)
|
||||
* Cross-platform: Works on Unix, macOS, Windows
|
||||
*
|
||||
* Test isolation: Set CCS_HOME env var to redirect all operations to a test directory
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the CCS home directory (respects CCS_HOME env var for test isolation)
|
||||
* @returns {string} Home directory path
|
||||
*/
|
||||
function getCcsHome() {
|
||||
return process.env.CCS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate created configuration files
|
||||
* @returns {object} { success: boolean, errors: string[], warnings: string[] }
|
||||
*/
|
||||
function validateConfiguration() {
|
||||
const homedir = os.homedir();
|
||||
const homedir = getCcsHome();
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
@@ -63,8 +73,8 @@ function validateConfiguration() {
|
||||
|
||||
function createConfigFiles() {
|
||||
try {
|
||||
// Get user home directory (cross-platform)
|
||||
const homedir = os.homedir();
|
||||
// Get user home directory (cross-platform, respects CCS_HOME for test isolation)
|
||||
const homedir = getCcsHome();
|
||||
const ccsDir = path.join(homedir, '.ccs');
|
||||
|
||||
// Create ~/.ccs/ directory if missing
|
||||
@@ -93,7 +103,7 @@ function createConfigFiles() {
|
||||
// Migrate from v3.1.1 to v3.2.0 (symlink architecture)
|
||||
console.log('');
|
||||
try {
|
||||
const SharedManager = require('../bin/management/shared-manager');
|
||||
const SharedManager = require('../dist/management/shared-manager').default;
|
||||
const sharedManager = new SharedManager();
|
||||
sharedManager.migrateFromV311();
|
||||
sharedManager.ensureSharedDirectories();
|
||||
@@ -106,32 +116,13 @@ function createConfigFiles() {
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Copy .claude/ directory from package to ~/.ccs/.claude/ (v4.1.1)
|
||||
try {
|
||||
const ClaudeDirInstaller = require('../bin/utils/claude-dir-installer');
|
||||
const installer = new ClaudeDirInstaller();
|
||||
const packageDir = path.join(__dirname, '..');
|
||||
installer.install(packageDir);
|
||||
|
||||
// Clean up deprecated files (v4.3.2)
|
||||
installer.cleanupDeprecated();
|
||||
} catch (err) {
|
||||
console.warn('[!] Failed to install .claude/ directory:', err.message);
|
||||
console.warn(' CCS items may not be available');
|
||||
}
|
||||
|
||||
// Install CCS items to ~/.claude/ (v4.1.0)
|
||||
try {
|
||||
const ClaudeSymlinkManager = require('../bin/utils/claude-symlink-manager');
|
||||
const claudeSymlinkManager = new ClaudeSymlinkManager();
|
||||
claudeSymlinkManager.install();
|
||||
} catch (err) {
|
||||
console.warn('[!] CCS item installation warning:', err.message);
|
||||
console.warn(' Run "ccs sync" to retry');
|
||||
}
|
||||
console.log('');
|
||||
// NOTE: .claude/ directory installation moved to "ccs sync" command
|
||||
// Users can run "ccs sync" to install CCS commands/skills to ~/.claude/
|
||||
// This gives users control over when to modify their Claude configuration
|
||||
|
||||
// Create config.json if missing
|
||||
// NOTE: gemini/codex profiles NOT included - they are added on-demand when user
|
||||
// runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first)
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
const config = {
|
||||
@@ -156,12 +147,17 @@ function createConfigFiles() {
|
||||
if (!config.profiles) {
|
||||
config.profiles = {};
|
||||
}
|
||||
let configUpdated = false;
|
||||
if (!config.profiles.glmt) {
|
||||
config.profiles.glmt = '~/.ccs/glmt.settings.json';
|
||||
configUpdated = true;
|
||||
}
|
||||
// NOTE: gemini/codex profiles added on-demand, not during migration
|
||||
if (configUpdated) {
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tmpPath, configPath);
|
||||
console.log('[OK] Updated config with GLMT profile');
|
||||
console.log('[OK] Updated config with glmt profile');
|
||||
} else {
|
||||
console.log('[OK] Config exists: ~/.ccs/config.json (preserved)');
|
||||
}
|
||||
@@ -310,6 +306,10 @@ function createConfigFiles() {
|
||||
console.log('[OK] Kimi profile exists: ~/.ccs/kimi.settings.json (preserved)');
|
||||
}
|
||||
|
||||
// NOTE: gemini.settings.json and codex.settings.json are NOT created during install
|
||||
// They are created on-demand when user runs `ccs gemini` or `ccs codex` for the first time
|
||||
// This prevents confusion - users need to run `--auth` first anyway
|
||||
|
||||
// Migrate existing Kimi configs to remove deprecated model fields (v4.1.2)
|
||||
// Kimi API changed - model fields now cause 401 errors
|
||||
if (fs.existsSync(kimiSettingsPath)) {
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ProfileRegistry = require('./profile-registry');
|
||||
const InstanceManager = require('../management/instance-manager');
|
||||
const { colored } = require('../utils/helpers');
|
||||
const { detectClaudeCli } = require('../utils/claude-detector');
|
||||
const { InteractivePrompt } = require('../utils/prompt');
|
||||
const CCS_VERSION = require('../../package.json').version;
|
||||
|
||||
/**
|
||||
* Auth Commands (Simplified)
|
||||
*
|
||||
@@ -19,7 +7,48 @@ const CCS_VERSION = require('../../package.json').version;
|
||||
* Login-per-profile model: Each profile is an isolated Claude instance.
|
||||
* Users login directly in each instance (no credential copying).
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import ProfileRegistry from './profile-registry';
|
||||
import { InstanceManager } from '../management/instance-manager';
|
||||
import { colored } from '../utils/helpers';
|
||||
import { detectClaudeCli } from '../utils/claude-detector';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth Commands Class
|
||||
*/
|
||||
class AuthCommands {
|
||||
private registry: ProfileRegistry;
|
||||
private instanceMgr: InstanceManager;
|
||||
private readonly version: string = packageJson.version;
|
||||
|
||||
constructor() {
|
||||
this.registry = new ProfileRegistry();
|
||||
this.instanceMgr = new InstanceManager();
|
||||
@@ -28,7 +57,7 @@ class AuthCommands {
|
||||
/**
|
||||
* Show help for auth commands
|
||||
*/
|
||||
showHelp() {
|
||||
showHelp(): void {
|
||||
console.log(colored('CCS Concurrent Account Management', 'bold'));
|
||||
console.log('');
|
||||
console.log(colored('Usage:', 'cyan'));
|
||||
@@ -42,31 +71,65 @@ class AuthCommands {
|
||||
console.log(` ${colored('default <profile>', 'yellow')} Set default profile`);
|
||||
console.log('');
|
||||
console.log(colored('Examples:', 'cyan'));
|
||||
console.log(` ${colored('ccs auth create work', 'yellow')} # Create & login to work profile`);
|
||||
console.log(` ${colored('ccs auth default work', 'yellow')} # Set work as default`);
|
||||
console.log(` ${colored('ccs auth list', 'yellow')} # List all profiles`);
|
||||
console.log(` ${colored('ccs work "review code"', 'yellow')} # Use work profile`);
|
||||
console.log(` ${colored('ccs "review code"', 'yellow')} # Use default profile`);
|
||||
console.log(
|
||||
` ${colored('ccs auth create work', 'yellow')} # Create & login to work profile`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs auth default work', 'yellow')} # Set work as default`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs auth list', 'yellow')} # List all profiles`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs work "review code"', 'yellow')} # Use work profile`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs "review code"', 'yellow')} # Use default profile`
|
||||
);
|
||||
console.log('');
|
||||
console.log(colored('Options:', 'cyan'));
|
||||
console.log(` ${colored('--force', 'yellow')} Allow overwriting existing profile (create)`);
|
||||
console.log(` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts (remove)`);
|
||||
console.log(` ${colored('--json', 'yellow')} Output in JSON format (list, show)`);
|
||||
console.log(` ${colored('--verbose', 'yellow')} Show additional details (list)`);
|
||||
console.log(
|
||||
` ${colored('--force', 'yellow')} Allow overwriting existing profile (create)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts (remove)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('--json', 'yellow')} Output in JSON format (list, show)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('--verbose', 'yellow')} Show additional details (list)`
|
||||
);
|
||||
console.log('');
|
||||
console.log(colored('Note:', 'cyan'));
|
||||
console.log(` By default, ${colored('ccs', 'yellow')} uses Claude CLI defaults from ~/.claude/`);
|
||||
console.log(` Use ${colored('ccs auth default <profile>', 'yellow')} to change the default profile.`);
|
||||
console.log(
|
||||
` By default, ${colored('ccs', 'yellow')} uses Claude CLI defaults from ~/.claude/`
|
||||
);
|
||||
console.log(
|
||||
` Use ${colored('ccs auth default <profile>', 'yellow')} to change the default profile.`
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new profile and prompt for login
|
||||
* @param {Array} args - Command arguments
|
||||
* Parse command arguments
|
||||
*/
|
||||
async handleCreate(args) {
|
||||
const profileName = args.find(arg => !arg.startsWith('--'));
|
||||
const force = args.includes('--force');
|
||||
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
|
||||
*/
|
||||
async handleCreate(args: string[]): Promise<void> {
|
||||
const { profileName, force } = this.parseArgs(args);
|
||||
|
||||
if (!profileName) {
|
||||
console.error('[X] Profile name is required');
|
||||
@@ -93,11 +156,11 @@ class AuthCommands {
|
||||
// Create/update profile entry
|
||||
if (this.registry.hasProfile(profileName)) {
|
||||
this.registry.updateProfile(profileName, {
|
||||
type: 'account'
|
||||
type: 'account',
|
||||
});
|
||||
} else {
|
||||
this.registry.createProfile(profileName, {
|
||||
type: 'account'
|
||||
type: 'account',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,12 +181,12 @@ class AuthCommands {
|
||||
}
|
||||
|
||||
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
|
||||
const child = spawn(claudeCli, [], {
|
||||
const child: ChildProcess = spawn(claudeCli, [], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
child.on('exit', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
console.log('');
|
||||
console.log(colored('[OK] Profile created successfully', 'green'));
|
||||
@@ -132,7 +195,9 @@ class AuthCommands {
|
||||
console.log(` Instance: ${instancePath}`);
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(` ${colored(`ccs ${profileName} "your prompt here"`, 'yellow')} # Use this specific profile`);
|
||||
console.log(
|
||||
` ${colored(`ccs ${profileName} "your prompt here"`, 'yellow')} # Use this specific profile`
|
||||
);
|
||||
console.log('');
|
||||
console.log('To set as default (so you can use just "ccs"):');
|
||||
console.log(` ${colored(`ccs auth default ${profileName}`, 'yellow')}`);
|
||||
@@ -149,24 +214,21 @@ class AuthCommands {
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
child.on('error', (err: Error) => {
|
||||
console.error(`[X] Failed to execute Claude CLI: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[X] Failed to create profile: ${error.message}`);
|
||||
console.error(`[X] Failed to create profile: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved profiles
|
||||
* @param {Array} args - Command arguments
|
||||
*/
|
||||
async handleList(args) {
|
||||
const verbose = args.includes('--verbose');
|
||||
const json = args.includes('--json');
|
||||
async handleList(args: string[]): Promise<void> {
|
||||
const { verbose, json } = this.parseArgs(args);
|
||||
|
||||
try {
|
||||
const profiles = this.registry.getAllProfiles();
|
||||
@@ -175,9 +237,9 @@ class AuthCommands {
|
||||
|
||||
// JSON output mode
|
||||
if (json) {
|
||||
const output = {
|
||||
version: CCS_VERSION,
|
||||
profiles: profileNames.map(name => {
|
||||
const output: ListOutput = {
|
||||
version: this.version,
|
||||
profiles: profileNames.map((name) => {
|
||||
const profile = profiles[name];
|
||||
const isDefault = name === defaultProfile;
|
||||
const instancePath = this.instanceMgr.getInstancePath(name);
|
||||
@@ -188,9 +250,9 @@ class AuthCommands {
|
||||
is_default: isDefault,
|
||||
created: profile.created,
|
||||
last_used: profile.last_used || null,
|
||||
instance_path: instancePath
|
||||
instance_path: instancePath,
|
||||
};
|
||||
})
|
||||
}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
@@ -201,7 +263,9 @@ class AuthCommands {
|
||||
console.log(colored('No account profiles found', 'yellow'));
|
||||
console.log('');
|
||||
console.log('To create your first profile:');
|
||||
console.log(` ${colored('ccs auth create <profile>', 'yellow')} # Create and login to profile`);
|
||||
console.log(
|
||||
` ${colored('ccs auth create <profile>', 'yellow')} # Create and login to profile`
|
||||
);
|
||||
console.log('');
|
||||
console.log('Example:');
|
||||
console.log(` ${colored('ccs auth create work', 'yellow')}`);
|
||||
@@ -223,7 +287,7 @@ class AuthCommands {
|
||||
|
||||
// Then by last_used
|
||||
if (aProfile.last_used && bProfile.last_used) {
|
||||
return new Date(bProfile.last_used) - new Date(aProfile.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;
|
||||
@@ -232,12 +296,14 @@ class AuthCommands {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
sorted.forEach(name => {
|
||||
sorted.forEach((name) => {
|
||||
const profile = profiles[name];
|
||||
const isDefault = name === defaultProfile;
|
||||
const indicator = isDefault ? colored('[*]', 'green') : '[ ]';
|
||||
|
||||
console.log(`${indicator} ${colored(name, 'cyan')}${isDefault ? colored(' (default)', 'green') : ''}`);
|
||||
console.log(
|
||||
`${indicator} ${colored(name, 'cyan')}${isDefault ? colored(' (default)', 'green') : ''}`
|
||||
);
|
||||
|
||||
console.log(` Type: ${profile.type || 'account'}`);
|
||||
|
||||
@@ -253,20 +319,17 @@ class AuthCommands {
|
||||
|
||||
console.log(`Total profiles: ${profileNames.length}`);
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[X] Failed to list profiles: ${error.message}`);
|
||||
console.error(`[X] Failed to list profiles: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show details for a specific profile
|
||||
* @param {Array} args - Command arguments
|
||||
*/
|
||||
async handleShow(args) {
|
||||
const profileName = args.find(arg => !arg.startsWith('--'));
|
||||
const json = args.includes('--json');
|
||||
async handleShow(args: string[]): Promise<void> {
|
||||
const { profileName, json } = this.parseArgs(args);
|
||||
|
||||
if (!profileName) {
|
||||
console.error('[X] Profile name is required');
|
||||
@@ -287,22 +350,22 @@ class AuthCommands {
|
||||
const sessionsDir = path.join(instancePath, 'session-env');
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
const files = fs.readdirSync(sessionsDir);
|
||||
sessionCount = files.filter(f => f.endsWith('.json')).length;
|
||||
sessionCount = files.filter((f) => f.endsWith('.json')).length;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Ignore errors counting sessions
|
||||
}
|
||||
|
||||
// JSON output mode
|
||||
if (json) {
|
||||
const output = {
|
||||
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
|
||||
session_count: sessionCount,
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
@@ -323,19 +386,17 @@ class AuthCommands {
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[X] ${error.message}`);
|
||||
console.error(`[X] ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a saved profile
|
||||
* @param {Array} args - Command arguments
|
||||
*/
|
||||
async handleRemove(args) {
|
||||
const profileName = args.find(arg => !arg.startsWith('--') && !arg.startsWith('-'));
|
||||
async handleRemove(args: string[]): Promise<void> {
|
||||
const { profileName, yes } = this.parseArgs(args);
|
||||
|
||||
if (!profileName) {
|
||||
console.error('[X] Profile name is required');
|
||||
@@ -358,9 +419,9 @@ class AuthCommands {
|
||||
const sessionsDir = path.join(instancePath, 'session-env');
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
const files = fs.readdirSync(sessionsDir);
|
||||
sessionCount = files.filter(f => f.endsWith('.json')).length;
|
||||
sessionCount = files.filter((f) => f.endsWith('.json')).length;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Ignore errors counting sessions
|
||||
}
|
||||
|
||||
@@ -372,10 +433,12 @@ class AuthCommands {
|
||||
console.log('');
|
||||
|
||||
// Interactive confirmation (or --yes flag)
|
||||
const confirmed = await InteractivePrompt.confirm(
|
||||
'Delete this profile?',
|
||||
{ default: false } // Default to NO (safe)
|
||||
);
|
||||
const confirmed =
|
||||
yes ||
|
||||
(await InteractivePrompt.confirm(
|
||||
'Delete this profile?',
|
||||
{ default: false } // Default to NO (safe)
|
||||
));
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('[i] Cancelled');
|
||||
@@ -391,19 +454,17 @@ class AuthCommands {
|
||||
console.log(colored('[OK] Profile removed successfully', 'green'));
|
||||
console.log(` Profile: ${profileName}`);
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[X] Failed to remove profile: ${error.message}`);
|
||||
console.error(`[X] Failed to remove profile: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default profile
|
||||
* @param {Array} args - Command arguments
|
||||
*/
|
||||
async handleDefault(args) {
|
||||
const profileName = args.find(arg => !arg.startsWith('--'));
|
||||
async handleDefault(args: string[]): Promise<void> {
|
||||
const { profileName } = this.parseArgs(args);
|
||||
|
||||
if (!profileName) {
|
||||
console.error('[X] Profile name is required');
|
||||
@@ -421,18 +482,16 @@ class AuthCommands {
|
||||
console.log('Now you can use:');
|
||||
console.log(` ${colored('ccs "your prompt"', 'yellow')} # Uses ${profileName} profile`);
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[X] ${error.message}`);
|
||||
console.error(`[X] ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route auth command to appropriate handler
|
||||
* @param {Array} args - Command arguments
|
||||
*/
|
||||
async route(args) {
|
||||
async route(args: string[]): Promise<void> {
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
||||
this.showHelp();
|
||||
return;
|
||||
@@ -496,4 +555,4 @@ class AuthCommands {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthCommands;
|
||||
export default AuthCommands;
|
||||
@@ -1,17 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { findSimilarStrings } = require('../utils/helpers');
|
||||
|
||||
/**
|
||||
* Profile Detector
|
||||
*
|
||||
* Determines profile type (settings-based vs account-based) for routing.
|
||||
* Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findSimilarStrings } from '../utils/helpers';
|
||||
import { Config, Settings, ProfileMetadata } from '../types';
|
||||
|
||||
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default';
|
||||
|
||||
/** CLIProxy profile names (OAuth-based, zero config) */
|
||||
export const CLIPROXY_PROFILES = ['gemini', 'codex', 'agy'] as const;
|
||||
export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number];
|
||||
|
||||
export interface ProfileDetectionResult {
|
||||
type: ProfileType;
|
||||
name: string;
|
||||
settingsPath?: string;
|
||||
profile?: Settings | ProfileMetadata;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AllProfiles {
|
||||
settings: string[];
|
||||
accounts: string[];
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface ProfileNotFoundError extends Error {
|
||||
profileName: string;
|
||||
suggestions: string[];
|
||||
availableProfiles: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile Detector Class
|
||||
*/
|
||||
class ProfileDetector {
|
||||
private readonly configPath: string;
|
||||
private readonly profilesPath: string;
|
||||
|
||||
constructor() {
|
||||
this.configPath = path.join(os.homedir(), '.ccs', 'config.json');
|
||||
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
|
||||
@@ -19,70 +51,74 @@ class ProfileDetector {
|
||||
|
||||
/**
|
||||
* Read settings-based config (config.json)
|
||||
* @returns {Object} Config data
|
||||
*/
|
||||
_readConfig() {
|
||||
private readConfig(): Config {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
return { profiles: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(this.configPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
return JSON.parse(data) as Config;
|
||||
} catch (error) {
|
||||
console.warn(`[!] Warning: Could not read config.json: ${error.message}`);
|
||||
console.warn(`[!] Warning: Could not read config.json: ${(error as Error).message}`);
|
||||
return { profiles: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read account-based profiles (profiles.json)
|
||||
* @returns {Object} Profiles data
|
||||
*/
|
||||
_readProfiles() {
|
||||
private readProfiles(): { profiles: Record<string, ProfileMetadata>; default?: string } {
|
||||
if (!fs.existsSync(this.profilesPath)) {
|
||||
return { profiles: {}, default: null };
|
||||
return { profiles: {}, default: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(this.profilesPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.warn(`[!] Warning: Could not read profiles.json: ${error.message}`);
|
||||
return { profiles: {}, default: null };
|
||||
console.warn(`[!] Warning: Could not read profiles.json: ${(error as Error).message}`);
|
||||
return { profiles: {}, default: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect profile type and return routing information
|
||||
* @param {string} profileName - Profile name to detect
|
||||
* @returns {Object} {type: 'settings'|'account'|'default', ...info}
|
||||
*/
|
||||
detectProfileType(profileName) {
|
||||
detectProfileType(profileName: string | null | undefined): ProfileDetectionResult {
|
||||
// Special case: 'default' means use default profile
|
||||
if (profileName === 'default' || profileName === null || profileName === undefined) {
|
||||
return this._resolveDefaultProfile();
|
||||
return this.resolveDefaultProfile();
|
||||
}
|
||||
|
||||
// Priority 0: Check CLIProxy profiles (gemini, codex, agy) - OAuth-based, zero config
|
||||
if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) {
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY
|
||||
const config = this._readConfig();
|
||||
const config = this.readConfig();
|
||||
|
||||
if (config.profiles && config.profiles[profileName]) {
|
||||
return {
|
||||
type: 'settings',
|
||||
name: profileName,
|
||||
settingsPath: config.profiles[profileName]
|
||||
settingsPath: config.profiles[profileName],
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check account-based profiles (work, personal)
|
||||
const profiles = this._readProfiles();
|
||||
const profiles = this.readProfiles();
|
||||
|
||||
if (profiles.profiles && profiles.profiles[profileName]) {
|
||||
return {
|
||||
type: 'account',
|
||||
name: profileName,
|
||||
profile: profiles.profiles[profileName]
|
||||
profile: profiles.profiles[profileName],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,37 +127,36 @@ class ProfileDetector {
|
||||
const allProfileNames = [...allProfiles.settings, ...allProfiles.accounts];
|
||||
const suggestions = findSimilarStrings(profileName, allProfileNames);
|
||||
|
||||
const error = new Error(`Profile not found: ${profileName}`);
|
||||
const error = new Error(`Profile not found: ${profileName}`) as ProfileNotFoundError;
|
||||
error.profileName = profileName;
|
||||
error.suggestions = suggestions;
|
||||
error.availableProfiles = this._listAvailableProfiles();
|
||||
error.availableProfiles = this.listAvailableProfiles();
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve default profile
|
||||
* @returns {Object} Default profile info
|
||||
*/
|
||||
_resolveDefaultProfile() {
|
||||
private resolveDefaultProfile(): ProfileDetectionResult {
|
||||
// Check if account-based default exists
|
||||
const profiles = this._readProfiles();
|
||||
const profiles = this.readProfiles();
|
||||
|
||||
if (profiles.default && profiles.profiles[profiles.default]) {
|
||||
return {
|
||||
type: 'account',
|
||||
name: profiles.default,
|
||||
profile: profiles.profiles[profiles.default]
|
||||
profile: profiles.profiles[profiles.default],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if settings-based default exists
|
||||
const config = this._readConfig();
|
||||
const config = this.readConfig();
|
||||
|
||||
if (config.profiles && config.profiles['default']) {
|
||||
return {
|
||||
type: 'settings',
|
||||
name: 'default',
|
||||
settingsPath: config.profiles['default']
|
||||
settingsPath: config.profiles['default'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,54 +164,52 @@ class ProfileDetector {
|
||||
return {
|
||||
type: 'default',
|
||||
name: 'default',
|
||||
message: 'No profile configured. Using Claude CLI defaults from ~/.claude/'
|
||||
message: 'No profile configured. Using Claude CLI defaults from ~/.claude/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List available profiles (for error messages)
|
||||
* @returns {string} Formatted list
|
||||
*/
|
||||
_listAvailableProfiles() {
|
||||
const lines = [];
|
||||
private listAvailableProfiles(): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// CLIProxy profiles (OAuth-based, always available)
|
||||
lines.push('CLIProxy profiles (OAuth, zero config):');
|
||||
CLIPROXY_PROFILES.forEach((name) => {
|
||||
lines.push(` - ${name}`);
|
||||
});
|
||||
|
||||
// Settings-based profiles
|
||||
const config = this._readConfig();
|
||||
const config = this.readConfig();
|
||||
const settingsProfiles = Object.keys(config.profiles || {});
|
||||
|
||||
if (settingsProfiles.length > 0) {
|
||||
lines.push('Settings-based profiles (GLM, Kimi, etc.):');
|
||||
settingsProfiles.forEach(name => {
|
||||
settingsProfiles.forEach((name) => {
|
||||
lines.push(` - ${name}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Account-based profiles
|
||||
const profiles = this._readProfiles();
|
||||
const profiles = this.readProfiles();
|
||||
const accountProfiles = Object.keys(profiles.profiles || {});
|
||||
|
||||
if (accountProfiles.length > 0) {
|
||||
lines.push('Account-based profiles:');
|
||||
accountProfiles.forEach(name => {
|
||||
accountProfiles.forEach((name) => {
|
||||
const isDefault = name === profiles.default;
|
||||
lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return ' (no profiles configured)\n' +
|
||||
' Run "ccs auth save <profile>" to create your first account profile.';
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if profile exists (any type)
|
||||
* @param {string} profileName - Profile name
|
||||
* @returns {boolean} True if exists
|
||||
*/
|
||||
hasProfile(profileName) {
|
||||
hasProfile(profileName: string): boolean {
|
||||
try {
|
||||
this.detectProfileType(profileName);
|
||||
return true;
|
||||
@@ -187,18 +220,18 @@ class ProfileDetector {
|
||||
|
||||
/**
|
||||
* Get all available profile names
|
||||
* @returns {Object} {settings: [...], accounts: [...]}
|
||||
*/
|
||||
getAllProfiles() {
|
||||
const config = this._readConfig();
|
||||
const profiles = this._readProfiles();
|
||||
getAllProfiles(): AllProfiles & { cliproxy: string[] } {
|
||||
const config = this.readConfig();
|
||||
const profiles = this.readProfiles();
|
||||
|
||||
return {
|
||||
settings: Object.keys(config.profiles || {}),
|
||||
accounts: Object.keys(profiles.profiles || {}),
|
||||
default: profiles.default
|
||||
cliproxy: [...CLIPROXY_PROFILES],
|
||||
default: profiles.default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProfileDetector;
|
||||
export default ProfileDetector;
|
||||
@@ -1,8 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ProfileMetadata } from '../types';
|
||||
|
||||
/**
|
||||
* Profile Registry (Simplified)
|
||||
@@ -22,37 +21,51 @@ const os = require('os');
|
||||
* - subscription: No credential reading
|
||||
* - email: No credential reading
|
||||
*/
|
||||
class ProfileRegistry {
|
||||
|
||||
interface ProfileData {
|
||||
version: string;
|
||||
profiles: Record<string, ProfileMetadata>;
|
||||
default: string | null;
|
||||
}
|
||||
|
||||
interface CreateMetadata {
|
||||
type?: string;
|
||||
created?: string;
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
export class ProfileRegistry {
|
||||
private profilesPath: string;
|
||||
|
||||
constructor() {
|
||||
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read profiles from disk
|
||||
* @returns {Object} Profiles data
|
||||
*/
|
||||
_read() {
|
||||
private _read(): ProfileData {
|
||||
if (!fs.existsSync(this.profilesPath)) {
|
||||
return {
|
||||
version: '2.0.0',
|
||||
profiles: {},
|
||||
default: null
|
||||
default: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(this.profilesPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
return JSON.parse(data) as ProfileData;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read profiles: ${error.message}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to read profiles: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write profiles to disk atomically
|
||||
* @param {Object} data - Profiles data
|
||||
*/
|
||||
_write(data) {
|
||||
private _write(data: ProfileData): void {
|
||||
const dir = path.dirname(this.profilesPath);
|
||||
|
||||
// Ensure directory exists
|
||||
@@ -71,16 +84,15 @@ class ProfileRegistry {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
throw new Error(`Failed to write profiles: ${error.message}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to write profiles: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new profile
|
||||
* @param {string} name - Profile name
|
||||
* @param {Object} metadata - Profile metadata (type, created, last_used)
|
||||
*/
|
||||
createProfile(name, metadata = {}) {
|
||||
createProfile(name: string, metadata: CreateMetadata = {}): void {
|
||||
const data = this._read();
|
||||
|
||||
if (data.profiles[name]) {
|
||||
@@ -90,8 +102,8 @@ class ProfileRegistry {
|
||||
// v3.0 minimal schema: only essential fields
|
||||
data.profiles[name] = {
|
||||
type: metadata.type || 'account',
|
||||
created: new Date().toISOString(),
|
||||
last_used: null
|
||||
created: metadata.created || new Date().toISOString(),
|
||||
last_used: metadata.last_used || null,
|
||||
};
|
||||
|
||||
// Note: No longer auto-set as default
|
||||
@@ -103,10 +115,8 @@ class ProfileRegistry {
|
||||
|
||||
/**
|
||||
* Get profile metadata
|
||||
* @param {string} name - Profile name
|
||||
* @returns {Object} Profile metadata
|
||||
*/
|
||||
getProfile(name) {
|
||||
getProfile(name: string): ProfileMetadata {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
@@ -118,10 +128,8 @@ class ProfileRegistry {
|
||||
|
||||
/**
|
||||
* Update profile metadata
|
||||
* @param {string} name - Profile name
|
||||
* @param {Object} updates - Fields to update
|
||||
*/
|
||||
updateProfile(name, updates) {
|
||||
updateProfile(name: string, updates: Partial<ProfileMetadata>): void {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
@@ -130,7 +138,7 @@ class ProfileRegistry {
|
||||
|
||||
data.profiles[name] = {
|
||||
...data.profiles[name],
|
||||
...updates
|
||||
...updates,
|
||||
};
|
||||
|
||||
this._write(data);
|
||||
@@ -138,9 +146,8 @@ class ProfileRegistry {
|
||||
|
||||
/**
|
||||
* Delete a profile
|
||||
* @param {string} name - Profile name
|
||||
*/
|
||||
deleteProfile(name) {
|
||||
deleteProfile(name: string): void {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
@@ -161,36 +168,32 @@ class ProfileRegistry {
|
||||
|
||||
/**
|
||||
* List all profiles
|
||||
* @returns {Array} Array of profile names
|
||||
*/
|
||||
listProfiles() {
|
||||
listProfiles(): string[] {
|
||||
const data = this._read();
|
||||
return Object.keys(data.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all profiles with metadata
|
||||
* @returns {Object} All profiles
|
||||
*/
|
||||
getAllProfiles() {
|
||||
getAllProfiles(): Record<string, ProfileMetadata> {
|
||||
const data = this._read();
|
||||
return data.profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default profile name
|
||||
* @returns {string|null} Default profile name
|
||||
*/
|
||||
getDefaultProfile() {
|
||||
getDefaultProfile(): string | null {
|
||||
const data = this._read();
|
||||
return data.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default profile
|
||||
* @param {string} name - Profile name
|
||||
*/
|
||||
setDefaultProfile(name) {
|
||||
setDefaultProfile(name: string): void {
|
||||
const data = this._read();
|
||||
|
||||
if (!data.profiles[name]) {
|
||||
@@ -203,23 +206,20 @@ class ProfileRegistry {
|
||||
|
||||
/**
|
||||
* Check if profile exists
|
||||
* @param {string} name - Profile name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasProfile(name) {
|
||||
hasProfile(name: string): boolean {
|
||||
const data = this._read();
|
||||
return !!data.profiles[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last used timestamp
|
||||
* @param {string} name - Profile name
|
||||
*/
|
||||
touchProfile(name) {
|
||||
touchProfile(name: string): void {
|
||||
this.updateProfile(name, {
|
||||
last_used: new Date().toISOString()
|
||||
last_used: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProfileRegistry;
|
||||
export default ProfileRegistry;
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS (Claude Code Switch) - Entry Point
|
||||
*
|
||||
* Instant profile switching for Claude CLI.
|
||||
* Supports multiple accounts, alternative models (GLM, Kimi),
|
||||
* and cost-optimized delegation.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import { getSettingsPath } from './utils/config-manager';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
|
||||
|
||||
// Import extracted command handlers
|
||||
import { handleVersionCommand } from './commands/version-command';
|
||||
import { handleHelpCommand } from './commands/help-command';
|
||||
import { handleInstallCommand, handleUninstallCommand } from './commands/install-command';
|
||||
import { handleDoctorCommand } from './commands/doctor-command';
|
||||
import { handleSyncCommand } from './commands/sync-command';
|
||||
import { handleShellCompletionCommand } from './commands/shell-completion-command';
|
||||
import { handleUpdateCommand } from './commands/update-command';
|
||||
|
||||
// Import extracted utility functions
|
||||
import { execClaude, escapeShellArg } from './utils/shell-executor';
|
||||
|
||||
// ========== Profile Detection ==========
|
||||
|
||||
interface DetectedProfile {
|
||||
profile: string;
|
||||
remainingArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart profile detection
|
||||
*/
|
||||
function detectProfile(args: string[]): DetectedProfile {
|
||||
if (args.length === 0 || args[0].startsWith('-')) {
|
||||
// No args or first arg is a flag → use default profile
|
||||
return { profile: 'default', remainingArgs: args };
|
||||
} else {
|
||||
// First arg doesn't start with '-' → treat as profile name
|
||||
return { profile: args[0], remainingArgs: args.slice(1) };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== GLMT Proxy Execution ==========
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with embedded proxy (for GLMT profile)
|
||||
*/
|
||||
async function execClaudeWithProxy(
|
||||
claudeCli: string,
|
||||
profileName: string,
|
||||
args: string[]
|
||||
): Promise<void> {
|
||||
// 1. Read settings to get API key
|
||||
const settingsPath = getSettingsPath(profileName);
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const apiKey = settings.env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') {
|
||||
console.error('[X] GLMT profile requires Z.AI API key');
|
||||
console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect verbose flag
|
||||
const verbose = args.includes('--verbose') || args.includes('-v');
|
||||
|
||||
// 2. Spawn embedded proxy with verbose flag
|
||||
const proxyPath = path.join(__dirname, 'glmt', 'glmt-proxy.js');
|
||||
const proxyArgs = verbose ? ['--verbose'] : [];
|
||||
// Use process.execPath for Windows compatibility (CVE-2024-27980)
|
||||
const proxy = spawn(process.execPath, [proxyPath, ...proxyArgs], {
|
||||
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit'],
|
||||
});
|
||||
|
||||
// 3. Wait for proxy ready signal (with timeout)
|
||||
const { ProgressIndicator } = await import('./utils/progress-indicator');
|
||||
const spinner = new ProgressIndicator('Starting GLMT proxy');
|
||||
spinner.start();
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Proxy startup timeout (5s)'));
|
||||
}, 5000);
|
||||
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
const match = data.toString().match(/PROXY_READY:(\d+)/);
|
||||
if (match) {
|
||||
clearTimeout(timeout);
|
||||
resolve(parseInt(match[1]));
|
||||
}
|
||||
});
|
||||
|
||||
proxy.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
proxy.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Proxy exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
spinner.succeed(`GLMT proxy ready on port ${port}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
spinner.fail('Failed to start GLMT proxy');
|
||||
console.error('[X] Error:', err.message);
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(' 1. Port conflict (unlikely with random port)');
|
||||
console.error(' 2. Node.js permission issue');
|
||||
console.error(' 3. Firewall blocking localhost');
|
||||
console.error('');
|
||||
console.error('Workarounds:');
|
||||
console.error(' - Use non-thinking mode: ccs glm "prompt"');
|
||||
console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"');
|
||||
console.error(' - Check proxy logs in ~/.ccs/logs/ (if debug enabled)');
|
||||
console.error('');
|
||||
proxy.kill();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Spawn Claude CLI with proxy URL
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: 'glm-4.6',
|
||||
};
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const env = { ...process.env, ...envVars };
|
||||
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Cleanup: kill proxy when Claude exits
|
||||
claude.on('exit', (code, signal) => {
|
||||
proxy.kill('SIGTERM');
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error('[X] Claude CLI error:', error);
|
||||
proxy.kill('SIGTERM');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Also handle parent process termination
|
||||
process.once('SIGTERM', () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
});
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Main Execution ==========
|
||||
|
||||
interface ProfileError extends Error {
|
||||
profileName?: string;
|
||||
availableProfiles?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Special case: version command (check BEFORE profile detection)
|
||||
const firstArg = args[0];
|
||||
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
|
||||
handleVersionCommand();
|
||||
}
|
||||
|
||||
// Special case: help command
|
||||
if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
|
||||
handleHelpCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: install command
|
||||
if (firstArg === '--install') {
|
||||
handleInstallCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: uninstall command
|
||||
if (firstArg === '--uninstall') {
|
||||
handleUninstallCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: shell completion installer
|
||||
if (firstArg === '--shell-completion' || firstArg === '-sc') {
|
||||
await handleShellCompletionCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: doctor command
|
||||
if (firstArg === 'doctor' || firstArg === '--doctor') {
|
||||
await handleDoctorCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: sync command
|
||||
if (firstArg === 'sync' || firstArg === '--sync') {
|
||||
await handleSyncCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: update command
|
||||
if (firstArg === 'update' || firstArg === '--update') {
|
||||
await handleUpdateCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: auth command
|
||||
if (firstArg === 'auth') {
|
||||
const AuthCommandsModule = await import('./auth/auth-commands');
|
||||
const AuthCommands = AuthCommandsModule.default;
|
||||
const authCommands = new AuthCommands();
|
||||
await authCommands.route(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: headless delegation (-p flag)
|
||||
if (args.includes('-p') || args.includes('--prompt')) {
|
||||
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
||||
const handler = new DelegationHandler();
|
||||
await handler.route(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration
|
||||
const RecoveryManagerModule = await import('./management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
if (recovered) {
|
||||
recovery.showRecoveryHints();
|
||||
}
|
||||
|
||||
// Detect profile
|
||||
const { profile, remainingArgs } = detectProfile(args);
|
||||
|
||||
// Detect Claude CLI first (needed for all paths)
|
||||
const claudeCli = detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use ProfileDetector to determine profile type
|
||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||
const ProfileDetector = ProfileDetectorModule.default;
|
||||
const InstanceManagerModule = await import('./management/instance-manager');
|
||||
const InstanceManager = InstanceManagerModule.default;
|
||||
const ProfileRegistryModule = await import('./auth/profile-registry');
|
||||
const ProfileRegistry = ProfileRegistryModule.default;
|
||||
|
||||
const detector = new ProfileDetector();
|
||||
|
||||
try {
|
||||
const profileInfo = detector.detectProfileType(profile);
|
||||
|
||||
if (profileInfo.type === 'cliproxy') {
|
||||
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy)
|
||||
await execClaudeWithCLIProxy(claudeCli, profileInfo.name as CLIProxyProvider, remainingArgs);
|
||||
} else if (profileInfo.type === 'settings') {
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
// GLMT FLOW: Settings-based with embedded proxy for thinking support
|
||||
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
|
||||
} else {
|
||||
// EXISTING FLOW: Settings-based profile (glm, kimi)
|
||||
// Use --settings flag (backward compatible)
|
||||
const expandedSettingsPath = getSettingsPath(profileInfo.name);
|
||||
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs]);
|
||||
}
|
||||
} else if (profileInfo.type === 'account') {
|
||||
// NEW FLOW: Account-based profile (work, personal)
|
||||
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
// Ensure instance exists (lazy init if needed)
|
||||
const instancePath = instanceMgr.ensureInstance(profileInfo.name);
|
||||
|
||||
// Update last_used timestamp
|
||||
registry.touchProfile(profileInfo.name);
|
||||
|
||||
// Execute Claude with instance isolation
|
||||
const envVars: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: instancePath };
|
||||
execClaude(claudeCli, remainingArgs, envVars);
|
||||
} else {
|
||||
// DEFAULT: No profile configured, use Claude's own defaults
|
||||
execClaude(claudeCli, remainingArgs);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as ProfileError;
|
||||
// Check if this is a profile not found error with suggestions
|
||||
if (err.profileName && err.availableProfiles !== undefined) {
|
||||
const allProfiles = err.availableProfiles.split('\n');
|
||||
ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
|
||||
} else {
|
||||
console.error(`[X] ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable → Regular
+200
-276
@@ -1,34 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* CCS (Claude Code Switch) - Entry Point
|
||||
*
|
||||
* Instant profile switching for Claude CLI.
|
||||
* Supports multiple accounts, alternative models (GLM, Kimi),
|
||||
* and cost-optimized delegation.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { error, colored } = require('./utils/helpers');
|
||||
const { detectClaudeCli, showClaudeNotFoundError } = require('./utils/claude-detector');
|
||||
const { getSettingsPath, getConfigPath } = require('./utils/config-manager');
|
||||
const { ErrorManager } = require('./utils/error-manager');
|
||||
const RecoveryManager = require('./management/recovery-manager');
|
||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { colored } from './utils/helpers';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import { getSettingsPath, getConfigPath } from './utils/config-manager';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { handleHelpCommand } from './commands/help-command';
|
||||
|
||||
// Version (sync with package.json)
|
||||
const CCS_VERSION = require('../package.json').version;
|
||||
const CCS_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
// Helper: Escape arguments for shell execution
|
||||
function escapeShellArg(arg) {
|
||||
// Windows: escape double quotes and wrap in double quotes
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Escape arguments for shell execution (Windows compatibility)
|
||||
*/
|
||||
function escapeShellArg(arg: string): string {
|
||||
return '"' + String(arg).replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
// Execute Claude CLI with unified spawn logic
|
||||
function execClaude(claudeCli, args, envVars = null) {
|
||||
/**
|
||||
* Execute Claude CLI with unified spawn logic
|
||||
*/
|
||||
function execClaude(
|
||||
claudeCli: string,
|
||||
args: string[],
|
||||
envVars: NodeJS.ProcessEnv | null = null
|
||||
): void {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
// Prepare environment (merge with process.env if envVars provided)
|
||||
const env = envVars ? { ...process.env, ...envVars } : process.env;
|
||||
|
||||
let child;
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
@@ -36,57 +53,55 @@ function execClaude(claudeCli, args, envVars = null) {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
// When no shell needed: use array form (faster, no shell overhead)
|
||||
child = spawn(claudeCli, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
showClaudeNotFoundError();
|
||||
ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Special command handlers
|
||||
function handleVersionCommand() {
|
||||
// Title
|
||||
// ========== Command Handlers ==========
|
||||
|
||||
/**
|
||||
* Handle version command
|
||||
*/
|
||||
function handleVersionCommand(): void {
|
||||
console.log(colored(`CCS (Claude Code Switch) v${CCS_VERSION}`, 'bold'));
|
||||
console.log('');
|
||||
|
||||
// Installation section with table-like formatting
|
||||
console.log(colored('Installation:', 'cyan'));
|
||||
|
||||
// Location
|
||||
const installLocation = process.argv[1] || '(not found)';
|
||||
console.log(` ${colored('Location:'.padEnd(17), 'cyan')} ${installLocation}`);
|
||||
|
||||
// .ccs/ directory location
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
console.log(` ${colored('CCS Directory:'.padEnd(17), 'cyan')} ${ccsDir}`);
|
||||
|
||||
// Config path
|
||||
const configPath = getConfigPath();
|
||||
console.log(` ${colored('Config:'.padEnd(17), 'cyan')} ${configPath}`);
|
||||
|
||||
// Profiles.json location
|
||||
const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json');
|
||||
console.log(` ${colored('Profiles:'.padEnd(17), 'cyan')} ${profilesJson}`);
|
||||
|
||||
// Delegation status - check multiple indicators
|
||||
// Delegation status
|
||||
const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
|
||||
const delegationConfigured = fs.existsSync(delegationSessionsPath);
|
||||
|
||||
let readyProfiles = [];
|
||||
const readyProfiles: string[] = [];
|
||||
|
||||
// Check for profiles with valid API keys
|
||||
for (const profile of ['glm', 'kimi']) {
|
||||
@@ -98,7 +113,7 @@ function handleVersionCommand() {
|
||||
if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) {
|
||||
readyProfiles.push(profile);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
}
|
||||
@@ -115,131 +130,31 @@ function handleVersionCommand() {
|
||||
|
||||
console.log('');
|
||||
|
||||
// Ready Profiles section - make it more prominent
|
||||
if (readyProfiles.length > 0) {
|
||||
console.log(colored('Delegation Ready:', 'cyan'));
|
||||
console.log(` ${colored('✓', 'yellow')} ${readyProfiles.join(', ')} profiles are ready for delegation`);
|
||||
console.log(
|
||||
` ${colored('[OK]', 'yellow')} ${readyProfiles.join(', ')} profiles are ready for delegation`
|
||||
);
|
||||
console.log('');
|
||||
} else if (delegationEnabled) {
|
||||
console.log(colored('Delegation Ready:', 'cyan'));
|
||||
console.log(` ${colored('!', 'yellow')} Delegation configured but no valid API keys found`);
|
||||
console.log(` ${colored('[!]', 'yellow')} Delegation configured but no valid API keys found`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Documentation
|
||||
console.log(`${colored('Documentation:', 'cyan')} https://github.com/kaitranntt/ccs`);
|
||||
console.log(`${colored('License:', 'cyan')} MIT`);
|
||||
console.log('');
|
||||
|
||||
// Help hint
|
||||
console.log(colored('Run \'ccs --help\' for usage information', 'yellow'));
|
||||
console.log(colored("Run 'ccs --help' for usage information", 'yellow'));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function handleHelpCommand() {
|
||||
// Title
|
||||
console.log(colored('CCS (Claude Code Switch) - Instant profile switching for Claude CLI', 'bold'));
|
||||
console.log('');
|
||||
/**
|
||||
* Handle help command
|
||||
*/
|
||||
|
||||
// Usage
|
||||
console.log(colored('Usage:', 'cyan'));
|
||||
console.log(` ${colored('ccs', 'yellow')} [profile] [claude-args...]`);
|
||||
console.log(` ${colored('ccs', 'yellow')} [flags]`);
|
||||
console.log('');
|
||||
|
||||
// Description
|
||||
console.log(colored('Description:', 'cyan'));
|
||||
console.log(' Switch between multiple Claude accounts and alternative models');
|
||||
console.log(' (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently');
|
||||
console.log(' with auto-recovery. Zero downtime.');
|
||||
console.log('');
|
||||
|
||||
// Model Switching
|
||||
console.log(colored('Model Switching:', 'cyan'));
|
||||
console.log(` ${colored('ccs', 'yellow')} Use default Claude account`);
|
||||
console.log(` ${colored('ccs glm', 'yellow')} Switch to GLM 4.6 model`);
|
||||
console.log(` ${colored('ccs glmt', 'yellow')} Switch to GLM with thinking mode`);
|
||||
console.log(` ${colored('ccs glmt --verbose', 'yellow')} Enable debug logging`);
|
||||
console.log(` ${colored('ccs kimi', 'yellow')} Switch to Kimi for Coding`);
|
||||
console.log(` ${colored('ccs glm', 'yellow')} "debug this code" Use GLM and run command`);
|
||||
console.log('');
|
||||
|
||||
// Account Management
|
||||
console.log(colored('Account Management:', 'cyan'));
|
||||
console.log(` ${colored('ccs auth --help', 'yellow')} Run multiple Claude accounts concurrently`);
|
||||
console.log('');
|
||||
|
||||
// Delegation (inside Claude Code CLI)
|
||||
console.log(colored('Delegation (inside Claude Code CLI):', 'cyan'));
|
||||
console.log(` ${colored('/ccs "task"', 'yellow')} Delegate task (auto-selects best profile)`);
|
||||
console.log(` ${colored('/ccs --glm "task"', 'yellow')} Force GLM-4.6 for simple tasks`);
|
||||
console.log(` ${colored('/ccs --kimi "task"', 'yellow')} Force Kimi for long context`);
|
||||
console.log(` ${colored('/ccs:continue "follow-up"', 'yellow')} Continue last delegation session`);
|
||||
console.log(' Save tokens by delegating simple tasks to cost-optimized models');
|
||||
console.log('');
|
||||
|
||||
// Diagnostics
|
||||
console.log(colored('Diagnostics:', 'cyan'));
|
||||
console.log(` ${colored('ccs doctor', 'yellow')} Run health check and diagnostics`);
|
||||
console.log(` ${colored('ccs sync', 'yellow')} Sync delegation commands and skills`);
|
||||
console.log(` ${colored('ccs update', 'yellow')} Update CCS to latest version`);
|
||||
console.log('');
|
||||
|
||||
// Flags
|
||||
console.log(colored('Flags:', 'cyan'));
|
||||
console.log(` ${colored('-h, --help', 'yellow')} Show this help message`);
|
||||
console.log(` ${colored('-v, --version', 'yellow')} Show version and installation info`);
|
||||
console.log(` ${colored('-sc, --shell-completion', 'yellow')} Install shell auto-completion`);
|
||||
console.log('');
|
||||
|
||||
// Configuration
|
||||
console.log(colored('Configuration:', 'cyan'));
|
||||
console.log(' Config File: ~/.ccs/config.json');
|
||||
console.log(' Profiles: ~/.ccs/profiles.json');
|
||||
console.log(' Instances: ~/.ccs/instances/');
|
||||
console.log(' Settings: ~/.ccs/*.settings.json');
|
||||
console.log(' Environment: CCS_CONFIG (override config path)');
|
||||
console.log('');
|
||||
|
||||
// Shared Data
|
||||
console.log(colored('Shared Data:', 'cyan'));
|
||||
console.log(' Commands: ~/.ccs/shared/commands/');
|
||||
console.log(' Skills: ~/.ccs/shared/skills/');
|
||||
console.log(' Agents: ~/.ccs/shared/agents/');
|
||||
console.log(' Plugins: ~/.ccs/shared/plugins/');
|
||||
console.log(' Note: Commands, skills, agents, and plugins are symlinked across all profiles');
|
||||
console.log('');
|
||||
|
||||
// Examples
|
||||
console.log(colored('Examples:', 'cyan'));
|
||||
console.log(` ${colored('$ ccs', 'yellow')} # Use default account`);
|
||||
console.log(` ${colored('$ ccs glm "implement API"', 'yellow')} # Cost-optimized model`);
|
||||
console.log('');
|
||||
console.log(` For more: ${colored('https://github.com/kaitranntt/ccs/blob/main/README.md', 'cyan')}`);
|
||||
console.log('');
|
||||
|
||||
// Uninstall
|
||||
console.log(colored('Uninstall:', 'yellow'));
|
||||
console.log(' npm: npm uninstall -g @kaitranntt/ccs');
|
||||
console.log(' macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash');
|
||||
console.log(' Windows: irm ccs.kaitran.ca/uninstall | iex');
|
||||
console.log('');
|
||||
|
||||
// Documentation
|
||||
console.log(colored('Documentation:', 'cyan'));
|
||||
console.log(` GitHub: ${colored('https://github.com/kaitranntt/ccs', 'cyan')}`);
|
||||
console.log(' Docs: https://github.com/kaitranntt/ccs/blob/main/README.md');
|
||||
console.log(' Issues: https://github.com/kaitranntt/ccs/issues');
|
||||
console.log('');
|
||||
|
||||
// License
|
||||
console.log(`${colored('License:', 'cyan')} MIT`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function handleInstallCommand() {
|
||||
function handleInstallCommand(): void {
|
||||
console.log('');
|
||||
console.log('Feature not available');
|
||||
console.log('');
|
||||
@@ -251,7 +166,7 @@ function handleInstallCommand() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function handleUninstallCommand() {
|
||||
function handleUninstallCommand(): void {
|
||||
console.log('');
|
||||
console.log('Feature not available');
|
||||
console.log('');
|
||||
@@ -263,25 +178,24 @@ function handleUninstallCommand() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function handleDoctorCommand() {
|
||||
const Doctor = require('./management/doctor');
|
||||
async function handleDoctorCommand(): Promise<void> {
|
||||
const DoctorModule = await import('./management/doctor');
|
||||
const Doctor = DoctorModule.default;
|
||||
const doctor = new Doctor();
|
||||
|
||||
await doctor.runAllChecks();
|
||||
|
||||
// Exit with error code if unhealthy
|
||||
process.exit(doctor.results.isHealthy() ? 0 : 1);
|
||||
process.exit(doctor.isHealthy() ? 0 : 1);
|
||||
}
|
||||
|
||||
async function handleSyncCommand() {
|
||||
const { colored } = require('./utils/helpers');
|
||||
|
||||
async function handleSyncCommand(): Promise<void> {
|
||||
console.log('');
|
||||
console.log(colored('Syncing CCS Components...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
// First, copy .claude/ directory from package to ~/.ccs/.claude/
|
||||
const ClaudeDirInstaller = require('./utils/claude-dir-installer');
|
||||
const { ClaudeDirInstaller } = await import('./utils/claude-dir-installer');
|
||||
const installer = new ClaudeDirInstaller();
|
||||
installer.install();
|
||||
|
||||
@@ -293,7 +207,7 @@ async function handleSyncCommand() {
|
||||
}
|
||||
|
||||
// Then, create symlinks from ~/.ccs/.claude/ to ~/.claude/
|
||||
const ClaudeSymlinkManager = require('./utils/claude-symlink-manager');
|
||||
const { ClaudeSymlinkManager } = await import('./utils/claude-symlink-manager');
|
||||
const manager = new ClaudeSymlinkManager();
|
||||
manager.install(false);
|
||||
|
||||
@@ -306,9 +220,8 @@ async function handleSyncCommand() {
|
||||
|
||||
/**
|
||||
* Detect installation method
|
||||
* @returns {'npm'|'direct'} - Installation method
|
||||
*/
|
||||
function detectInstallationMethod() {
|
||||
function detectInstallationMethod(): 'npm' | 'direct' {
|
||||
const scriptPath = process.argv[1];
|
||||
|
||||
// Method 1: Check if script is inside node_modules
|
||||
@@ -317,17 +230,15 @@ function detectInstallationMethod() {
|
||||
}
|
||||
|
||||
// Method 2: Check if script is in npm global bin directory
|
||||
// Common patterns for npm global installations
|
||||
const npmGlobalBinPatterns = [
|
||||
/\.npm\/global\/bin\//, // ~/.npm/global/bin/ccs
|
||||
/\/\.nvm\/versions\/node\/[^\/]+\/bin\//, // ~/.nvm/versions/node/v22.19.0/bin/ccs
|
||||
/\/usr\/local\/bin\//, // /usr/local/bin/ccs (if npm global prefix is /usr/local)
|
||||
/\/usr\/bin\// // /usr/bin/ccs (if npm global prefix is /usr)
|
||||
/\.npm\/global\/bin\//,
|
||||
/\/\.nvm\/versions\/node\/[^/]+\/bin\//,
|
||||
/\/usr\/local\/bin\//,
|
||||
/\/usr\/bin\//,
|
||||
];
|
||||
|
||||
for (const pattern of npmGlobalBinPatterns) {
|
||||
if (pattern.test(scriptPath)) {
|
||||
// Verify this is actually CCS by checking the linked target
|
||||
try {
|
||||
const binDir = path.dirname(scriptPath);
|
||||
const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs');
|
||||
@@ -336,23 +247,22 @@ function detectInstallationMethod() {
|
||||
if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue checking other patterns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check if package.json exists in parent directory (development mode)
|
||||
// Method 3: Check if package.json exists in parent directory
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
// If package.json has name "@kaitranntt/ccs", it's npm install
|
||||
if (pkg.name === '@kaitranntt/ccs') {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -366,19 +276,17 @@ function detectInstallationMethod() {
|
||||
return 'npm';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue to default
|
||||
}
|
||||
|
||||
// Default to direct installation
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager was used for installation
|
||||
* @returns {'npm'|'yarn'|'pnpm'|'bun'|'unknown'}
|
||||
*/
|
||||
function detectPackageManager() {
|
||||
function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' {
|
||||
const scriptPath = process.argv[1];
|
||||
|
||||
// Check if script path contains package manager indicators
|
||||
@@ -388,9 +296,7 @@ function detectPackageManager() {
|
||||
|
||||
// Check parent directories for lock files
|
||||
const binDir = path.dirname(scriptPath);
|
||||
const fs = require('fs');
|
||||
|
||||
// Check global node_modules parent for lock files
|
||||
let checkDir = binDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
@@ -400,81 +306,72 @@ function detectPackageManager() {
|
||||
}
|
||||
|
||||
// Check if package managers are available on the system
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Try yarn global list to see if CCS is installed via yarn
|
||||
try {
|
||||
const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
});
|
||||
if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'yarn';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue to next check
|
||||
}
|
||||
|
||||
// Try pnpm list -g to see if CCS is installed via pnpm
|
||||
try {
|
||||
const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
});
|
||||
if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'pnpm';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue to next check
|
||||
}
|
||||
|
||||
// Try bun pm ls -g to see if CCS is installed via bun
|
||||
try {
|
||||
const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
});
|
||||
if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'bun';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue to default
|
||||
}
|
||||
|
||||
// Default to npm
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
async function handleUpdateCommand() {
|
||||
const { checkForUpdates } = require('./utils/update-checker');
|
||||
const { spawn } = require('child_process');
|
||||
async function handleUpdateCommand(): Promise<void> {
|
||||
const { checkForUpdates } = await import('./utils/update-checker');
|
||||
|
||||
console.log('');
|
||||
console.log(colored('Checking for updates...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
// Detect installation method for proper update source
|
||||
const installMethod = detectInstallationMethod();
|
||||
const isNpmInstall = installMethod === 'npm';
|
||||
|
||||
// Check for updates (force check)
|
||||
const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod);
|
||||
|
||||
if (updateResult.status === 'check_failed') {
|
||||
console.log(colored(`[X] ${updateResult.message}`, 'red'));
|
||||
console.log('');
|
||||
console.log(colored('[i] Possible causes:', 'yellow'));
|
||||
console.log(' • Network connection issues');
|
||||
console.log(' • Firewall blocking requests');
|
||||
console.log(' • GitHub/npm API temporarily unavailable');
|
||||
console.log(' - Network connection issues');
|
||||
console.log(' - Firewall blocking requests');
|
||||
console.log(' - GitHub/npm API temporarily unavailable');
|
||||
console.log('');
|
||||
console.log('Try again later or update manually:');
|
||||
if (isNpmInstall) {
|
||||
const packageManager = detectPackageManager();
|
||||
let manualCommand;
|
||||
let manualCommand: string;
|
||||
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
@@ -509,7 +406,6 @@ async function handleUpdateCommand() {
|
||||
if (updateResult.status === 'no_update') {
|
||||
let message = `You are already on the latest version (${CCS_VERSION})`;
|
||||
|
||||
// Add context for why no update is shown
|
||||
switch (updateResult.reason) {
|
||||
case 'dismissed':
|
||||
message = `Update dismissed. You are on version ${CCS_VERSION}`;
|
||||
@@ -527,13 +423,17 @@ async function handleUpdateCommand() {
|
||||
}
|
||||
|
||||
// Update available
|
||||
console.log(colored(`[i] Update available: ${updateResult.current} → ${updateResult.latest}`, 'yellow'));
|
||||
console.log(
|
||||
colored(`[i] Update available: ${updateResult.current} -> ${updateResult.latest}`, 'yellow')
|
||||
);
|
||||
console.log('');
|
||||
|
||||
if (isNpmInstall) {
|
||||
// npm installation - detect package manager and update
|
||||
const packageManager = detectPackageManager();
|
||||
let updateCommand, updateArgs, cacheCommand, cacheArgs;
|
||||
let updateCommand: string;
|
||||
let updateArgs: string[];
|
||||
let cacheCommand: string | null;
|
||||
let cacheArgs: string[] | null;
|
||||
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
@@ -557,7 +457,7 @@ async function handleUpdateCommand() {
|
||||
case 'bun':
|
||||
updateCommand = 'bun';
|
||||
updateArgs = ['add', '-g', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = null; // bun doesn't need explicit cache clearing
|
||||
cacheCommand = null;
|
||||
cacheArgs = null;
|
||||
break;
|
||||
default:
|
||||
@@ -570,35 +470,9 @@ async function handleUpdateCommand() {
|
||||
console.log(colored(`Updating via ${packageManager}...`, 'cyan'));
|
||||
console.log('');
|
||||
|
||||
// Clear package manager cache first to ensure fresh download
|
||||
if (cacheCommand) {
|
||||
console.log(colored('Clearing package cache...', 'cyan'));
|
||||
const cacheChild = spawn(cacheCommand, cacheArgs, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
cacheChild.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
}
|
||||
// Continue with update regardless of cache clearing result
|
||||
performUpdate();
|
||||
});
|
||||
|
||||
cacheChild.on('error', (err) => {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
// Continue with update regardless of cache clearing result
|
||||
performUpdate();
|
||||
});
|
||||
} else {
|
||||
// No cache clearing needed, proceed directly
|
||||
performUpdate();
|
||||
}
|
||||
|
||||
function performUpdate() {
|
||||
const performUpdate = (): void => {
|
||||
const child = spawn(updateCommand, updateArgs, {
|
||||
stdio: 'inherit'
|
||||
// No shell needed for direct commands
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
@@ -619,7 +493,7 @@ async function handleUpdateCommand() {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
child.on('error', () => {
|
||||
console.log('');
|
||||
console.log(colored(`[X] Failed to run ${packageManager} update`, 'red'));
|
||||
console.log('');
|
||||
@@ -628,6 +502,27 @@ async function handleUpdateCommand() {
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
if (cacheCommand && cacheArgs) {
|
||||
console.log(colored('Clearing package cache...', 'cyan'));
|
||||
const cacheChild = spawn(cacheCommand, cacheArgs, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
cacheChild.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
}
|
||||
performUpdate();
|
||||
});
|
||||
|
||||
cacheChild.on('error', () => {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
performUpdate();
|
||||
});
|
||||
} else {
|
||||
performUpdate();
|
||||
}
|
||||
} else {
|
||||
// Direct installation - re-run installer
|
||||
@@ -635,22 +530,25 @@ async function handleUpdateCommand() {
|
||||
console.log('');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
let command, args;
|
||||
let command: string;
|
||||
let args: string[];
|
||||
|
||||
if (isWindows) {
|
||||
// PowerShell
|
||||
command = 'powershell.exe';
|
||||
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command',
|
||||
'irm ccs.kaitran.ca/install | iex'];
|
||||
args = [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
'irm ccs.kaitran.ca/install | iex',
|
||||
];
|
||||
} else {
|
||||
// Unix (bash with proper shell invocation)
|
||||
command = '/bin/bash';
|
||||
args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash'];
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit'
|
||||
// Do NOT use shell: true
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
@@ -675,7 +573,7 @@ async function handleUpdateCommand() {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
child.on('error', () => {
|
||||
console.log('');
|
||||
console.log(colored('[X] Failed to run installer', 'red'));
|
||||
console.log('');
|
||||
@@ -691,8 +589,17 @@ async function handleUpdateCommand() {
|
||||
}
|
||||
}
|
||||
|
||||
// Smart profile detection
|
||||
function detectProfile(args) {
|
||||
// ========== Profile Detection ==========
|
||||
|
||||
interface DetectedProfile {
|
||||
profile: string;
|
||||
remainingArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart profile detection
|
||||
*/
|
||||
function detectProfile(args: string[]): DetectedProfile {
|
||||
if (args.length === 0 || args[0].startsWith('-')) {
|
||||
// No args or first arg is a flag → use default profile
|
||||
return { profile: 'default', remainingArgs: args };
|
||||
@@ -702,10 +609,16 @@ function detectProfile(args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute Claude CLI with embedded proxy (for GLMT profile)
|
||||
async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
const { getSettingsPath } = require('./utils/config-manager');
|
||||
// ========== GLMT Proxy Execution ==========
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with embedded proxy (for GLMT profile)
|
||||
*/
|
||||
async function execClaudeWithProxy(
|
||||
claudeCli: string,
|
||||
profileName: string,
|
||||
args: string[]
|
||||
): Promise<void> {
|
||||
// 1. Read settings to get API key
|
||||
const settingsPath = getSettingsPath(profileName);
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
@@ -725,22 +638,22 @@ async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
const proxyArgs = verbose ? ['--verbose'] : [];
|
||||
// Use process.execPath for Windows compatibility (CVE-2024-27980)
|
||||
const proxy = spawn(process.execPath, [proxyPath, ...proxyArgs], {
|
||||
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit']
|
||||
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit'],
|
||||
});
|
||||
|
||||
// 3. Wait for proxy ready signal (with timeout)
|
||||
const { ProgressIndicator } = require('./utils/progress-indicator');
|
||||
const { ProgressIndicator } = await import('./utils/progress-indicator');
|
||||
const spinner = new ProgressIndicator('Starting GLMT proxy');
|
||||
spinner.start();
|
||||
|
||||
let port;
|
||||
let port: number;
|
||||
try {
|
||||
port = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Proxy startup timeout (5s)'));
|
||||
}, 5000);
|
||||
|
||||
proxy.stdout.on('data', (data) => {
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
const match = data.toString().match(/PROXY_READY:(\d+)/);
|
||||
if (match) {
|
||||
clearTimeout(timeout);
|
||||
@@ -763,8 +676,9 @@ async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
|
||||
spinner.succeed(`GLMT proxy ready on port ${port}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
spinner.fail('Failed to start GLMT proxy');
|
||||
console.error('[X] Error:', error.message);
|
||||
console.error('[X] Error:', err.message);
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(' 1. Port conflict (unlikely with random port)');
|
||||
@@ -781,40 +695,37 @@ async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
}
|
||||
|
||||
// 4. Spawn Claude CLI with proxy URL
|
||||
const envVars = {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: 'glm-4.6'
|
||||
ANTHROPIC_MODEL: 'glm-4.6',
|
||||
};
|
||||
|
||||
// Use existing execClaude helper for consistent Windows handling
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const env = { ...process.env, ...envVars };
|
||||
|
||||
let claude;
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
// When no shell needed: use array form (faster, no shell overhead)
|
||||
claude = spawn(claudeCli, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Cleanup: kill proxy when Claude exits
|
||||
claude.on('exit', (code, signal) => {
|
||||
proxy.kill('SIGTERM');
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
@@ -824,7 +735,7 @@ async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Also handle parent process termination (use .once to avoid duplicates)
|
||||
// Also handle parent process termination
|
||||
process.once('SIGTERM', () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
@@ -839,15 +750,14 @@ async function execClaudeWithProxy(claudeCli, profileName, args) {
|
||||
/**
|
||||
* Handle shell completion installation
|
||||
*/
|
||||
async function handleShellCompletionCommand(args) {
|
||||
const { ShellCompletionInstaller } = require('./utils/shell-completion');
|
||||
const { colored } = require('./utils/helpers');
|
||||
async function handleShellCompletionCommand(args: string[]): Promise<void> {
|
||||
const { ShellCompletionInstaller } = await import('./utils/shell-completion');
|
||||
|
||||
console.log(colored('Shell Completion Installer', 'bold'));
|
||||
console.log('');
|
||||
|
||||
// Parse flags
|
||||
let targetShell = null;
|
||||
let targetShell: string | null = null;
|
||||
if (args.includes('--bash')) targetShell = 'bash';
|
||||
else if (args.includes('--zsh')) targetShell = 'zsh';
|
||||
else if (args.includes('--fish')) targetShell = 'fish';
|
||||
@@ -855,7 +765,7 @@ async function handleShellCompletionCommand(args) {
|
||||
|
||||
try {
|
||||
const installer = new ShellCompletionInstaller();
|
||||
const result = installer.install(targetShell);
|
||||
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null);
|
||||
|
||||
if (result.alreadyInstalled) {
|
||||
console.log(colored('[OK] Shell completion already installed', 'green'));
|
||||
@@ -875,7 +785,8 @@ async function handleShellCompletionCommand(args) {
|
||||
console.log(' ccs auth <TAB> # See auth subcommands');
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error(colored('[X] Error:', 'red'), error.message);
|
||||
const err = error as Error;
|
||||
console.error(colored('[X] Error:', 'red'), err.message);
|
||||
console.error('');
|
||||
console.error(colored('Usage:', 'yellow'));
|
||||
console.error(' ccs --shell-completion # Auto-detect shell');
|
||||
@@ -888,8 +799,15 @@ async function handleShellCompletionCommand(args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
// ========== Main Execution ==========
|
||||
|
||||
interface ProfileError extends Error {
|
||||
profileName?: string;
|
||||
availableProfiles?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Special case: version command (check BEFORE profile detection)
|
||||
@@ -928,21 +846,22 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: sync command (sync delegation commands and skills to ~/.claude/)
|
||||
// Special case: sync command
|
||||
if (firstArg === 'sync' || firstArg === '--sync') {
|
||||
await handleSyncCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: update command (update CCS to latest version)
|
||||
// Special case: update command
|
||||
if (firstArg === 'update' || firstArg === '--update') {
|
||||
await handleUpdateCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: auth command (multi-account management)
|
||||
// Special case: auth command
|
||||
if (firstArg === 'auth') {
|
||||
const AuthCommands = require('./auth/auth-commands');
|
||||
const AuthCommandsModule = await import('./auth/auth-commands');
|
||||
const AuthCommands = AuthCommandsModule.default;
|
||||
const authCommands = new AuthCommands();
|
||||
await authCommands.route(args.slice(1));
|
||||
return;
|
||||
@@ -950,13 +869,15 @@ async function main() {
|
||||
|
||||
// Special case: headless delegation (-p flag)
|
||||
if (args.includes('-p') || args.includes('--prompt')) {
|
||||
const DelegationHandler = require('./delegation/delegation-handler');
|
||||
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
||||
const handler = new DelegationHandler();
|
||||
await handler.route(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration
|
||||
const RecoveryManagerModule = await import('./management/recovery-manager');
|
||||
const RecoveryManager = RecoveryManagerModule.default;
|
||||
const recovery = new RecoveryManager();
|
||||
const recovered = recovery.recoverAll();
|
||||
|
||||
@@ -975,10 +896,12 @@ async function main() {
|
||||
}
|
||||
|
||||
// Use ProfileDetector to determine profile type
|
||||
const ProfileDetector = require('./auth/profile-detector');
|
||||
const InstanceManager = require('./management/instance-manager');
|
||||
const ProfileRegistry = require('./auth/profile-registry');
|
||||
const { getSettingsPath } = require('./utils/config-manager');
|
||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||
const ProfileDetector = ProfileDetectorModule.default;
|
||||
const InstanceManagerModule = await import('./management/instance-manager');
|
||||
const InstanceManager = InstanceManagerModule.default;
|
||||
const ProfileRegistryModule = await import('./auth/profile-registry');
|
||||
const ProfileRegistry = ProfileRegistryModule.default;
|
||||
|
||||
const detector = new ProfileDetector();
|
||||
|
||||
@@ -1009,26 +932,27 @@ async function main() {
|
||||
registry.touchProfile(profileInfo.name);
|
||||
|
||||
// Execute Claude with instance isolation
|
||||
const envVars = { CLAUDE_CONFIG_DIR: instancePath };
|
||||
const envVars: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: instancePath };
|
||||
execClaude(claudeCli, remainingArgs, envVars);
|
||||
} else {
|
||||
// DEFAULT: No profile configured, use Claude's own defaults
|
||||
execClaude(claudeCli, remainingArgs);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as ProfileError;
|
||||
// Check if this is a profile not found error with suggestions
|
||||
if (error.profileName && error.availableProfiles !== undefined) {
|
||||
const allProfiles = error.availableProfiles.split('\n');
|
||||
ErrorManager.showProfileNotFound(error.profileName, allProfiles, error.suggestions);
|
||||
if (err.profileName && err.availableProfiles !== undefined) {
|
||||
const allProfiles = err.availableProfiles.split('\n');
|
||||
ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
|
||||
} else {
|
||||
console.error(`[X] ${error.message}`);
|
||||
console.error(`[X] ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main
|
||||
main().catch(error => {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Auth Handler for CLIProxyAPI
|
||||
*
|
||||
* Manages OAuth authentication 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
|
||||
*
|
||||
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
|
||||
* Each provider has its own directory to avoid conflicts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { getProviderAuthDir, generateConfig } from './config-generator';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import { CLIProxyProvider } from './types';
|
||||
|
||||
/**
|
||||
* Detect if running in a headless environment (no browser available)
|
||||
*/
|
||||
function isHeadlessEnvironment(): boolean {
|
||||
// SSH session
|
||||
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No display (Linux/X11)
|
||||
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-interactive (piped stdin)
|
||||
if (!process.stdin.isTTY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth config for each provider
|
||||
*/
|
||||
interface ProviderOAuthConfig {
|
||||
/** Provider identifier */
|
||||
provider: CLIProxyProvider;
|
||||
/** Display name */
|
||||
displayName: string;
|
||||
/** OAuth authorization URL (for manual flow) */
|
||||
authUrl: string;
|
||||
/** Scopes required */
|
||||
scopes: string[];
|
||||
/** CLI flag for auth */
|
||||
authFlag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth configurations per provider
|
||||
* Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow
|
||||
*/
|
||||
const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Google Gemini',
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
scopes: ['https://www.googleapis.com/auth/generative-language'],
|
||||
authFlag: '-login',
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
authUrl: 'https://auth.openai.com/authorize',
|
||||
scopes: ['openid', 'profile'],
|
||||
authFlag: '-codex-login',
|
||||
},
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
authUrl: 'https://antigravity.ai/oauth/authorize',
|
||||
scopes: ['api'],
|
||||
authFlag: '-antigravity-login',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
|
||||
*/
|
||||
const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
|
||||
gemini: ['gemini-', 'google-'],
|
||||
codex: ['codex-', 'openai-'],
|
||||
agy: ['antigravity-', 'agy-'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if provider has valid authentication
|
||||
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
||||
* Files are named with provider prefix (e.g., "antigravity-user@email.json").
|
||||
* We MUST check for provider-specific files, not just any .json file.
|
||||
*/
|
||||
export function isAuthenticated(provider: CLIProxyProvider): boolean {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get valid prefixes for this provider
|
||||
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
||||
|
||||
// Check for provider-specific token files
|
||||
try {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const providerTokenFiles = files.filter((f) => {
|
||||
// Must be a token-like file
|
||||
if (!f.endsWith('.json') && !f.endsWith('.token') && f !== 'credentials') {
|
||||
return false;
|
||||
}
|
||||
// Must have provider-specific prefix
|
||||
const lowerFile = f.toLowerCase();
|
||||
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
||||
});
|
||||
return providerTokenFiles.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed auth status for provider
|
||||
*/
|
||||
export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
let tokenFiles: string[] = [];
|
||||
let lastAuth: Date | undefined;
|
||||
|
||||
// Get valid prefixes for this provider
|
||||
const validPrefixes = PROVIDER_AUTH_PREFIXES[provider] || [];
|
||||
|
||||
if (fs.existsSync(tokenDir)) {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
// Only include provider-specific files
|
||||
tokenFiles = files.filter((f) => {
|
||||
if (!f.endsWith('.json') && !f.endsWith('.token') && f !== 'credentials') {
|
||||
return false;
|
||||
}
|
||||
const lowerFile = f.toLowerCase();
|
||||
return validPrefixes.some((prefix) => lowerFile.startsWith(prefix));
|
||||
});
|
||||
|
||||
// Get most recent modification time
|
||||
for (const file of tokenFiles) {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!lastAuth || stats.mtime > lastAuth) {
|
||||
lastAuth = stats.mtime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
authenticated: tokenFiles.length > 0,
|
||||
tokenDir,
|
||||
tokenFiles,
|
||||
lastAuth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth status for all providers
|
||||
*/
|
||||
export function getAllAuthStatus(): AuthStatus[] {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy'];
|
||||
return providers.map(getAuthStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication for provider
|
||||
*/
|
||||
export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove all files in token directory
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(tokenDir, file));
|
||||
}
|
||||
|
||||
// Remove directory
|
||||
fs.rmdirSync(tokenDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger OAuth flow for provider
|
||||
* Auto-detects headless environment and uses -no-browser flag accordingly
|
||||
*/
|
||||
export async function triggerOAuth(
|
||||
provider: CLIProxyProvider,
|
||||
options: { verbose?: boolean; headless?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false } = options;
|
||||
|
||||
// Auto-detect headless if not explicitly set
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[auth] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
} catch (error) {
|
||||
console.error('[X] Failed to prepare CLIProxy binary');
|
||||
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}`);
|
||||
|
||||
// Build args: config + auth flag + optional -no-browser for headless
|
||||
const args = ['-config', configPath, oauthConfig.authFlag];
|
||||
if (headless) {
|
||||
args.push('-no-browser');
|
||||
}
|
||||
|
||||
// Show appropriate message
|
||||
console.log('');
|
||||
if (headless) {
|
||||
console.log(`[i] Headless mode detected - manual authentication required`);
|
||||
console.log(`[i] ${oauthConfig.displayName} will display an OAuth URL below`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`);
|
||||
console.log('[i] Complete the login in your browser.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`);
|
||||
if (!headless) {
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// Spawn CLIProxyAPI with auth flag (and -no-browser if headless)
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CLI_PROXY_AUTH_DIR: tokenDir,
|
||||
},
|
||||
});
|
||||
|
||||
let stderrData = '';
|
||||
let urlDisplayed = false;
|
||||
|
||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
log(`stdout: ${output.trim()}`);
|
||||
|
||||
// In headless mode, display OAuth URLs prominently
|
||||
if (headless) {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
// Look for URLs in the output
|
||||
const urlMatch = line.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch && !urlDisplayed) {
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log('');
|
||||
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
|
||||
urlDisplayed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stderrData += output;
|
||||
log(`stderr: ${output.trim()}`);
|
||||
|
||||
// Also check stderr for URLs (some tools output there)
|
||||
if (headless && !urlDisplayed) {
|
||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log('');
|
||||
console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)');
|
||||
urlDisplayed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes for headless (user needs time to copy URL)
|
||||
const timeoutMs = headless ? 300000 : 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!headless) spinner.fail('Authentication timeout');
|
||||
authProcess.kill();
|
||||
console.error(`[X] OAuth timed out after ${headless ? 5 : 2} minutes`);
|
||||
console.error('');
|
||||
if (!headless) {
|
||||
console.error('Troubleshooting:');
|
||||
console.error(' - Make sure a browser is available');
|
||||
console.error(' - Try running with --verbose for details');
|
||||
}
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
authProcess.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||
|
||||
// Verify token was created
|
||||
if (isAuthenticated(provider)) {
|
||||
console.log('[OK] Authentication successful');
|
||||
resolve(true);
|
||||
} else {
|
||||
if (!headless) spinner.fail('Authentication incomplete');
|
||||
console.error('[X] Token not found after authentication');
|
||||
console.error(' The OAuth flow may have been cancelled');
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
if (!headless) spinner.fail('Authentication failed');
|
||||
console.error(`[X] CLIProxyAPI auth exited with code ${code}`);
|
||||
if (stderrData && !urlDisplayed) {
|
||||
console.error(` ${stderrData.trim().split('\n')[0]}`);
|
||||
}
|
||||
// Show headless hint if we detected headless environment
|
||||
if (headless && !urlDisplayed) {
|
||||
console.error('');
|
||||
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
authProcess.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
if (!headless) spinner.fail('Authentication error');
|
||||
console.error(`[X] Failed to start auth process: ${error.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure provider is authenticated
|
||||
* Triggers OAuth flow if not authenticated
|
||||
*/
|
||||
export async function ensureAuth(
|
||||
provider: CLIProxyProvider,
|
||||
options: { verbose?: boolean; headless?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
// Check if already authenticated
|
||||
if (isAuthenticated(provider)) {
|
||||
if (options.verbose) {
|
||||
console.error(`[auth] ${provider} already authenticated`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not authenticated - trigger OAuth
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
console.log(`[i] ${oauthConfig.displayName} authentication required`);
|
||||
|
||||
return triggerOAuth(provider, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display auth status for all providers
|
||||
*/
|
||||
export function displayAuthStatus(): void {
|
||||
console.log('CLIProxy Authentication Status:');
|
||||
console.log('');
|
||||
|
||||
const statuses = getAllAuthStatus();
|
||||
|
||||
for (const status of statuses) {
|
||||
const oauthConfig = getOAuthConfig(status.provider);
|
||||
const icon = status.authenticated ? '[OK]' : '[!]';
|
||||
const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated';
|
||||
const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : '';
|
||||
|
||||
console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('To authenticate: ccs <provider> --auth');
|
||||
console.log('To logout: ccs <provider> --logout');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Base Config Loader for CLIProxy Providers
|
||||
*
|
||||
* Loads provider configurations from config/base-{provider}.settings.json files.
|
||||
* This allows model mappings to be easily updated without digging into code.
|
||||
*
|
||||
* Config files are bundled with the npm package and read at runtime.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider, ProviderModelMapping } from './types';
|
||||
|
||||
/** Base settings file structure */
|
||||
interface BaseSettings {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Cached configs to avoid repeated file reads */
|
||||
const configCache: Map<CLIProxyProvider, BaseSettings> = new Map();
|
||||
|
||||
/**
|
||||
* Get path to base config file for provider
|
||||
* Config files are in the config/ directory relative to package root
|
||||
*/
|
||||
function getBaseConfigPath(provider: CLIProxyProvider): string {
|
||||
// __dirname points to dist/cliproxy at runtime
|
||||
// Config files are at package root: ../config/
|
||||
const configDir = path.join(__dirname, '..', '..', 'config');
|
||||
return path.join(configDir, `base-${provider}.settings.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load base config for a provider
|
||||
* Returns parsed settings from config/base-{provider}.settings.json
|
||||
*/
|
||||
export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
|
||||
// Check cache first
|
||||
const cached = configCache.get(provider);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const configPath = getBaseConfigPath(provider);
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(
|
||||
`Base config not found for provider '${provider}': ${configPath}\n` +
|
||||
`Expected file: config/base-${provider}.settings.json`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const settings: BaseSettings = JSON.parse(content);
|
||||
|
||||
// Validate required fields
|
||||
if (!settings.env || typeof settings.env !== 'object') {
|
||||
throw new Error('Missing or invalid "env" object');
|
||||
}
|
||||
|
||||
const required = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
for (const field of required) {
|
||||
if (!settings.env[field as keyof BaseSettings['env']]) {
|
||||
throw new Error(`Missing required field: env.${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache and return
|
||||
configCache.set(provider, settings);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new Error(`Failed to load base config for '${provider}': ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model mapping from base config
|
||||
* Extracts model names from env vars
|
||||
*/
|
||||
export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping {
|
||||
const config = loadBaseConfig(provider);
|
||||
|
||||
return {
|
||||
defaultModel: config.env.ANTHROPIC_MODEL,
|
||||
claudeModel: config.env.ANTHROPIC_MODEL,
|
||||
opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full env vars from base config
|
||||
* Returns the complete env object for Claude CLI
|
||||
*/
|
||||
export function getEnvVarsFromConfig(provider: CLIProxyProvider): NodeJS.ProcessEnv {
|
||||
const config = loadBaseConfig(provider);
|
||||
return config.env as unknown as NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear config cache (useful for testing)
|
||||
*/
|
||||
export function clearConfigCache(): void {
|
||||
configCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* 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 { getBinDir } from './config-generator';
|
||||
import {
|
||||
BinaryInfo,
|
||||
BinaryManagerConfig,
|
||||
ChecksumResult,
|
||||
DownloadResult,
|
||||
ProgressCallback,
|
||||
} from './types';
|
||||
import {
|
||||
detectPlatform,
|
||||
getDownloadUrl,
|
||||
getChecksumsUrl,
|
||||
getExecutableName,
|
||||
getArchiveBinaryName,
|
||||
CLIPROXY_VERSION,
|
||||
} from './platform-detector';
|
||||
|
||||
/** Default configuration */
|
||||
const DEFAULT_CONFIG: BinaryManagerConfig = {
|
||||
version: CLIPROXY_VERSION,
|
||||
releaseUrl: 'https://github.com/router-for-me/CLIProxyAPI/releases/download',
|
||||
binPath: getBinDir(),
|
||||
maxRetries: 3,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Binary Manager class for CLIProxyAPI binary lifecycle
|
||||
*/
|
||||
export class BinaryManager {
|
||||
private config: BinaryManagerConfig;
|
||||
private verbose: boolean;
|
||||
|
||||
constructor(config: Partial<BinaryManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.verbose = this.config.verbose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure binary is available (download if missing)
|
||||
* @returns Path to executable binary
|
||||
*/
|
||||
async ensureBinary(): Promise<string> {
|
||||
const binaryPath = this.getBinaryPath();
|
||||
|
||||
// Check if binary already exists
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
this.log(`Binary exists: ${binaryPath}`);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Download, verify, extract
|
||||
this.log('Binary not found, downloading...');
|
||||
await this.downloadAndInstall();
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full path to binary executable
|
||||
*/
|
||||
getBinaryPath(): string {
|
||||
const execName = getExecutableName();
|
||||
return path.join(this.config.binPath, execName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if binary exists
|
||||
*/
|
||||
isBinaryInstalled(): boolean {
|
||||
return fs.existsSync(this.getBinaryPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary info if installed
|
||||
*/
|
||||
async getBinaryInfo(): Promise<BinaryInfo | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install binary
|
||||
*/
|
||||
private async downloadAndInstall(): Promise<void> {
|
||||
const platform = detectPlatform();
|
||||
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}`);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
spinner.succeed('Download complete');
|
||||
|
||||
// Verify checksum
|
||||
const verifySpinner = new ProgressIndicator('Verifying checksum');
|
||||
verifySpinner.start();
|
||||
|
||||
const checksumResult = await this.verifyChecksum(
|
||||
archivePath,
|
||||
platform.binaryName,
|
||||
checksumsUrl
|
||||
);
|
||||
|
||||
if (!checksumResult.valid) {
|
||||
verifySpinner.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}`
|
||||
);
|
||||
}
|
||||
|
||||
verifySpinner.succeed('Checksum verified');
|
||||
|
||||
// Extract archive
|
||||
const extractSpinner = new ProgressIndicator('Extracting binary');
|
||||
extractSpinner.start();
|
||||
|
||||
await this.extractArchive(archivePath, platform.extension);
|
||||
|
||||
extractSpinner.succeed('Extraction complete');
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
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<DownloadResult> {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects (GitHub releases use 302)
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const redirectUrl = res.headers.location;
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
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<ChecksumResult> {
|
||||
// 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<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
stream.on('data', (data) => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text content from URL
|
||||
*/
|
||||
private fetchText(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleResponse = (res: http.IncomingMessage) => {
|
||||
// Handle redirects
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const redirectUrl = res.headers.location;
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
*/
|
||||
deleteBinary(): void {
|
||||
const binaryPath = this.getBinaryPath();
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
fs.unlinkSync(binaryPath);
|
||||
this.log(`Deleted: ${binaryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if verbose
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.verbose) {
|
||||
console.error(`[cliproxy] ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to ensure binary is available
|
||||
* @returns Path to CLIProxyAPI executable
|
||||
*/
|
||||
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
|
||||
const manager = new BinaryManager({ verbose });
|
||||
return manager.ensureBinary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxyAPI binary is installed
|
||||
*/
|
||||
export function isCLIProxyInstalled(): boolean {
|
||||
const manager = new BinaryManager();
|
||||
return manager.isBinaryInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLIProxyAPI binary path (may not exist)
|
||||
*/
|
||||
export function getCLIProxyPath(): string {
|
||||
const manager = new BinaryManager();
|
||||
return manager.getBinaryPath();
|
||||
}
|
||||
|
||||
export default BinaryManager;
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* CLIProxy Executor - Spawn/Kill Pattern for CLIProxyAPI
|
||||
*
|
||||
* Mirrors GLMT architecture:
|
||||
* 1. Ensure binary exists (Phase 1)
|
||||
* 2. Generate config for provider
|
||||
* 3. Spawn CLIProxyAPI binary
|
||||
* 4. Poll port for readiness (no stdout signal)
|
||||
* 5. Execute Claude CLI with proxied environment
|
||||
* 6. Kill proxy on Claude exit
|
||||
*
|
||||
* Key difference from GLMT: Uses TCP port polling instead of PROXY_READY signal
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as net from 'net';
|
||||
import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
getEffectiveEnvVars,
|
||||
getProviderConfig,
|
||||
ensureProviderSettings,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './config-generator';
|
||||
import { isAuthenticated } from './auth-handler';
|
||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
port: CLIPROXY_DEFAULT_PORT,
|
||||
timeout: 5000,
|
||||
verbose: false,
|
||||
pollInterval: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for TCP port to become available
|
||||
* Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal
|
||||
*/
|
||||
async function waitForProxyReady(
|
||||
port: number,
|
||||
timeout: number = 5000,
|
||||
pollInterval: number = 100
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Individual connection timeout
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
});
|
||||
});
|
||||
|
||||
return; // Connection successful - proxy is ready
|
||||
} catch {
|
||||
// Connection failed, wait and retry
|
||||
await new Promise((r) => setTimeout(r, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with CLIProxy (main entry point)
|
||||
*
|
||||
* @param claudeCli Path to Claude CLI executable
|
||||
* @param provider CLIProxy provider (gemini, codex, agy)
|
||||
* @param args Arguments to pass to Claude CLI
|
||||
* @param config Optional executor configuration
|
||||
*/
|
||||
export async function execClaudeWithCLIProxy(
|
||||
claudeCli: string,
|
||||
provider: CLIProxyProvider,
|
||||
args: string[],
|
||||
config: Partial<ExecutorConfig> = {}
|
||||
): Promise<void> {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v');
|
||||
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate provider
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
// 1. Ensure binary exists (downloads if needed)
|
||||
const spinner = new ProgressIndicator('Preparing CLIProxy');
|
||||
spinner.start();
|
||||
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. Handle authentication flags
|
||||
const forceAuth = args.includes('--auth');
|
||||
const forceHeadless = args.includes('--headless');
|
||||
const forceLogout = args.includes('--logout');
|
||||
|
||||
// Handle --logout: clear auth and exit
|
||||
if (forceLogout) {
|
||||
const { clearAuth } = await import('./auth-handler');
|
||||
if (clearAuth(provider)) {
|
||||
console.log(`[OK] Logged out from ${providerConfig.displayName}`);
|
||||
} else {
|
||||
console.log(`[i] No authentication found for ${providerConfig.displayName}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Ensure OAuth completed (if provider requires it)
|
||||
if (providerConfig.requiresOAuth) {
|
||||
log(`Checking authentication for ${provider}`);
|
||||
|
||||
if (forceAuth || !isAuthenticated(provider)) {
|
||||
// Pass headless only if explicitly set; otherwise let auth-handler auto-detect
|
||||
const { triggerOAuth } = await import('./auth-handler');
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
||||
}
|
||||
// If --auth was explicitly passed, exit after auth (don't start Claude)
|
||||
if (forceAuth) {
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
log(`${provider} already authenticated`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Ensure user settings file exists (creates from defaults if not)
|
||||
ensureProviderSettings(provider);
|
||||
|
||||
// 5. Generate config file
|
||||
log(`Generating config for ${provider}`);
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6. Spawn CLIProxyAPI binary
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
const proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Forward proxy output in verbose mode
|
||||
if (verbose) {
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
||||
});
|
||||
proxy.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle proxy errors
|
||||
proxy.on('error', (error) => {
|
||||
console.error(`[X] CLIProxy spawn error: ${error.message}`);
|
||||
});
|
||||
|
||||
// 5. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error('[X] CLIProxy failed to start');
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check if port ${cfg.port} is in use: lsof -i :${cfg.port}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - Check config: cat ${configPath}`);
|
||||
console.error('');
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Uses user settings if ~/.ccs/{provider}.settings.json exists, else bundled defaults
|
||||
const envVars = getEffectiveEnvVars(provider, cfg.port);
|
||||
const env = { ...process.env, ...envVars };
|
||||
|
||||
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
|
||||
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
|
||||
|
||||
// Filter out CCS-specific flags before passing to Claude CLI
|
||||
const ccsFlags = ['--auth', '--headless', '--logout'];
|
||||
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
claude = spawn(claudeCli, claudeArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Cleanup: kill proxy when Claude exits
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
} else {
|
||||
process.exit(code || 0);
|
||||
}
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error('[X] Claude CLI error:', error);
|
||||
proxy.kill('SIGTERM');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle parent process termination (SIGTERM, SIGINT)
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
process.once('SIGTERM', cleanup);
|
||||
process.once('SIGINT', cleanup);
|
||||
|
||||
// Handle proxy crash
|
||||
proxy.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
||||
// Don't kill Claude - it may have already exited
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
*/
|
||||
export async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port in range
|
||||
*/
|
||||
export async function findAvailablePort(
|
||||
startPort: number = CLIPROXY_DEFAULT_PORT,
|
||||
range: number = 10
|
||||
): Promise<number> {
|
||||
for (let port = startPort; port < startPort + range; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`);
|
||||
}
|
||||
|
||||
export default execClaudeWithCLIProxy;
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Config Generator for CLIProxyAPI
|
||||
*
|
||||
* Generates config.yaml for CLIProxyAPI based on provider.
|
||||
* Handles OAuth token paths and provider-specific settings.
|
||||
*
|
||||
* Model mappings are loaded from config/base-{provider}.settings.json files
|
||||
* to allow easy updates without code changes.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
|
||||
import { getModelMappingFromConfig } from './base-config-loader';
|
||||
|
||||
/** Settings file structure for user overrides */
|
||||
interface ProviderSettings {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/** Default CLIProxy port */
|
||||
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** Internal API key for CCS-managed requests */
|
||||
const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
|
||||
/** Provider display names (static metadata) */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
|
||||
gemini: 'Gemini',
|
||||
codex: 'Codex',
|
||||
agy: 'Antigravity',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get provider configuration
|
||||
* Model mappings are loaded from config/base-{provider}.settings.json
|
||||
*/
|
||||
export function getProviderConfig(provider: CLIProxyProvider): ProviderConfig {
|
||||
const displayName = PROVIDER_DISPLAY_NAMES[provider];
|
||||
if (!displayName) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
// Load models from base config file
|
||||
const models = getModelMappingFromConfig(provider);
|
||||
|
||||
return {
|
||||
name: provider,
|
||||
displayName,
|
||||
models,
|
||||
requiresOAuth: true, // All CLIProxy providers require OAuth
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model mapping for provider
|
||||
* Loads from config/base-{provider}.settings.json
|
||||
*/
|
||||
export function getModelMapping(provider: CLIProxyProvider): ProviderModelMapping {
|
||||
return getProviderConfig(provider).models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLIProxy base directory
|
||||
* All CLIProxy-related files are stored under ~/.ccs/cliproxy/
|
||||
*/
|
||||
export function getCliproxyDir(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth directory for provider
|
||||
* All providers use a FLAT auth directory structure for unified config.
|
||||
* CLIProxyAPI stores OAuth tokens directly in auth/ (not subdirectories).
|
||||
* This enables all providers to be discovered and used concurrently.
|
||||
*/
|
||||
export function getProviderAuthDir(_provider: CLIProxyProvider): string {
|
||||
// Use flat structure - all auth files in same directory for unified discovery
|
||||
// Provider param kept for API compatibility (CLIProxyAPI handles via auth file type field)
|
||||
return path.join(getCliproxyDir(), 'auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base auth directory for CLIProxyAPI
|
||||
*/
|
||||
export function getAuthDir(): string {
|
||||
return path.join(getCliproxyDir(), 'auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return path.join(getCliproxyDir(), 'config.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary directory path
|
||||
*/
|
||||
export function getBinDir(): string {
|
||||
return path.join(getCliproxyDir(), 'bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UNIFIED config.yaml content for ALL providers
|
||||
* This enables concurrent usage of gemini/codex/agy without config conflicts.
|
||||
* CLIProxyAPI routes requests by model name to the appropriate provider.
|
||||
*/
|
||||
function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||
|
||||
// Unified config with all providers
|
||||
const config = `# CLIProxyAPI unified config generated by CCS
|
||||
# Supports: gemini, codex, agy (concurrent usage)
|
||||
# Generated: ${new Date().toISOString()}
|
||||
|
||||
port: ${port}
|
||||
debug: false
|
||||
logging-to-file: false
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# CCS internal authentication
|
||||
api-keys:
|
||||
- "${CCS_INTERNAL_API_KEY}"
|
||||
|
||||
# OAuth tokens stored in auth/ directory
|
||||
# CLIProxyAPI auto-discovers auth files in subdirectories
|
||||
auth-dir: "${authDir}"
|
||||
|
||||
# All providers configured - routes by model name
|
||||
# No provider-specific sections needed - OAuth auth files provide credentials
|
||||
`;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unified config.yaml file (supports all providers concurrently)
|
||||
* Only regenerates if config doesn't exist.
|
||||
* @returns Path to config file
|
||||
*/
|
||||
export function generateConfig(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): string {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Ensure provider auth directory exists
|
||||
const authDir = getProviderAuthDir(provider);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Only generate config if it doesn't exist (unified config serves all providers)
|
||||
if (!fs.existsSync(configPath)) {
|
||||
const configContent = generateUnifiedConfigContent(port);
|
||||
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
|
||||
}
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config exists for provider
|
||||
*/
|
||||
export function configExists(): boolean {
|
||||
return fs.existsSync(getConfigPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete config file
|
||||
*/
|
||||
export function deleteConfig(): void {
|
||||
const configPath = getConfigPath();
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to user settings file for provider
|
||||
* Example: ~/.ccs/gemini.settings.json
|
||||
*/
|
||||
export function getProviderSettingsPath(provider: CLIProxyProvider): string {
|
||||
return path.join(getCcsDir(), `${provider}.settings.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for Claude CLI (bundled defaults)
|
||||
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
|
||||
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path.
|
||||
*/
|
||||
export function getClaudeEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): NodeJS.ProcessEnv {
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
return {
|
||||
// Provider-specific endpoint - routes to correct provider via URL path
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
|
||||
ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective environment variables for provider
|
||||
*
|
||||
* Priority order:
|
||||
* 1. User settings file (~/.ccs/{provider}.settings.json) if exists
|
||||
* 2. Bundled defaults from PROVIDER_CONFIGS
|
||||
*
|
||||
* This allows users to customize model mappings without code changes.
|
||||
* User takes full responsibility for custom settings.
|
||||
*/
|
||||
export function getEffectiveEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): NodeJS.ProcessEnv {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
|
||||
// Check for user override file
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// User override found - use their settings
|
||||
// Note: User is responsible for correctness
|
||||
return settings.env;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON or structure - fall through to defaults
|
||||
// Silent fallback: don't spam errors for broken user files
|
||||
}
|
||||
}
|
||||
|
||||
// No override or invalid - use bundled defaults
|
||||
return getClaudeEnvVars(provider, port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy bundled settings template to user directory if not exists
|
||||
* Called during installation/first run
|
||||
*/
|
||||
export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
|
||||
// Only create if doesn't exist (preserve user edits)
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate default settings from PROVIDER_CONFIGS
|
||||
const envVars = getClaudeEnvVars(provider);
|
||||
const settings: ProviderSettings = { env: envVars };
|
||||
|
||||
// Ensure directory exists
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
|
||||
// Write with restricted permissions
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* CLIProxy Module Exports
|
||||
* Central export point for CLIProxyAPI binary management and execution
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PlatformInfo,
|
||||
SupportedOS,
|
||||
SupportedArch,
|
||||
ArchiveExtension,
|
||||
BinaryManagerConfig,
|
||||
BinaryInfo,
|
||||
DownloadProgress,
|
||||
ProgressCallback,
|
||||
ChecksumResult,
|
||||
DownloadResult,
|
||||
CLIProxyProvider,
|
||||
CLIProxyConfig,
|
||||
ExecutorConfig,
|
||||
ProviderConfig,
|
||||
ProviderModelMapping,
|
||||
} from './types';
|
||||
|
||||
// Platform detection
|
||||
export {
|
||||
detectPlatform,
|
||||
getDownloadUrl,
|
||||
getChecksumsUrl,
|
||||
getExecutableName,
|
||||
getArchiveBinaryName,
|
||||
isPlatformSupported,
|
||||
getPlatformDescription,
|
||||
CLIPROXY_VERSION,
|
||||
} from './platform-detector';
|
||||
|
||||
// Binary management
|
||||
export {
|
||||
BinaryManager,
|
||||
ensureCLIProxyBinary,
|
||||
isCLIProxyInstalled,
|
||||
getCLIProxyPath,
|
||||
} from './binary-manager';
|
||||
|
||||
// Config generation
|
||||
export {
|
||||
generateConfig,
|
||||
getClaudeEnvVars,
|
||||
getEffectiveEnvVars,
|
||||
getProviderSettingsPath,
|
||||
ensureProviderSettings,
|
||||
getProviderConfig,
|
||||
getModelMapping,
|
||||
getCliproxyDir,
|
||||
getProviderAuthDir,
|
||||
getAuthDir,
|
||||
getConfigPath,
|
||||
getBinDir,
|
||||
configExists,
|
||||
deleteConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './config-generator';
|
||||
|
||||
// Base config loader (for reading config/base-*.settings.json)
|
||||
export {
|
||||
loadBaseConfig,
|
||||
getModelMappingFromConfig,
|
||||
getEnvVarsFromConfig,
|
||||
clearConfigCache,
|
||||
} from './base-config-loader';
|
||||
|
||||
// Executor
|
||||
export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor';
|
||||
|
||||
// Authentication
|
||||
export type { AuthStatus } from './auth-handler';
|
||||
export {
|
||||
isAuthenticated,
|
||||
getAuthStatus,
|
||||
getAllAuthStatus,
|
||||
clearAuth,
|
||||
triggerOAuth,
|
||||
ensureAuth,
|
||||
getOAuthConfig,
|
||||
getProviderTokenDir,
|
||||
displayAuthStatus,
|
||||
} from './auth-handler';
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Platform Detector for CLIProxyAPI Binary Downloads
|
||||
*
|
||||
* Detects OS and architecture to determine correct binary asset.
|
||||
* Supports 6 platforms: darwin/linux/windows x amd64/arm64
|
||||
*/
|
||||
|
||||
import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types';
|
||||
|
||||
/** CLIProxyAPI version pinned for stability */
|
||||
export const CLIPROXY_VERSION = '6.5.27';
|
||||
|
||||
/**
|
||||
* Platform mapping from Node.js values to CLIProxyAPI naming
|
||||
*/
|
||||
const OS_MAP: Record<string, SupportedOS | undefined> = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
};
|
||||
|
||||
const ARCH_MAP: Record<string, SupportedArch | undefined> = {
|
||||
x64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect current platform and return binary info
|
||||
* @throws Error if platform is unsupported
|
||||
*/
|
||||
export function detectPlatform(): PlatformInfo {
|
||||
const nodePlatform = process.platform;
|
||||
const nodeArch = process.arch;
|
||||
|
||||
const os = OS_MAP[nodePlatform];
|
||||
const arch = ARCH_MAP[nodeArch];
|
||||
|
||||
if (!os) {
|
||||
throw new Error(
|
||||
`Unsupported operating system: ${nodePlatform}\n` +
|
||||
`Supported: macOS (darwin), Linux, Windows`
|
||||
);
|
||||
}
|
||||
|
||||
if (!arch) {
|
||||
throw new Error(
|
||||
`Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64`
|
||||
);
|
||||
}
|
||||
|
||||
const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz';
|
||||
const binaryName = `CLIProxyAPI_${CLIPROXY_VERSION}_${os}_${arch}.${extension}`;
|
||||
|
||||
return {
|
||||
os,
|
||||
arch,
|
||||
binaryName,
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get executable name based on platform
|
||||
* @returns Binary executable name (with .exe on Windows)
|
||||
* Note: The actual binary inside the archive is named 'cli-proxy-api'
|
||||
*/
|
||||
export function getExecutableName(): string {
|
||||
const platform = detectPlatform();
|
||||
return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the binary inside the archive
|
||||
* @returns Binary name as it appears in the tar.gz/zip
|
||||
*/
|
||||
export function getArchiveBinaryName(): string {
|
||||
const platform = detectPlatform();
|
||||
return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL for current platform
|
||||
* @param version Optional version override (defaults to CLIPROXY_VERSION)
|
||||
* @returns Full GitHub release download URL
|
||||
*/
|
||||
export function getDownloadUrl(version: string = CLIPROXY_VERSION): string {
|
||||
const platform = detectPlatform();
|
||||
const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}`;
|
||||
return `${baseUrl}/${platform.binaryName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checksums.txt URL for version
|
||||
* @param version Optional version override
|
||||
* @returns Full URL to checksums.txt
|
||||
*/
|
||||
export function getChecksumsUrl(version: string = CLIPROXY_VERSION): string {
|
||||
return `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}/checksums.txt`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if platform is supported
|
||||
* @returns true if current platform is supported
|
||||
*/
|
||||
export function isPlatformSupported(): boolean {
|
||||
try {
|
||||
detectPlatform();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable platform description
|
||||
* @returns Description string (e.g., "macOS arm64")
|
||||
*/
|
||||
export function getPlatformDescription(): string {
|
||||
try {
|
||||
const platform = detectPlatform();
|
||||
const osName =
|
||||
platform.os === 'darwin' ? 'macOS' : platform.os === 'linux' ? 'Linux' : 'Windows';
|
||||
return `${osName} ${platform.arch}`;
|
||||
} catch {
|
||||
return `${process.platform} ${process.arch} (unsupported)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* CLIProxy Type Definitions
|
||||
* Types for CLIProxyAPI binary management and execution
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported operating systems
|
||||
*/
|
||||
export type SupportedOS = 'darwin' | 'linux' | 'windows';
|
||||
|
||||
/**
|
||||
* Supported CPU architectures
|
||||
*/
|
||||
export type SupportedArch = 'amd64' | 'arm64';
|
||||
|
||||
/**
|
||||
* Archive extension based on platform
|
||||
*/
|
||||
export type ArchiveExtension = 'tar.gz' | 'zip';
|
||||
|
||||
/**
|
||||
* Platform detection result
|
||||
*/
|
||||
export interface PlatformInfo {
|
||||
/** Operating system (darwin, linux, windows) */
|
||||
os: SupportedOS;
|
||||
/** CPU architecture (amd64, arm64) */
|
||||
arch: SupportedArch;
|
||||
/** Full binary archive name (e.g., CLIProxyAPI_6.5.27_linux_amd64.tar.gz) */
|
||||
binaryName: string;
|
||||
/** Archive extension (tar.gz for Unix, zip for Windows) */
|
||||
extension: ArchiveExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary manager configuration
|
||||
*/
|
||||
export interface BinaryManagerConfig {
|
||||
/** CLIProxyAPI version to download */
|
||||
version: string;
|
||||
/** GitHub releases base URL */
|
||||
releaseUrl: string;
|
||||
/** Local binary storage path (~/.ccs/bin/) */
|
||||
binPath: string;
|
||||
/** Maximum download retry attempts */
|
||||
maxRetries: number;
|
||||
/** Enable verbose logging */
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download progress callback
|
||||
*/
|
||||
export interface DownloadProgress {
|
||||
/** Total bytes to download */
|
||||
total: number;
|
||||
/** Bytes downloaded so far */
|
||||
downloaded: number;
|
||||
/** Download percentage (0-100) */
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download progress callback function type
|
||||
*/
|
||||
export type ProgressCallback = (progress: DownloadProgress) => void;
|
||||
|
||||
/**
|
||||
* Binary info after successful download/verification
|
||||
*/
|
||||
export interface BinaryInfo {
|
||||
/** Full path to executable */
|
||||
path: string;
|
||||
/** CLIProxyAPI version */
|
||||
version: string;
|
||||
/** Platform info */
|
||||
platform: PlatformInfo;
|
||||
/** SHA256 checksum (verified) */
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checksum verification result
|
||||
*/
|
||||
export interface ChecksumResult {
|
||||
/** Whether checksum matched */
|
||||
valid: boolean;
|
||||
/** Expected checksum from checksums.txt */
|
||||
expected: string;
|
||||
/** Actual computed checksum */
|
||||
actual: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download result
|
||||
*/
|
||||
export interface DownloadResult {
|
||||
/** Whether download succeeded */
|
||||
success: boolean;
|
||||
/** Path to downloaded file */
|
||||
filePath?: string;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Number of retries attempted */
|
||||
retries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported CLIProxy providers
|
||||
* - gemini: Google Gemini via OAuth
|
||||
* - codex: OpenAI Codex via OAuth
|
||||
* - agy: Antigravity via OAuth (short name for easy usage)
|
||||
*/
|
||||
export type CLIProxyProvider = 'gemini' | 'codex' | 'agy';
|
||||
|
||||
/**
|
||||
* CLIProxy config.yaml structure (minimal)
|
||||
*/
|
||||
export interface CLIProxyConfig {
|
||||
port: number;
|
||||
'api-keys': string[];
|
||||
'auth-dir': string;
|
||||
debug: boolean;
|
||||
'gemini-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
}>;
|
||||
'codex-api-key'?: Array<{
|
||||
'api-key': string;
|
||||
'base-url'?: string;
|
||||
}>;
|
||||
'openai-compatibility'?: Array<{
|
||||
name: string;
|
||||
'base-url': string;
|
||||
'api-key-entries': Array<{
|
||||
'api-key': string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor configuration
|
||||
*/
|
||||
export interface ExecutorConfig {
|
||||
/** Port for CLIProxyAPI (default: 8317) */
|
||||
port: number;
|
||||
/** Timeout for proxy readiness in ms (default: 5000) */
|
||||
timeout: number;
|
||||
/** Enable verbose logging */
|
||||
verbose: boolean;
|
||||
/** Poll interval for port check in ms (default: 100) */
|
||||
pollInterval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model mapping for each provider
|
||||
*/
|
||||
export interface ProviderModelMapping {
|
||||
/** Default model for requests */
|
||||
defaultModel: string;
|
||||
/** Model for Claude CLI's ANTHROPIC_MODEL */
|
||||
claudeModel: string;
|
||||
/** Model for Claude CLI's ANTHROPIC_DEFAULT_OPUS_MODEL */
|
||||
opusModel?: string;
|
||||
/** Model for Claude CLI's ANTHROPIC_DEFAULT_SONNET_MODEL */
|
||||
sonnetModel?: string;
|
||||
/** Model for Claude CLI's ANTHROPIC_DEFAULT_HAIKU_MODEL */
|
||||
haikuModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
/** Provider name */
|
||||
name: CLIProxyProvider;
|
||||
/** Display name for UI */
|
||||
displayName: string;
|
||||
/** Model configuration */
|
||||
models: ProviderModelMapping;
|
||||
/** Whether OAuth is required */
|
||||
requiresOAuth: boolean;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Doctor Command Handler
|
||||
*
|
||||
* Handle doctor command for CCS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handle doctor command
|
||||
*/
|
||||
export async function handleDoctorCommand(): Promise<void> {
|
||||
const DoctorModule = await import('../management/doctor');
|
||||
const Doctor = DoctorModule.default;
|
||||
const doctor = new Doctor();
|
||||
|
||||
await doctor.runAllChecks();
|
||||
|
||||
// Exit with error code if unhealthy
|
||||
process.exit(doctor.isHealthy() ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { colored } from '../utils/helpers';
|
||||
|
||||
/**
|
||||
* Display comprehensive help information for CCS (Claude Code Switch)
|
||||
*/
|
||||
export function handleHelpCommand(): void {
|
||||
console.log(colored('CCS (Claude Code Switch) - Profile switching for Claude CLI', 'bold'));
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Usage:', 'cyan'));
|
||||
console.log(` ${colored('ccs', 'yellow')} [profile] [claude-args...]`);
|
||||
console.log(` ${colored('ccs', 'yellow')} [flags]`);
|
||||
console.log('');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SECTION 1: API KEY MODELS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
console.log(colored('═══ API Key Models ═══', 'cyanBold'));
|
||||
console.log(' Configure API keys in ~/.ccs/*.settings.json');
|
||||
console.log('');
|
||||
console.log(` ${colored('ccs', 'yellow')} Use default Claude account`);
|
||||
console.log(` ${colored('ccs glm', 'yellow')} GLM 4.6 (API key required)`);
|
||||
console.log(` ${colored('ccs glmt', 'yellow')} GLM with thinking mode`);
|
||||
console.log(` ${colored('ccs kimi', 'yellow')} Kimi for Coding (API key)`);
|
||||
console.log('');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SECTION 2: ACCOUNT MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
console.log(colored('═══ Account Management ═══', 'cyanBold'));
|
||||
console.log(' Run multiple Claude accounts concurrently');
|
||||
console.log('');
|
||||
console.log(
|
||||
` ${colored('ccs auth --help', 'yellow')} Show account management commands`
|
||||
);
|
||||
console.log(` ${colored('ccs auth create <name>', 'yellow')} Create new account profile`);
|
||||
console.log(` ${colored('ccs auth list', 'yellow')} List all account profiles`);
|
||||
console.log('');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SECTION 3: CLI PROXY (OAUTH PROVIDERS)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
console.log(colored('═══ CLI Proxy (OAuth Providers) ═══', 'cyanBold'));
|
||||
console.log(' Zero-config OAuth authentication via CLIProxyAPI');
|
||||
console.log(' First run: Browser opens for authentication');
|
||||
console.log(' Settings: ~/.ccs/{provider}.settings.json (created after auth)');
|
||||
console.log('');
|
||||
console.log(
|
||||
` ${colored('ccs gemini', 'yellow')} Google Gemini (gemini-2.5-pro)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs codex', 'yellow')} OpenAI Codex (gpt-5.1-codex-max)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs agy', 'yellow')} Antigravity (gemini-3-pro-preview)`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${colored('ccs <provider> --auth', 'yellow')} Authenticate only`);
|
||||
console.log(` ${colored('ccs <provider> --logout', 'yellow')} Clear authentication`);
|
||||
console.log(` ${colored('ccs <provider> --headless', 'yellow')} Headless auth (for SSH)`);
|
||||
console.log(` ${colored('ccs codex "explain code"', 'yellow')} Use with prompt`);
|
||||
console.log('');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELEGATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
console.log(colored('Delegation (inside Claude Code CLI):', 'cyan'));
|
||||
console.log(
|
||||
` ${colored('/ccs "task"', 'yellow')} Delegate task (auto-selects profile)`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('/ccs --glm "task"', 'yellow')} Force GLM-4.6 for simple tasks`
|
||||
);
|
||||
console.log(` ${colored('/ccs --kimi "task"', 'yellow')} Force Kimi for long context`);
|
||||
console.log(
|
||||
` ${colored('/ccs:continue "follow-up"', 'yellow')} Continue last delegation session`
|
||||
);
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Diagnostics:', 'cyan'));
|
||||
console.log(
|
||||
` ${colored('ccs doctor', 'yellow')} Run health check and diagnostics`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('ccs sync', 'yellow')} Sync delegation commands and skills`
|
||||
);
|
||||
console.log(` ${colored('ccs update', 'yellow')} Update CCS to latest version`);
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Flags:', 'cyan'));
|
||||
console.log(` ${colored('-h, --help', 'yellow')} Show this help message`);
|
||||
console.log(
|
||||
` ${colored('-v, --version', 'yellow')} Show version and installation info`
|
||||
);
|
||||
console.log(
|
||||
` ${colored('-sc, --shell-completion', 'yellow')} Install shell auto-completion`
|
||||
);
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Configuration:', 'cyan'));
|
||||
console.log(' Config File: ~/.ccs/config.json');
|
||||
console.log(' Profiles: ~/.ccs/profiles.json');
|
||||
console.log(' Instances: ~/.ccs/instances/');
|
||||
console.log(' Settings: ~/.ccs/*.settings.json');
|
||||
console.log('');
|
||||
|
||||
console.log(colored('CLI Proxy:', 'cyan'));
|
||||
console.log(' Binary: ~/.ccs/cliproxy/bin/cli-proxy-api');
|
||||
console.log(' Config: ~/.ccs/cliproxy/config.yaml');
|
||||
console.log(' Auth: ~/.ccs/cliproxy/auth/');
|
||||
console.log(' Port: 8317 (default)');
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Shared Data:', 'cyan'));
|
||||
console.log(' Commands: ~/.ccs/shared/commands/');
|
||||
console.log(' Skills: ~/.ccs/shared/skills/');
|
||||
console.log(' Agents: ~/.ccs/shared/agents/');
|
||||
console.log(' Note: Symlinked across all profiles');
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Examples:', 'cyan'));
|
||||
console.log(` ${colored('$ ccs', 'yellow')} # Use default account`);
|
||||
console.log(
|
||||
` ${colored('$ ccs gemini', 'yellow')} # OAuth (browser opens first time)`
|
||||
);
|
||||
console.log(` ${colored('$ ccs glm "implement API"', 'yellow')} # API key model`);
|
||||
console.log('');
|
||||
console.log(` Docs: ${colored('https://github.com/kaitranntt/ccs', 'cyan')}`);
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Uninstall:', 'yellow'));
|
||||
console.log(' npm uninstall -g @kaitranntt/ccs');
|
||||
console.log('');
|
||||
|
||||
console.log(`${colored('License:', 'cyan')} MIT`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Install/Uninstall Command Handlers
|
||||
*
|
||||
* Handle --install and --uninstall commands for CCS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handle install command
|
||||
*/
|
||||
export function handleInstallCommand(): void {
|
||||
console.log('');
|
||||
console.log('Feature not available');
|
||||
console.log('');
|
||||
console.log('The --install flag is currently under development.');
|
||||
console.log('.claude/ integration testing is not complete.');
|
||||
console.log('');
|
||||
console.log('For updates: https://github.com/kaitranntt/ccs/issues');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle uninstall command
|
||||
*/
|
||||
export function handleUninstallCommand(): void {
|
||||
console.log('');
|
||||
console.log('Feature not available');
|
||||
console.log('');
|
||||
console.log('The --uninstall flag is currently under development.');
|
||||
console.log('.claude/ integration testing is not complete.');
|
||||
console.log('');
|
||||
console.log('For updates: https://github.com/kaitranntt/ccs/issues');
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shell Completion Command Handler
|
||||
*
|
||||
* Handle --shell-completion command for CCS.
|
||||
*/
|
||||
|
||||
import { colored } from '../utils/helpers';
|
||||
|
||||
/**
|
||||
* Handle shell completion command
|
||||
*/
|
||||
export async function handleShellCompletionCommand(args: string[]): Promise<void> {
|
||||
const { ShellCompletionInstaller } = await import('../utils/shell-completion');
|
||||
|
||||
console.log(colored('Shell Completion Installer', 'bold'));
|
||||
console.log('');
|
||||
|
||||
// Parse flags
|
||||
let targetShell: string | null = null;
|
||||
if (args.includes('--bash')) targetShell = 'bash';
|
||||
else if (args.includes('--zsh')) targetShell = 'zsh';
|
||||
else if (args.includes('--fish')) targetShell = 'fish';
|
||||
else if (args.includes('--powershell')) targetShell = 'powershell';
|
||||
|
||||
try {
|
||||
const installer = new ShellCompletionInstaller();
|
||||
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null);
|
||||
|
||||
if (result.alreadyInstalled) {
|
||||
console.log(colored('[OK] Shell completion already installed', 'green'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(colored('[OK] Shell completion installed successfully!', 'green'));
|
||||
console.log('');
|
||||
console.log(result.message);
|
||||
console.log('');
|
||||
console.log(colored('To activate:', 'cyan'));
|
||||
console.log(` ${result.reload}`);
|
||||
console.log('');
|
||||
console.log(colored('Then test:', 'cyan'));
|
||||
console.log(' ccs <TAB> # See available profiles');
|
||||
console.log(' ccs auth <TAB> # See auth subcommands');
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(colored('[X] Error:', 'red'), err.message);
|
||||
console.error('');
|
||||
console.error(colored('Usage:', 'yellow'));
|
||||
console.error(' ccs --shell-completion # Auto-detect shell');
|
||||
console.error(' ccs --shell-completion --bash # Install for bash');
|
||||
console.error(' ccs --shell-completion --zsh # Install for zsh');
|
||||
console.error(' ccs --shell-completion --fish # Install for fish');
|
||||
console.error(' ccs --shell-completion --powershell # Install for PowerShell');
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Sync Command Handler
|
||||
*
|
||||
* Handle sync command for CCS.
|
||||
*/
|
||||
|
||||
import { colored } from '../utils/helpers';
|
||||
|
||||
/**
|
||||
* Handle sync command
|
||||
*/
|
||||
export async function handleSyncCommand(): Promise<void> {
|
||||
console.log('');
|
||||
console.log(colored('Syncing CCS Components...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
// First, copy .claude/ directory from package to ~/.ccs/.claude/
|
||||
const { ClaudeDirInstaller } = await import('../utils/claude-dir-installer');
|
||||
const installer = new ClaudeDirInstaller();
|
||||
installer.install();
|
||||
|
||||
console.log('');
|
||||
|
||||
const cleanupResult = installer.cleanupDeprecated();
|
||||
if (cleanupResult.success && cleanupResult.cleanedFiles.length > 0) {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Then, create symlinks from ~/.ccs/.claude/ to ~/.claude/
|
||||
const { ClaudeSymlinkManager } = await import('../utils/claude-symlink-manager');
|
||||
const manager = new ClaudeSymlinkManager();
|
||||
manager.install(false);
|
||||
|
||||
console.log('');
|
||||
console.log(colored('[OK] Sync complete!', 'green'));
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Update Command Handler
|
||||
*
|
||||
* Handles `ccs update` command - checks for updates and installs latest version.
|
||||
* Supports both npm and direct installation methods.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { colored } from '../utils/helpers';
|
||||
import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector';
|
||||
|
||||
// Version (sync with package.json)
|
||||
const CCS_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
/**
|
||||
* Handle the update command
|
||||
* Checks for updates and installs the latest version
|
||||
*/
|
||||
export async function handleUpdateCommand(): Promise<void> {
|
||||
const { checkForUpdates } = await import('../utils/update-checker');
|
||||
|
||||
console.log('');
|
||||
console.log(colored('Checking for updates...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
const installMethod = detectInstallationMethod();
|
||||
const isNpmInstall = installMethod === 'npm';
|
||||
|
||||
const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod);
|
||||
|
||||
if (updateResult.status === 'check_failed') {
|
||||
handleCheckFailed(updateResult.message ?? 'Update check failed', isNpmInstall);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateResult.status === 'no_update') {
|
||||
handleNoUpdate(updateResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update available
|
||||
console.log(
|
||||
colored(`[i] Update available: ${updateResult.current} -> ${updateResult.latest}`, 'yellow')
|
||||
);
|
||||
console.log('');
|
||||
|
||||
if (isNpmInstall) {
|
||||
await performNpmUpdate();
|
||||
} else {
|
||||
await performDirectUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed update check
|
||||
*/
|
||||
function handleCheckFailed(message: string, isNpmInstall: boolean): void {
|
||||
console.log(colored(`[X] ${message}`, 'red'));
|
||||
console.log('');
|
||||
console.log(colored('[i] Possible causes:', 'yellow'));
|
||||
console.log(' - Network connection issues');
|
||||
console.log(' - Firewall blocking requests');
|
||||
console.log(' - GitHub/npm API temporarily unavailable');
|
||||
console.log('');
|
||||
console.log('Try again later or update manually:');
|
||||
|
||||
if (isNpmInstall) {
|
||||
const packageManager = detectPackageManager();
|
||||
let manualCommand: string;
|
||||
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
manualCommand = 'npm install -g @kaitranntt/ccs@latest';
|
||||
break;
|
||||
case 'yarn':
|
||||
manualCommand = 'yarn global add @kaitranntt/ccs@latest';
|
||||
break;
|
||||
case 'pnpm':
|
||||
manualCommand = 'pnpm add -g @kaitranntt/ccs@latest';
|
||||
break;
|
||||
case 'bun':
|
||||
manualCommand = 'bun add -g @kaitranntt/ccs@latest';
|
||||
break;
|
||||
default:
|
||||
manualCommand = 'npm install -g @kaitranntt/ccs@latest';
|
||||
}
|
||||
|
||||
console.log(colored(` ${manualCommand}`, 'yellow'));
|
||||
} else {
|
||||
const isWindows = process.platform === 'win32';
|
||||
if (isWindows) {
|
||||
console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow'));
|
||||
} else {
|
||||
console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow'));
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle no update available
|
||||
*/
|
||||
function handleNoUpdate(reason: string | undefined): void {
|
||||
const CCS_VERSION_LOCAL = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
let message = `You are already on the latest version (${CCS_VERSION_LOCAL})`;
|
||||
|
||||
switch (reason) {
|
||||
case 'dismissed':
|
||||
message = `Update dismissed. You are on version ${CCS_VERSION_LOCAL}`;
|
||||
console.log(colored(`[i] ${message}`, 'yellow'));
|
||||
break;
|
||||
case 'cached':
|
||||
message = `No updates available (cached result). You are on version ${CCS_VERSION_LOCAL}`;
|
||||
console.log(colored(`[i] ${message}`, 'cyan'));
|
||||
break;
|
||||
default:
|
||||
console.log(colored(`[OK] ${message}`, 'green'));
|
||||
}
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform update via npm/yarn/pnpm/bun
|
||||
*/
|
||||
async function performNpmUpdate(): Promise<void> {
|
||||
const packageManager = detectPackageManager();
|
||||
let updateCommand: string;
|
||||
let updateArgs: string[];
|
||||
let cacheCommand: string | null;
|
||||
let cacheArgs: string[] | null;
|
||||
|
||||
switch (packageManager) {
|
||||
case 'npm':
|
||||
updateCommand = 'npm';
|
||||
updateArgs = ['install', '-g', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = 'npm';
|
||||
cacheArgs = ['cache', 'clean', '--force'];
|
||||
break;
|
||||
case 'yarn':
|
||||
updateCommand = 'yarn';
|
||||
updateArgs = ['global', 'add', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = 'yarn';
|
||||
cacheArgs = ['cache', 'clean'];
|
||||
break;
|
||||
case 'pnpm':
|
||||
updateCommand = 'pnpm';
|
||||
updateArgs = ['add', '-g', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = 'pnpm';
|
||||
cacheArgs = ['store', 'prune'];
|
||||
break;
|
||||
case 'bun':
|
||||
updateCommand = 'bun';
|
||||
updateArgs = ['add', '-g', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = null;
|
||||
cacheArgs = null;
|
||||
break;
|
||||
default:
|
||||
updateCommand = 'npm';
|
||||
updateArgs = ['install', '-g', '@kaitranntt/ccs@latest'];
|
||||
cacheCommand = 'npm';
|
||||
cacheArgs = ['cache', 'clean', '--force'];
|
||||
}
|
||||
|
||||
console.log(colored(`Updating via ${packageManager}...`, 'cyan'));
|
||||
console.log('');
|
||||
|
||||
const performUpdate = (): void => {
|
||||
const child = spawn(updateCommand, updateArgs, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('');
|
||||
console.log(colored('[OK] Update successful!', 'green'));
|
||||
console.log('');
|
||||
console.log(`Run ${colored('ccs --version', 'yellow')} to verify`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(colored('[X] Update failed', 'red'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow'));
|
||||
console.log('');
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
console.log('');
|
||||
console.log(colored(`[X] Failed to run ${packageManager} update`, 'red'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow'));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
if (cacheCommand && cacheArgs) {
|
||||
console.log(colored('Clearing package cache...', 'cyan'));
|
||||
const cacheChild = spawn(cacheCommand, cacheArgs, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
cacheChild.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
}
|
||||
performUpdate();
|
||||
});
|
||||
|
||||
cacheChild.on('error', () => {
|
||||
console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow'));
|
||||
performUpdate();
|
||||
});
|
||||
} else {
|
||||
performUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform update via direct installer (curl/irm)
|
||||
*/
|
||||
async function performDirectUpdate(): Promise<void> {
|
||||
console.log(colored('Updating via installer...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
let command: string;
|
||||
let args: string[];
|
||||
|
||||
if (isWindows) {
|
||||
command = 'powershell.exe';
|
||||
args = [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
'irm ccs.kaitran.ca/install | iex',
|
||||
];
|
||||
} else {
|
||||
command = '/bin/bash';
|
||||
args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash'];
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('');
|
||||
console.log(colored('[OK] Update successful!', 'green'));
|
||||
console.log('');
|
||||
console.log(`Run ${colored('ccs --version', 'yellow')} to verify`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(colored('[X] Update failed', 'red'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
if (isWindows) {
|
||||
console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow'));
|
||||
} else {
|
||||
console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
console.log('');
|
||||
console.log(colored('[X] Failed to run installer', 'red'));
|
||||
console.log('');
|
||||
console.log('Try manually:');
|
||||
if (isWindows) {
|
||||
console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow'));
|
||||
} else {
|
||||
console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow'));
|
||||
}
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Version Command Handler
|
||||
*
|
||||
* Handle --version command for CCS.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { colored } from '../utils/helpers';
|
||||
import { getConfigPath } from '../utils/config-manager';
|
||||
|
||||
// Get version from package.json
|
||||
const CCS_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
/**
|
||||
* Handle version command
|
||||
*/
|
||||
export function handleVersionCommand(): void {
|
||||
console.log(colored(`CCS (Claude Code Switch) v${CCS_VERSION}`, 'bold'));
|
||||
console.log('');
|
||||
|
||||
console.log(colored('Installation:', 'cyan'));
|
||||
const installLocation = process.argv[1] || '(not found)';
|
||||
console.log(` ${colored('Location:'.padEnd(17), 'cyan')} ${installLocation}`);
|
||||
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
console.log(` ${colored('CCS Directory:'.padEnd(17), 'cyan')} ${ccsDir}`);
|
||||
|
||||
const configPath = getConfigPath();
|
||||
console.log(` ${colored('Config:'.padEnd(17), 'cyan')} ${configPath}`);
|
||||
|
||||
const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json');
|
||||
console.log(` ${colored('Profiles:'.padEnd(17), 'cyan')} ${profilesJson}`);
|
||||
|
||||
// Delegation status
|
||||
const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
|
||||
const delegationConfigured = fs.existsSync(delegationSessionsPath);
|
||||
|
||||
const readyProfiles: string[] = [];
|
||||
|
||||
// Check for profiles with valid API keys
|
||||
for (const profile of ['glm', 'kimi']) {
|
||||
const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) {
|
||||
readyProfiles.push(profile);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidApiKeys = readyProfiles.length > 0;
|
||||
const delegationEnabled = delegationConfigured || hasValidApiKeys;
|
||||
|
||||
if (delegationEnabled) {
|
||||
console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Enabled`);
|
||||
} else {
|
||||
console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Not configured`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
if (readyProfiles.length > 0) {
|
||||
console.log(colored('Delegation Ready:', 'cyan'));
|
||||
console.log(
|
||||
` ${colored('[OK]', 'yellow')} ${readyProfiles.join(', ')} profiles are ready for delegation`
|
||||
);
|
||||
console.log('');
|
||||
} else if (delegationEnabled) {
|
||||
console.log(colored('Delegation Ready:', 'cyan'));
|
||||
console.log(` ${colored('[!]', 'yellow')} Delegation configured but no valid API keys found`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`${colored('Documentation:', 'cyan')} https://github.com/kaitranntt/ccs`);
|
||||
console.log(`${colored('License:', 'cyan')} MIT`);
|
||||
console.log('');
|
||||
console.log(colored("Run 'ccs --help' for usage information", 'yellow'));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { HeadlessExecutor } = require('./headless-executor');
|
||||
const { SessionManager } = require('./session-manager');
|
||||
const { ResultFormatter } = require('./result-formatter');
|
||||
const { DelegationValidator } = require('../utils/delegation-validator');
|
||||
const { SettingsParser } = require('./settings-parser');
|
||||
import { HeadlessExecutor } from './headless-executor';
|
||||
import { SessionManager } from './session-manager';
|
||||
import { ResultFormatter } from './result-formatter';
|
||||
import { DelegationValidator } from '../utils/delegation-validator';
|
||||
import { SettingsParser } from './settings-parser';
|
||||
|
||||
interface ParsedArgs {
|
||||
profile: string;
|
||||
prompt: string;
|
||||
options: {
|
||||
cwd: string;
|
||||
outputFormat: string;
|
||||
permissionMode: string;
|
||||
timeout?: number;
|
||||
resumeSession?: boolean;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegation command handler
|
||||
* Routes -p flag commands to HeadlessExecutor with enhanced features
|
||||
*/
|
||||
class DelegationHandler {
|
||||
export class DelegationHandler {
|
||||
/**
|
||||
* Route delegation command
|
||||
* @param {Array<string>} args - Full args array from ccs.js
|
||||
* @param args - Full args array from ccs.js
|
||||
*/
|
||||
async route(args) {
|
||||
async route(args: string[]): Promise<void> {
|
||||
try {
|
||||
// 1. Parse args into { profile, prompt, options }
|
||||
const parsed = this._parseArgs(args);
|
||||
@@ -30,11 +42,7 @@ class DelegationHandler {
|
||||
this._validateProfile(parsed.profile);
|
||||
|
||||
// 4. Execute via HeadlessExecutor
|
||||
const result = await HeadlessExecutor.execute(
|
||||
parsed.profile,
|
||||
parsed.prompt,
|
||||
parsed.options
|
||||
);
|
||||
const result = await HeadlessExecutor.execute(parsed.profile, parsed.prompt, parsed.options);
|
||||
|
||||
// 5. Format and display results
|
||||
const formatted = ResultFormatter.format(result);
|
||||
@@ -43,9 +51,9 @@ class DelegationHandler {
|
||||
// 6. Exit with proper code
|
||||
process.exit(result.exitCode || 0);
|
||||
} catch (error) {
|
||||
console.error(`[X] Delegation error: ${error.message}`);
|
||||
console.error(`[X] Delegation error: ${(error as Error).message}`);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(error.stack);
|
||||
console.error((error as Error).stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -53,9 +61,9 @@ class DelegationHandler {
|
||||
|
||||
/**
|
||||
* Handle continue command (resume last session)
|
||||
* @param {Object} parsed - Parsed args
|
||||
* @param parsed - Parsed args
|
||||
*/
|
||||
async _handleContinue(parsed) {
|
||||
async _handleContinue(parsed: ParsedArgs): Promise<void> {
|
||||
const baseProfile = parsed.profile.replace(':continue', '');
|
||||
|
||||
// Get last session from SessionManager
|
||||
@@ -69,15 +77,11 @@ class DelegationHandler {
|
||||
}
|
||||
|
||||
// Execute with resume flag
|
||||
const result = await HeadlessExecutor.execute(
|
||||
baseProfile,
|
||||
parsed.prompt,
|
||||
{
|
||||
...parsed.options,
|
||||
resumeSession: true,
|
||||
sessionId: lastSession.sessionId
|
||||
}
|
||||
);
|
||||
const result = await HeadlessExecutor.execute(baseProfile, parsed.prompt, {
|
||||
...parsed.options,
|
||||
resumeSession: true,
|
||||
sessionId: lastSession.sessionId,
|
||||
});
|
||||
|
||||
const formatted = ResultFormatter.format(result);
|
||||
console.log(formatted);
|
||||
@@ -87,10 +91,10 @@ class DelegationHandler {
|
||||
|
||||
/**
|
||||
* Parse args into structured format
|
||||
* @param {Array<string>} args - Raw args
|
||||
* @returns {Object} { profile, prompt, options }
|
||||
* @param args - Raw args
|
||||
* @returns { profile, prompt, options }
|
||||
*/
|
||||
_parseArgs(args) {
|
||||
_parseArgs(args: string[]): ParsedArgs {
|
||||
// Extract profile (first non-flag arg or 'default')
|
||||
const profile = this._extractProfile(args);
|
||||
|
||||
@@ -105,10 +109,10 @@ class DelegationHandler {
|
||||
|
||||
/**
|
||||
* Extract profile from args (first non-flag arg)
|
||||
* @param {Array<string>} args - Args array
|
||||
* @returns {string} Profile name
|
||||
* @param args - Args array
|
||||
* @returns profile name
|
||||
*/
|
||||
_extractProfile(args) {
|
||||
_extractProfile(args: string[]): string {
|
||||
// Find first arg that doesn't start with '-' and isn't -p value
|
||||
let skipNext = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -127,16 +131,16 @@ class DelegationHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// No profile specified, return null (will error in validation)
|
||||
return null;
|
||||
// No profile specified, return empty string (will error in validation)
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt from -p flag
|
||||
* @param {Array<string>} args - Args array
|
||||
* @returns {string} Prompt text
|
||||
* @param args - Args array
|
||||
* @returns prompt text
|
||||
*/
|
||||
_extractPrompt(args) {
|
||||
_extractPrompt(args: string[]): string {
|
||||
const pIndex = args.indexOf('-p');
|
||||
const promptIndex = args.indexOf('--prompt');
|
||||
|
||||
@@ -153,20 +157,20 @@ class DelegationHandler {
|
||||
|
||||
/**
|
||||
* Extract options from remaining args
|
||||
* @param {Array<string>} args - Args array
|
||||
* @returns {Object} Options for HeadlessExecutor
|
||||
* @param args - Args array
|
||||
* @returns options for HeadlessExecutor
|
||||
*/
|
||||
_extractOptions(args) {
|
||||
_extractOptions(args: string[]): ParsedArgs['options'] {
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Read default permission mode from .claude/settings.local.json
|
||||
// Falls back to 'acceptEdits' if file doesn't exist
|
||||
const defaultPermissionMode = SettingsParser.parseDefaultPermissionMode(cwd);
|
||||
|
||||
const options = {
|
||||
const options: ParsedArgs['options'] = {
|
||||
cwd,
|
||||
outputFormat: 'stream-json',
|
||||
permissionMode: defaultPermissionMode
|
||||
permissionMode: defaultPermissionMode,
|
||||
};
|
||||
|
||||
// Parse permission-mode (CLI flag overrides settings file)
|
||||
@@ -186,9 +190,9 @@ class DelegationHandler {
|
||||
|
||||
/**
|
||||
* Validate profile exists and is configured
|
||||
* @param {string} profile - Profile name
|
||||
* @param profile - Profile name
|
||||
*/
|
||||
_validateProfile(profile) {
|
||||
_validateProfile(profile: string): void {
|
||||
if (!profile) {
|
||||
console.error('[X] No profile specified');
|
||||
console.error(' Usage: ccs <profile> -p "task"');
|
||||
@@ -203,10 +207,8 @@ class DelegationHandler {
|
||||
console.error(` ${validation.error}`);
|
||||
console.error('');
|
||||
console.error(' Run: ccs doctor');
|
||||
console.error(' Or configure: ~/.ccs/${profile}.settings.json');
|
||||
console.error(` Or configure: ~/.ccs/${profile}.settings.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DelegationHandler;
|
||||
@@ -1,39 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const { SessionManager } = require('./session-manager');
|
||||
const { SettingsParser } = require('./settings-parser');
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import { SessionManager } from './session-manager';
|
||||
import { SettingsParser } from './settings-parser';
|
||||
|
||||
// Type definitions for delegation responses
|
||||
interface ClaudeMessage {
|
||||
type: string;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
tool_use?: {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
tool_result?: {
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PermissionDenial {
|
||||
tool_name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ExecutionError {
|
||||
message?: string;
|
||||
error?: string;
|
||||
type?: string;
|
||||
tool_name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExecutionOptions {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
outputFormat?: string;
|
||||
permissionMode?: string;
|
||||
resumeSession?: boolean;
|
||||
sessionId?: string;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
interface ExecutionResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
cwd: string;
|
||||
profile: string;
|
||||
duration: number;
|
||||
timedOut: boolean;
|
||||
success: boolean;
|
||||
messages: ClaudeMessage[];
|
||||
sessionId?: string;
|
||||
totalCost?: number;
|
||||
numTurns?: number;
|
||||
isError?: boolean;
|
||||
type?: string | null;
|
||||
subtype?: string;
|
||||
durationApi?: number;
|
||||
permissionDenials?: PermissionDenial[];
|
||||
errors?: ExecutionError[];
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface StreamMessage {
|
||||
type: string;
|
||||
message?: {
|
||||
content?: Array<{
|
||||
type: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
session_id?: string;
|
||||
total_cost_usd?: number;
|
||||
num_turns?: number;
|
||||
is_error?: boolean;
|
||||
result?: string;
|
||||
duration_api_ms?: number;
|
||||
permission_denials?: PermissionDenial[];
|
||||
errors?: ExecutionError[];
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless executor for Claude CLI delegation
|
||||
* Spawns claude with -p flag for single-turn execution
|
||||
*/
|
||||
class HeadlessExecutor {
|
||||
export class HeadlessExecutor {
|
||||
/**
|
||||
* Execute task via headless Claude CLI
|
||||
* @param {string} profile - Profile name (glm, kimi, custom)
|
||||
* @param {string} enhancedPrompt - Enhanced prompt with context
|
||||
* @param {Object} options - Execution options
|
||||
* @param {string} options.cwd - Working directory (absolute path)
|
||||
* @param {number} options.timeout - Timeout in milliseconds (default: 600000 = 10 minutes)
|
||||
* @param {string} options.outputFormat - Output format: 'stream-json' or 'text' (default: 'stream-json')
|
||||
* @param {string} options.permissionMode - Permission mode: 'default', 'plan', 'acceptEdits', 'bypassPermissions' (default: 'acceptEdits')
|
||||
* @param {boolean} options.resumeSession - Resume last session for profile (default: false)
|
||||
* @param {string} options.sessionId - Specific session ID to resume
|
||||
* @returns {Promise<Object>} Execution result
|
||||
* @param profile - Profile name (glm, kimi, custom)
|
||||
* @param enhancedPrompt - Enhanced prompt with context
|
||||
* @param options - Execution options
|
||||
* @returns execution result
|
||||
*/
|
||||
static async execute(profile, enhancedPrompt, options = {}) {
|
||||
static async execute(
|
||||
profile: string,
|
||||
enhancedPrompt: string,
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<ExecutionResult> {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
timeout = 600000, // 10 minutes default
|
||||
outputFormat = 'stream-json', // Use stream-json for real-time progress
|
||||
permissionMode = 'acceptEdits',
|
||||
resumeSession = false,
|
||||
sessionId = null
|
||||
sessionId = null,
|
||||
} = options;
|
||||
|
||||
// Validate permission mode
|
||||
@@ -45,7 +122,9 @@ class HeadlessExecutor {
|
||||
// Detect Claude CLI path
|
||||
const claudeCli = this._detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
throw new Error('Claude CLI not found in PATH. Install from: https://docs.claude.com/en/docs/claude-code/installation');
|
||||
throw new Error(
|
||||
'Claude CLI not found in PATH. Install from: https://docs.claude.com/en/docs/claude-code/installation'
|
||||
);
|
||||
}
|
||||
|
||||
// Get settings path for profile
|
||||
@@ -53,7 +132,9 @@ class HeadlessExecutor {
|
||||
|
||||
// Validate settings file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
throw new Error(`Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.`);
|
||||
throw new Error(
|
||||
`Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.`
|
||||
);
|
||||
}
|
||||
|
||||
// Smart slash command detection and preservation
|
||||
@@ -61,7 +142,7 @@ class HeadlessExecutor {
|
||||
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
||||
|
||||
// Prepare arguments
|
||||
const args = ['-p', processedPrompt, '--settings', settingsPath];
|
||||
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
|
||||
@@ -74,7 +155,9 @@ class HeadlessExecutor {
|
||||
// Warn about dangerous mode
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.warn('[!] WARNING: Using --dangerously-skip-permissions mode');
|
||||
console.warn('[!] This bypasses ALL permission checks. Use only in trusted environments.');
|
||||
console.warn(
|
||||
'[!] This bypasses ALL permission checks. Use only in trusted environments.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
args.push('--permission-mode', permissionMode);
|
||||
@@ -88,8 +171,13 @@ class HeadlessExecutor {
|
||||
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(`[i] Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`);
|
||||
const cost =
|
||||
lastSession.totalCost !== undefined && lastSession.totalCost !== null
|
||||
? lastSession.totalCost.toFixed(4)
|
||||
: '0.0000';
|
||||
console.error(
|
||||
`[i] Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`
|
||||
);
|
||||
}
|
||||
} else if (sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
@@ -111,12 +199,12 @@ class HeadlessExecutor {
|
||||
|
||||
if (toolRestrictions.allowedTools.length > 0) {
|
||||
args.push('--allowedTools');
|
||||
toolRestrictions.allowedTools.forEach(tool => args.push(tool));
|
||||
toolRestrictions.allowedTools.forEach((tool) => args.push(tool));
|
||||
}
|
||||
|
||||
if (toolRestrictions.disallowedTools.length > 0) {
|
||||
args.push('--disallowedTools');
|
||||
toolRestrictions.disallowedTools.forEach(tool => args.push(tool));
|
||||
toolRestrictions.disallowedTools.forEach((tool) => args.push(tool));
|
||||
}
|
||||
|
||||
// Note: No max-turns limit - using time-based limits instead (default 10min timeout)
|
||||
@@ -135,20 +223,21 @@ class HeadlessExecutor {
|
||||
|
||||
// Show initial progress message
|
||||
if (showProgress) {
|
||||
const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase();
|
||||
const modelName =
|
||||
profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase();
|
||||
console.error(`[i] Delegating to ${modelName}...`);
|
||||
}
|
||||
|
||||
const proc = spawn(claudeCli, args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout
|
||||
timeout,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let progressInterval;
|
||||
const messages = []; // Accumulate stream-json messages
|
||||
let progressInterval: NodeJS.Timeout | undefined;
|
||||
const messages: StreamMessage[] = []; // Accumulate stream-json messages
|
||||
let partialLine = ''; // Buffer for incomplete JSON lines
|
||||
|
||||
// Handle parent process termination (Ctrl+C or Esc in Claude)
|
||||
@@ -190,11 +279,12 @@ class HeadlessExecutor {
|
||||
}
|
||||
|
||||
// Capture stdout (stream-json format - jsonl)
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
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 + data.toString();
|
||||
const chunk = partialLine + dataStr;
|
||||
const lines = chunk.split('\n');
|
||||
partialLine = lines.pop() || ''; // Save incomplete line for next chunk
|
||||
|
||||
@@ -202,12 +292,12 @@ class HeadlessExecutor {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
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') || [];
|
||||
const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || [];
|
||||
|
||||
for (const tool of toolUses) {
|
||||
process.stderr.write('\r\x1b[K'); // Clear line
|
||||
@@ -221,9 +311,8 @@ class HeadlessExecutor {
|
||||
case 'Bash':
|
||||
if (toolInput.command) {
|
||||
// Truncate long commands
|
||||
const cmd = toolInput.command.length > 80
|
||||
? toolInput.command.substring(0, 77) + '...'
|
||||
: toolInput.command;
|
||||
const command = toolInput.command as string;
|
||||
const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command;
|
||||
verboseMsg += `: ${cmd}`;
|
||||
}
|
||||
break;
|
||||
@@ -268,9 +357,9 @@ class HeadlessExecutor {
|
||||
if (toolInput.description) {
|
||||
verboseMsg += `: ${toolInput.description}`;
|
||||
} else if (toolInput.prompt) {
|
||||
const prompt = toolInput.prompt.length > 60
|
||||
? toolInput.prompt.substring(0, 57) + '...'
|
||||
: toolInput.prompt;
|
||||
const promptText = toolInput.prompt as string;
|
||||
const prompt =
|
||||
promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText;
|
||||
verboseMsg += `: ${prompt}`;
|
||||
}
|
||||
break;
|
||||
@@ -278,7 +367,9 @@ class HeadlessExecutor {
|
||||
case 'TodoWrite':
|
||||
if (toolInput.todos && Array.isArray(toolInput.todos)) {
|
||||
// Show in_progress task instead of just count
|
||||
const inProgressTask = toolInput.todos.find(t => t.status === 'in_progress');
|
||||
const inProgressTask = toolInput.todos.find(
|
||||
(t: { status: string; activeForm?: string }) => t.status === 'in_progress'
|
||||
);
|
||||
if (inProgressTask && inProgressTask.activeForm) {
|
||||
verboseMsg += `: ${inProgressTask.activeForm}`;
|
||||
} else {
|
||||
@@ -317,14 +408,16 @@ class HeadlessExecutor {
|
||||
} catch (parseError) {
|
||||
// Skip malformed JSON lines (shouldn't happen with stream-json)
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[!] Failed to parse stream-json line: ${parseError.message}`);
|
||||
console.error(
|
||||
`[!] Failed to parse stream-json line: ${(parseError as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream stderr in real-time (progress messages from Claude CLI)
|
||||
proc.stderr.on('data', (data) => {
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
const stderrText = data.toString();
|
||||
stderr += stderrText;
|
||||
|
||||
@@ -339,7 +432,7 @@ class HeadlessExecutor {
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
proc.on('close', (exitCode) => {
|
||||
proc.on('close', (exitCode: number | null) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Clear progress indicator
|
||||
@@ -359,28 +452,28 @@ class HeadlessExecutor {
|
||||
console.error(''); // Blank line before formatted output
|
||||
}
|
||||
|
||||
const result = {
|
||||
exitCode,
|
||||
const result: ExecutionResult = {
|
||||
exitCode: exitCode || 0,
|
||||
stdout,
|
||||
stderr,
|
||||
cwd,
|
||||
profile,
|
||||
duration,
|
||||
timedOut,
|
||||
timedOut: false,
|
||||
success: exitCode === 0 && !timedOut,
|
||||
messages // Include all stream-json messages
|
||||
messages, // Include all stream-json messages
|
||||
};
|
||||
|
||||
// Extract metadata from final 'result' message in stream-json
|
||||
const resultMessage = messages.find(m => m.type === 'result');
|
||||
const resultMessage = messages.find((m) => m.type === 'result');
|
||||
if (resultMessage) {
|
||||
// Add parsed fields from result message
|
||||
result.sessionId = resultMessage.session_id || null;
|
||||
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 || null;
|
||||
result.subtype = resultMessage.subtype || undefined;
|
||||
result.durationApi = resultMessage.duration_api_ms || 0;
|
||||
result.permissionDenials = resultMessage.permission_denials || [];
|
||||
result.errors = resultMessage.errors || [];
|
||||
@@ -400,19 +493,20 @@ class HeadlessExecutor {
|
||||
if (resumeSession || sessionId) {
|
||||
// Update existing session
|
||||
sessionMgr.updateSession(profile, result.sessionId, {
|
||||
totalCost: result.totalCost
|
||||
totalCost: result.totalCost,
|
||||
});
|
||||
} else {
|
||||
// Store new session
|
||||
sessionMgr.storeSession(profile, {
|
||||
sessionId: result.sessionId,
|
||||
totalCost: result.totalCost,
|
||||
cwd: result.cwd
|
||||
cwd: result.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup expired sessions periodically
|
||||
if (Math.random() < 0.1) { // 10% chance
|
||||
if (Math.random() < 0.1) {
|
||||
// 10% chance
|
||||
sessionMgr.cleanupExpired();
|
||||
}
|
||||
}
|
||||
@@ -421,7 +515,7 @@ class HeadlessExecutor {
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
proc.on('error', (error) => {
|
||||
proc.on('error', (error: Error) => {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
@@ -441,7 +535,9 @@ class HeadlessExecutor {
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[!] Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`);
|
||||
console.error(
|
||||
`[!] Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`
|
||||
);
|
||||
}
|
||||
|
||||
// Send SIGTERM for graceful shutdown
|
||||
@@ -467,25 +563,23 @@ class HeadlessExecutor {
|
||||
|
||||
/**
|
||||
* Validate permission mode
|
||||
* @param {string} mode - Permission mode
|
||||
* @param mode - Permission mode
|
||||
* @throws {Error} If mode is invalid
|
||||
* @private
|
||||
*/
|
||||
static _validatePermissionMode(mode) {
|
||||
private static _validatePermissionMode(mode: string): void {
|
||||
const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
|
||||
if (!VALID_MODES.includes(mode)) {
|
||||
throw new Error(
|
||||
`Invalid permission mode: "${mode}". Valid modes: ${VALID_MODES.join(', ')}`
|
||||
);
|
||||
throw new Error(`Invalid permission mode: "${mode}". Valid modes: ${VALID_MODES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude CLI executable
|
||||
* @returns {string|null} Path to claude CLI or null if not found
|
||||
* @returns Path to claude CLI or null if not found
|
||||
* @private
|
||||
*/
|
||||
static _detectClaudeCli() {
|
||||
private static _detectClaudeCli(): string | null {
|
||||
// Check environment variable override
|
||||
if (process.env.CCS_CLAUDE_PATH) {
|
||||
return process.env.CCS_CLAUDE_PATH;
|
||||
@@ -496,22 +590,25 @@ class HeadlessExecutor {
|
||||
try {
|
||||
const result = execSync('command -v claude', { encoding: 'utf8' });
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute with retry logic
|
||||
* @param {string} profile - Profile name
|
||||
* @param {string} enhancedPrompt - Enhanced prompt
|
||||
* @param {Object} options - Execution options
|
||||
* @param {number} options.maxRetries - Maximum retry attempts (default: 2)
|
||||
* @returns {Promise<Object>} Execution result
|
||||
* @param profile - Profile name
|
||||
* @param enhancedPrompt - Enhanced prompt
|
||||
* @param options - Execution options
|
||||
* @returns execution result
|
||||
*/
|
||||
static async executeWithRetry(profile, enhancedPrompt, options = {}) {
|
||||
static async executeWithRetry(
|
||||
profile: string,
|
||||
enhancedPrompt: string,
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<ExecutionResult> {
|
||||
const { maxRetries = 2, ...execOptions } = options;
|
||||
let lastError;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
@@ -532,10 +629,12 @@ class HeadlessExecutor {
|
||||
// Last attempt failed, return result anyway
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
console.error(`[!] Attempt ${attempt + 1} errored: ${error.message}, retrying...`);
|
||||
console.error(
|
||||
`[!] Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...`
|
||||
);
|
||||
await this._sleep(1000 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
@@ -547,22 +646,22 @@ class HeadlessExecutor {
|
||||
|
||||
/**
|
||||
* Sleep utility for retry backoff
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
* @param ms - Milliseconds to sleep
|
||||
* @returns Promise<void>
|
||||
* @private
|
||||
*/
|
||||
static _sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
private static _sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process prompt to detect and preserve slash commands
|
||||
* Implements smart enhancement: preserves slash command at start, allows context in rest
|
||||
* @param {string} prompt - Original prompt (may contain slash command)
|
||||
* @returns {string} Processed prompt with slash command preserved
|
||||
* @param prompt - Original prompt (may contain slash command)
|
||||
* @returns Processed prompt with slash command preserved
|
||||
* @private
|
||||
*/
|
||||
static _processSlashCommand(prompt) {
|
||||
private static _processSlashCommand(prompt: string): string {
|
||||
const trimmed = prompt.trim();
|
||||
|
||||
// Case 1: Already starts with slash command - keep as-is
|
||||
@@ -582,7 +681,8 @@ class HeadlessExecutor {
|
||||
const args = (embeddedSlash[2] || '').trim(); // Everything after command
|
||||
|
||||
// Calculate where the command starts (excluding preceding char if any)
|
||||
const matchStart = embeddedSlash.index + (embeddedSlash[0][0] === '/' ? 0 : 1);
|
||||
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
|
||||
@@ -600,19 +700,17 @@ class HeadlessExecutor {
|
||||
|
||||
/**
|
||||
* Test if profile is executable (quick health check)
|
||||
* @param {string} profile - Profile name
|
||||
* @returns {Promise<boolean>} True if profile can execute
|
||||
* @param profile - Profile name
|
||||
* @returns True if profile can execute
|
||||
*/
|
||||
static async testProfile(profile) {
|
||||
static async testProfile(profile: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.execute(profile, 'Say "test successful"', {
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HeadlessExecutor };
|
||||
@@ -1,40 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Formats delegation execution results for display
|
||||
* Creates ASCII box output with file change tracking
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface FileChanges {
|
||||
created: string[];
|
||||
modified: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result Formatter Class
|
||||
*/
|
||||
class ResultFormatter {
|
||||
/**
|
||||
* Format execution result with complete source-of-truth
|
||||
* @param {Object} result - Execution result from HeadlessExecutor
|
||||
* @param {string} result.profile - Profile used (glm, kimi, etc.)
|
||||
* @param {string} result.cwd - Working directory
|
||||
* @param {number} result.exitCode - Exit code
|
||||
* @param {string} result.stdout - Standard output
|
||||
* @param {string} result.stderr - Standard error
|
||||
* @param {number} result.duration - Duration in milliseconds
|
||||
* @param {boolean} result.success - Success flag
|
||||
* @param {string} result.content - Parsed content (from JSON or stdout)
|
||||
* @param {string} result.sessionId - Session ID (from JSON)
|
||||
* @param {number} result.totalCost - Total cost USD (from JSON)
|
||||
* @param {number} result.numTurns - Number of turns (from JSON)
|
||||
* @returns {string} Formatted result
|
||||
*/
|
||||
static format(result) {
|
||||
const { profile, cwd, exitCode, stdout, stderr, duration, success, content, sessionId, totalCost, numTurns, subtype, permissionDenials, errors, json, timedOut } = result;
|
||||
static format(result: ExecutionResult): string {
|
||||
const {
|
||||
profile,
|
||||
cwd,
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
success,
|
||||
content,
|
||||
sessionId,
|
||||
totalCost,
|
||||
numTurns,
|
||||
subtype,
|
||||
permissionDenials,
|
||||
errors,
|
||||
timedOut,
|
||||
} = result;
|
||||
|
||||
// Handle timeout (graceful termination)
|
||||
if (timedOut) {
|
||||
return this._formatTimeoutError(result);
|
||||
return this.formatTimeoutError(result);
|
||||
}
|
||||
|
||||
// Handle legacy max_turns error (Claude CLI might still return this)
|
||||
if (subtype === 'error_max_turns') {
|
||||
return this._formatTimeoutError(result);
|
||||
return this.formatTimeoutError(result);
|
||||
}
|
||||
|
||||
// Use content field for output (JSON result or fallback stdout)
|
||||
@@ -44,49 +91,46 @@ class ResultFormatter {
|
||||
let output = '';
|
||||
|
||||
// Header
|
||||
output += this._formatHeader(profile, success);
|
||||
output += this.formatHeader(profile, success);
|
||||
|
||||
// Info box (file detection handled by delegated session itself)
|
||||
output += this._formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns);
|
||||
output += this.formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns);
|
||||
|
||||
// Task output
|
||||
output += '\n';
|
||||
output += this._formatOutput(displayOutput);
|
||||
output += this.formatOutput(displayOutput);
|
||||
|
||||
// Permission denials if present
|
||||
if (permissionDenials && permissionDenials.length > 0) {
|
||||
output += '\n';
|
||||
output += this._formatPermissionDenials(permissionDenials);
|
||||
output += this.formatPermissionDenials(permissionDenials);
|
||||
}
|
||||
|
||||
// Errors if present
|
||||
if (errors && errors.length > 0) {
|
||||
output += '\n';
|
||||
output += this._formatErrors(errors);
|
||||
output += this.formatErrors(errors);
|
||||
}
|
||||
|
||||
// Stderr if present
|
||||
if (stderr && stderr.trim()) {
|
||||
output += '\n';
|
||||
output += this._formatStderr(stderr);
|
||||
output += this.formatStderr(stderr);
|
||||
}
|
||||
|
||||
// Footer
|
||||
output += '\n';
|
||||
output += this._formatFooter(success, duration);
|
||||
output += this.formatFooter(success, duration);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file changes from output
|
||||
* @param {string} output - Command output
|
||||
* @param {string} cwd - Working directory for filesystem scanning fallback
|
||||
* @returns {Object} { created: Array<string>, modified: Array<string> }
|
||||
*/
|
||||
static extractFileChanges(output, cwd) {
|
||||
const created = [];
|
||||
const modified = [];
|
||||
static extractFileChanges(output: string, cwd: string): FileChanges {
|
||||
const created: string[] = [];
|
||||
const modified: string[] = [];
|
||||
|
||||
// Patterns to match file operations (case-insensitive)
|
||||
const createdPatterns = [
|
||||
@@ -96,7 +140,7 @@ class ResultFormatter {
|
||||
/write:\s*([^\n\r]+)/gi,
|
||||
/new file:\s*([^\n\r]+)/gi,
|
||||
/generated:\s*([^\n\r]+)/gi,
|
||||
/added:\s*([^\n\r]+)/gi
|
||||
/added:\s*([^\n\r]+)/gi,
|
||||
];
|
||||
|
||||
const modifiedPatterns = [
|
||||
@@ -105,11 +149,11 @@ class ResultFormatter {
|
||||
/updated:\s*([^\n\r]+)/gi,
|
||||
/edit:\s*([^\n\r]+)/gi,
|
||||
/edited:\s*([^\n\r]+)/gi,
|
||||
/changed:\s*([^\n\r]+)/gi
|
||||
/changed:\s*([^\n\r]+)/gi,
|
||||
];
|
||||
|
||||
// Helper to check if file is infrastructure (should be ignored)
|
||||
const isInfrastructure = (filePath) => {
|
||||
const isInfrastructure = (filePath: string): boolean => {
|
||||
return filePath.includes('/.claude/') || filePath.startsWith('.claude/');
|
||||
};
|
||||
|
||||
@@ -130,7 +174,12 @@ class ResultFormatter {
|
||||
while ((match = pattern.exec(output)) !== null) {
|
||||
const filePath = match[1].trim();
|
||||
// Don't include if already in created list or is infrastructure
|
||||
if (filePath && !modified.includes(filePath) && !created.includes(filePath) && !isInfrastructure(filePath)) {
|
||||
if (
|
||||
filePath &&
|
||||
!modified.includes(filePath) &&
|
||||
!created.includes(filePath) &&
|
||||
!isInfrastructure(filePath)
|
||||
) {
|
||||
modified.push(filePath);
|
||||
}
|
||||
}
|
||||
@@ -139,15 +188,12 @@ class ResultFormatter {
|
||||
// Fallback: Scan filesystem for recently modified files (last 5 minutes)
|
||||
if (created.length === 0 && modified.length === 0 && cwd) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const childProcess = require('child_process');
|
||||
|
||||
// Use find command to get recently modified files (excluding infrastructure)
|
||||
const findCmd = `find . -type f -mmin -5 -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.claude/*" 2>/dev/null | head -20`;
|
||||
const result = childProcess.execSync(findCmd, { cwd, encoding: 'utf8', timeout: 5000 });
|
||||
const result = execSync(findCmd, { cwd, encoding: 'utf8', timeout: 5000 });
|
||||
|
||||
const files = result.split('\n').filter(f => f.trim());
|
||||
files.forEach(file => {
|
||||
const files = result.split('\n').filter((f) => f.trim());
|
||||
files.forEach((file) => {
|
||||
const fullPath = path.join(cwd, file);
|
||||
|
||||
// Double-check not infrastructure
|
||||
@@ -163,7 +209,7 @@ class ResultFormatter {
|
||||
|
||||
// If both mtime and ctime are very recent (within 10 minutes), likely created
|
||||
// ctime = inode change time, for new files this is close to creation time
|
||||
const isVeryRecent = (now - mtime) < 600000 && (now - ctime) < 600000;
|
||||
const isVeryRecent = now - mtime < 600000 && now - ctime < 600000;
|
||||
const timeDiff = Math.abs(mtime - ctime);
|
||||
|
||||
// If mtime and ctime are very close (< 1 second apart) and both recent, it's created
|
||||
@@ -177,7 +223,7 @@ class ResultFormatter {
|
||||
modified.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (statError) {
|
||||
} catch (_statError) {
|
||||
// If stat fails, default to created (since we're in fallback mode)
|
||||
if (!created.includes(fullPath) && !modified.includes(fullPath)) {
|
||||
created.push(fullPath);
|
||||
@@ -187,7 +233,7 @@ class ResultFormatter {
|
||||
} catch (scanError) {
|
||||
// Silently fail if filesystem scan doesn't work
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[!] Filesystem scan failed: ${scanError.message}`);
|
||||
console.error(`[!] Filesystem scan failed: ${(scanError as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,31 +243,26 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format header with delegation indicator
|
||||
* @param {string} profile - Profile name
|
||||
* @param {boolean} success - Success flag
|
||||
* @returns {string} Formatted header
|
||||
* @private
|
||||
*/
|
||||
static _formatHeader(profile, success) {
|
||||
const modelName = this._getModelDisplayName(profile);
|
||||
private static formatHeader(profile: string, success: boolean): string {
|
||||
const modelName = this.getModelDisplayName(profile);
|
||||
const icon = success ? '[i]' : '[X]';
|
||||
return `${icon} Delegated to ${modelName} (ccs:${profile})\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format info box with delegation details
|
||||
* @param {string} cwd - Working directory
|
||||
* @param {string} profile - Profile name
|
||||
* @param {number} duration - Duration in ms
|
||||
* @param {number} exitCode - Exit code
|
||||
* @param {string} sessionId - Session ID (from JSON)
|
||||
* @param {number} totalCost - Total cost USD (from JSON)
|
||||
* @param {number} numTurns - Number of turns (from JSON)
|
||||
* @returns {string} Formatted info box
|
||||
* @private
|
||||
*/
|
||||
static _formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns) {
|
||||
const modelName = this._getModelDisplayName(profile);
|
||||
private static formatInfoBox(
|
||||
cwd: string,
|
||||
profile: string,
|
||||
duration: number,
|
||||
exitCode: number,
|
||||
sessionId?: string,
|
||||
totalCost?: number,
|
||||
numTurns?: number
|
||||
): string {
|
||||
const modelName = this.getModelDisplayName(profile);
|
||||
const durationSec = (duration / 1000).toFixed(1);
|
||||
|
||||
// Calculate box width (fit longest line + padding)
|
||||
@@ -229,11 +270,11 @@ class ResultFormatter {
|
||||
const cwdLine = `Working Directory: ${cwd}`;
|
||||
const boxWidth = Math.min(Math.max(cwdLine.length + 4, 50), maxWidth);
|
||||
|
||||
const lines = [
|
||||
`Working Directory: ${this._truncate(cwd, boxWidth - 22)}`,
|
||||
const lines: string[] = [
|
||||
`Working Directory: ${this.truncate(cwd, boxWidth - 22)}`,
|
||||
`Model: ${modelName}`,
|
||||
`Duration: ${durationSec}s`,
|
||||
`Exit Code: ${exitCode}`
|
||||
`Exit Code: ${exitCode}`,
|
||||
];
|
||||
|
||||
// Add JSON-specific fields if available
|
||||
@@ -264,11 +305,8 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format task output
|
||||
* @param {string} output - Standard output
|
||||
* @returns {string} Formatted output
|
||||
* @private
|
||||
*/
|
||||
static _formatOutput(output) {
|
||||
private static formatOutput(output: string): string {
|
||||
if (!output || !output.trim()) {
|
||||
return '[i] No output from delegated task\n';
|
||||
}
|
||||
@@ -278,22 +316,16 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format stderr output
|
||||
* @param {string} stderr - Standard error
|
||||
* @returns {string} Formatted stderr
|
||||
* @private
|
||||
*/
|
||||
static _formatStderr(stderr) {
|
||||
private static formatStderr(stderr: string): string {
|
||||
return `[!] Stderr:\n${stderr.trim()}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file list (created or modified)
|
||||
* @param {string} label - Label (Created/Modified)
|
||||
* @param {Array<string>} files - File paths
|
||||
* @returns {string} Formatted file list
|
||||
* @private
|
||||
* Format file list (created or modified) - Currently unused
|
||||
*/
|
||||
static _formatFileList(label, files) {
|
||||
/*
|
||||
private static formatFileList(label: string, files: string[]): string {
|
||||
let output = `[i] ${label} Files:\n`;
|
||||
|
||||
for (const file of files) {
|
||||
@@ -302,15 +334,12 @@ class ResultFormatter {
|
||||
|
||||
return output;
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format footer with completion status
|
||||
* @param {boolean} success - Success flag
|
||||
* @param {number} duration - Duration in ms
|
||||
* @returns {string} Formatted footer
|
||||
* @private
|
||||
*/
|
||||
static _formatFooter(success, duration) {
|
||||
private static formatFooter(success: boolean, _duration: number): string {
|
||||
const icon = success ? '[OK]' : '[X]';
|
||||
const status = success ? 'Delegation completed' : 'Delegation failed';
|
||||
return `${icon} ${status}\n`;
|
||||
@@ -318,16 +347,13 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Get display name for model profile
|
||||
* @param {string} profile - Profile name
|
||||
* @returns {string} Display name
|
||||
* @private
|
||||
*/
|
||||
static _getModelDisplayName(profile) {
|
||||
const displayNames = {
|
||||
'glm': 'GLM-4.6',
|
||||
'glmt': 'GLM-4.6 (Thinking)',
|
||||
'kimi': 'Kimi',
|
||||
'default': 'Claude'
|
||||
private static getModelDisplayName(profile: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
glm: 'GLM-4.6',
|
||||
glmt: 'GLM-4.6 (Thinking)',
|
||||
kimi: 'Kimi',
|
||||
default: 'Claude',
|
||||
};
|
||||
|
||||
return displayNames[profile] || profile.toUpperCase();
|
||||
@@ -335,12 +361,8 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Truncate string to max length
|
||||
* @param {string} str - String to truncate
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} Truncated string
|
||||
* @private
|
||||
*/
|
||||
static _truncate(str, maxLength) {
|
||||
private static truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
@@ -349,12 +371,10 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format minimal result (for quick tasks)
|
||||
* @param {Object} result - Execution result
|
||||
* @returns {string} Minimal formatted result
|
||||
*/
|
||||
static formatMinimal(result) {
|
||||
static formatMinimal(result: ExecutionResult): string {
|
||||
const { profile, success, duration } = result;
|
||||
const modelName = this._getModelDisplayName(profile);
|
||||
const modelName = this.getModelDisplayName(profile);
|
||||
const icon = success ? '[OK]' : '[X]';
|
||||
const durationSec = (duration / 1000).toFixed(1);
|
||||
|
||||
@@ -363,10 +383,8 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format verbose result (with full details)
|
||||
* @param {Object} result - Execution result
|
||||
* @returns {string} Verbose formatted result
|
||||
*/
|
||||
static formatVerbose(result) {
|
||||
static formatVerbose(result: ExecutionResult): string {
|
||||
const basic = this.format(result);
|
||||
|
||||
// Add additional debug info
|
||||
@@ -384,30 +402,27 @@ class ResultFormatter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if NO_COLOR environment variable is set
|
||||
* @returns {boolean} True if colors should be disabled
|
||||
* @private
|
||||
* Check if NO_COLOR environment variable is set - Currently unused
|
||||
*/
|
||||
static _shouldDisableColors() {
|
||||
/*
|
||||
private static shouldDisableColors(): boolean {
|
||||
return process.env.NO_COLOR !== undefined;
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format timeout error (session exceeded time limit)
|
||||
* @param {Object} result - Execution result
|
||||
* @returns {string} Formatted timeout error
|
||||
* @private
|
||||
*/
|
||||
static _formatTimeoutError(result) {
|
||||
private static formatTimeoutError(result: ExecutionResult): string {
|
||||
const { profile, cwd, duration, sessionId, totalCost, numTurns, permissionDenials } = result;
|
||||
|
||||
let output = '';
|
||||
|
||||
// Header
|
||||
output += this._formatHeader(profile, false);
|
||||
output += this.formatHeader(profile, false);
|
||||
|
||||
// Info box
|
||||
output += this._formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns);
|
||||
output += this.formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns);
|
||||
|
||||
// Timeout message
|
||||
output += '\n';
|
||||
@@ -419,7 +434,7 @@ class ResultFormatter {
|
||||
// Permission denials if present
|
||||
if (permissionDenials && permissionDenials.length > 0) {
|
||||
output += '\n';
|
||||
output += this._formatPermissionDenials(permissionDenials);
|
||||
output += this.formatPermissionDenials(permissionDenials);
|
||||
output += '\n';
|
||||
output += 'The task may require permissions that were denied.\n';
|
||||
output += 'Consider running with --permission-mode bypassPermissions or execute manually.\n';
|
||||
@@ -446,11 +461,8 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format permission denials
|
||||
* @param {Array<Object>} denials - Permission denial objects
|
||||
* @returns {string} Formatted permission denials
|
||||
* @private
|
||||
*/
|
||||
static _formatPermissionDenials(denials) {
|
||||
private static formatPermissionDenials(denials: PermissionDenial[]): string {
|
||||
let output = '[!] Permission Denials:\n';
|
||||
|
||||
for (const denial of denials) {
|
||||
@@ -466,11 +478,8 @@ class ResultFormatter {
|
||||
|
||||
/**
|
||||
* Format errors array
|
||||
* @param {Array<Object>} errors - Error objects
|
||||
* @returns {string} Formatted errors
|
||||
* @private
|
||||
*/
|
||||
static _formatErrors(errors) {
|
||||
private static formatErrors(errors: ErrorInfo[]): string {
|
||||
let output = '[X] Errors:\n';
|
||||
|
||||
for (const error of errors) {
|
||||
@@ -482,4 +491,4 @@ class ResultFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ResultFormatter };
|
||||
export { ResultFormatter };
|
||||
@@ -1,28 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
* Manages delegation session persistence for multi-turn conversations
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
interface SessionData {
|
||||
sessionId: string;
|
||||
totalCost?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface TurnData {
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
sessionId: string;
|
||||
profile: string;
|
||||
startTime: number;
|
||||
lastTurnTime: number;
|
||||
totalCost: number;
|
||||
turns: number;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface SessionsRegistry {
|
||||
[key: string]: SessionMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Manager Class
|
||||
*/
|
||||
class SessionManager {
|
||||
private readonly sessionsPath: string;
|
||||
|
||||
constructor() {
|
||||
this.sessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new session metadata
|
||||
* @param {string} profile - Profile name (glm, kimi, etc.)
|
||||
* @param {Object} sessionData - Session data
|
||||
* @param {string} sessionData.sessionId - Claude session ID
|
||||
* @param {number} sessionData.totalCost - Initial cost
|
||||
* @param {string} sessionData.cwd - Working directory
|
||||
*/
|
||||
storeSession(profile, sessionData) {
|
||||
const sessions = this._loadSessions();
|
||||
storeSession(profile: string, sessionData: SessionData): void {
|
||||
const sessions = this.loadSessions();
|
||||
const key = `${profile}:latest`;
|
||||
|
||||
sessions[key] = {
|
||||
@@ -32,10 +56,10 @@ class SessionManager {
|
||||
lastTurnTime: Date.now(),
|
||||
totalCost: sessionData.totalCost || 0,
|
||||
turns: 1,
|
||||
cwd: sessionData.cwd || process.cwd()
|
||||
cwd: sessionData.cwd || process.cwd(),
|
||||
};
|
||||
|
||||
this._saveSessions(sessions);
|
||||
this.saveSessions(sessions);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[i] Stored session: ${sessionData.sessionId} for ${profile}`);
|
||||
@@ -44,60 +68,58 @@ class SessionManager {
|
||||
|
||||
/**
|
||||
* Update session after additional turn
|
||||
* @param {string} profile - Profile name
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} turnData - Turn data
|
||||
* @param {number} turnData.totalCost - Turn cost
|
||||
*/
|
||||
updateSession(profile, sessionId, turnData) {
|
||||
const sessions = this._loadSessions();
|
||||
updateSession(profile: string, sessionId: string, turnData: TurnData): void {
|
||||
const sessions = this.loadSessions();
|
||||
const key = `${profile}:latest`;
|
||||
|
||||
if (sessions[key]?.sessionId === sessionId) {
|
||||
sessions[key].lastTurnTime = Date.now();
|
||||
sessions[key].totalCost += turnData.totalCost || 0;
|
||||
sessions[key].turns += 1;
|
||||
this._saveSessions(sessions);
|
||||
this.saveSessions(sessions);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
const cost = sessions[key].totalCost !== undefined && sessions[key].totalCost !== null ? sessions[key].totalCost.toFixed(4) : '0.0000';
|
||||
console.error(`[i] Updated session: ${sessionId}, total: $${cost}, turns: ${sessions[key].turns}`);
|
||||
const cost =
|
||||
sessions[key].totalCost !== undefined && sessions[key].totalCost !== null
|
||||
? sessions[key].totalCost.toFixed(4)
|
||||
: '0.0000';
|
||||
console.error(
|
||||
`[i] Updated session: ${sessionId}, total: $${cost}, turns: ${sessions[key].turns}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last session for profile
|
||||
* @param {string} profile - Profile name
|
||||
* @returns {Object|null} Session metadata or null
|
||||
*/
|
||||
getLastSession(profile) {
|
||||
const sessions = this._loadSessions();
|
||||
getLastSession(profile: string): SessionMetadata | null {
|
||||
const sessions = this.loadSessions();
|
||||
const key = `${profile}:latest`;
|
||||
return sessions[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sessions for profile
|
||||
* @param {string} profile - Profile name
|
||||
*/
|
||||
clearProfile(profile) {
|
||||
const sessions = this._loadSessions();
|
||||
clearProfile(profile: string): void {
|
||||
const sessions = this.loadSessions();
|
||||
const key = `${profile}:latest`;
|
||||
delete sessions[key];
|
||||
this._saveSessions(sessions);
|
||||
this.saveSessions(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions (>30 days)
|
||||
*/
|
||||
cleanupExpired() {
|
||||
const sessions = this._loadSessions();
|
||||
cleanupExpired(): void {
|
||||
const sessions = this.loadSessions();
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
let cleaned = 0;
|
||||
Object.keys(sessions).forEach(key => {
|
||||
Object.keys(sessions).forEach((key) => {
|
||||
if (now - sessions[key].lastTurnTime > maxAge) {
|
||||
delete sessions[key];
|
||||
cleaned++;
|
||||
@@ -105,7 +127,7 @@ class SessionManager {
|
||||
});
|
||||
|
||||
if (cleaned > 0) {
|
||||
this._saveSessions(sessions);
|
||||
this.saveSessions(sessions);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[i] Cleaned ${cleaned} expired sessions`);
|
||||
}
|
||||
@@ -114,19 +136,17 @@ class SessionManager {
|
||||
|
||||
/**
|
||||
* Load sessions from disk
|
||||
* @returns {Object} Sessions object
|
||||
* @private
|
||||
*/
|
||||
_loadSessions() {
|
||||
private loadSessions(): SessionsRegistry {
|
||||
try {
|
||||
if (!fs.existsSync(this.sessionsPath)) {
|
||||
return {};
|
||||
}
|
||||
const content = fs.readFileSync(this.sessionsPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
return JSON.parse(content) as SessionsRegistry;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.warn(`[!] Failed to load sessions: ${error.message}`);
|
||||
console.warn(`[!] Failed to load sessions: ${(error as Error).message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -134,24 +154,18 @@ class SessionManager {
|
||||
|
||||
/**
|
||||
* Save sessions to disk
|
||||
* @param {Object} sessions - Sessions object
|
||||
* @private
|
||||
*/
|
||||
_saveSessions(sessions) {
|
||||
private saveSessions(sessions: SessionsRegistry): void {
|
||||
try {
|
||||
const dir = path.dirname(this.sessionsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
this.sessionsPath,
|
||||
JSON.stringify(sessions, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
fs.writeFileSync(this.sessionsPath, JSON.stringify(sessions, null, 2), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.error(`[!] Failed to save sessions: ${error.message}`);
|
||||
console.error(`[!] Failed to save sessions: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SessionManager };
|
||||
export { SessionManager };
|
||||
@@ -1,20 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parses Claude Code settings for tool restrictions
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface ToolRestrictions {
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
defaultMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings Parser Class
|
||||
*/
|
||||
class SettingsParser {
|
||||
/**
|
||||
* Parse default permission mode from project settings
|
||||
* @param {string} projectDir - Project directory (usually cwd)
|
||||
* @returns {string} Default permission mode (e.g., 'acceptEdits', 'bypassPermissions', 'plan', 'default')
|
||||
*/
|
||||
static parseDefaultPermissionMode(projectDir) {
|
||||
const settings = this._loadSettings(projectDir);
|
||||
static parseDefaultPermissionMode(projectDir: string): string {
|
||||
const settings = this.loadSettings(projectDir);
|
||||
const permissions = settings.permissions || {};
|
||||
|
||||
// Priority: local > shared > fallback to 'acceptEdits'
|
||||
@@ -29,11 +43,9 @@ class SettingsParser {
|
||||
|
||||
/**
|
||||
* Parse project settings for tool restrictions
|
||||
* @param {string} projectDir - Project directory (usually cwd)
|
||||
* @returns {Object} { allowedTools: string[], disallowedTools: string[] }
|
||||
*/
|
||||
static parseToolRestrictions(projectDir) {
|
||||
const settings = this._loadSettings(projectDir);
|
||||
static parseToolRestrictions(projectDir: string): ToolRestrictions {
|
||||
const settings = this.loadSettings(projectDir);
|
||||
const permissions = settings.permissions || {};
|
||||
|
||||
const allowed = permissions.allow || [];
|
||||
@@ -45,65 +57,53 @@ class SettingsParser {
|
||||
|
||||
return {
|
||||
allowedTools: allowed,
|
||||
disallowedTools: denied
|
||||
disallowedTools: denied,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge settings files (local overrides shared)
|
||||
* @param {string} projectDir - Project directory
|
||||
* @returns {Object} Merged settings
|
||||
* @private
|
||||
*/
|
||||
static _loadSettings(projectDir) {
|
||||
private static loadSettings(projectDir: string): Settings {
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
const sharedPath = path.join(claudeDir, 'settings.json');
|
||||
const localPath = path.join(claudeDir, 'settings.local.json');
|
||||
|
||||
// Load shared settings
|
||||
const shared = this._readJsonSafe(sharedPath) || {};
|
||||
const shared = this.readJsonSafe(sharedPath) || {};
|
||||
|
||||
// Load local settings (overrides shared)
|
||||
const local = this._readJsonSafe(localPath) || {};
|
||||
const local = this.readJsonSafe(localPath) || {};
|
||||
|
||||
// Merge permissions (local overrides shared)
|
||||
return {
|
||||
permissions: {
|
||||
allow: [
|
||||
...(shared.permissions?.allow || []),
|
||||
...(local.permissions?.allow || [])
|
||||
],
|
||||
deny: [
|
||||
...(shared.permissions?.deny || []),
|
||||
...(local.permissions?.deny || [])
|
||||
],
|
||||
allow: [...(shared.permissions?.allow || []), ...(local.permissions?.allow || [])],
|
||||
deny: [...(shared.permissions?.deny || []), ...(local.permissions?.deny || [])],
|
||||
// Local defaultMode takes priority over shared
|
||||
defaultMode: local.permissions?.defaultMode || shared.permissions?.defaultMode || null
|
||||
}
|
||||
defaultMode: local.permissions?.defaultMode || shared.permissions?.defaultMode || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON file safely (no throw)
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @returns {Object|null} Parsed JSON or null
|
||||
* @private
|
||||
*/
|
||||
static _readJsonSafe(filePath) {
|
||||
private static readJsonSafe(filePath: string): Settings | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
return JSON.parse(content) as Settings;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.warn(`[!] Failed to read settings: ${filePath}: ${error.message}`);
|
||||
console.warn(`[!] Failed to read settings: ${filePath}: ${(error as Error).message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SettingsParser };
|
||||
export { SettingsParser };
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DeltaAccumulator - Maintain state across streaming deltas
|
||||
@@ -14,9 +13,88 @@
|
||||
* const acc = new DeltaAccumulator(thinkingConfig);
|
||||
* const events = transformer.transformDelta(openaiEvent, acc);
|
||||
*/
|
||||
class DeltaAccumulator {
|
||||
constructor(thinkingConfig = {}, options = {}) {
|
||||
this.thinkingConfig = thinkingConfig;
|
||||
|
||||
interface ThinkingConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DeltaAccumulatorOptions {
|
||||
maxBlocks?: number;
|
||||
maxBufferSize?: number;
|
||||
loopDetectionThreshold?: number;
|
||||
}
|
||||
|
||||
interface ContentBlock {
|
||||
index: number;
|
||||
type: string;
|
||||
content: string;
|
||||
started: boolean;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
index: number;
|
||||
id: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolCallDelta {
|
||||
index: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
prompt_tokens?: number;
|
||||
input_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
output_tokens?: number;
|
||||
}
|
||||
|
||||
interface AccumulatorSummary {
|
||||
messageId: string;
|
||||
model: string | null;
|
||||
role: string;
|
||||
blockCount: number;
|
||||
currentIndex: number;
|
||||
toolCallCount: number;
|
||||
messageStarted: boolean;
|
||||
finalized: boolean;
|
||||
loopDetected: boolean;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class DeltaAccumulator {
|
||||
private messageId: string;
|
||||
private model: string | null;
|
||||
private role: string;
|
||||
private contentBlocks: ContentBlock[];
|
||||
private currentBlockIndex: number;
|
||||
private toolCalls: ToolCall[];
|
||||
private toolCallsIndex: Record<number, ToolCall>;
|
||||
private thinkingBuffer: string;
|
||||
private textBuffer: string;
|
||||
private maxBlocks: number;
|
||||
private maxBufferSize: number;
|
||||
private loopDetectionThreshold: number;
|
||||
private loopDetected: boolean;
|
||||
private messageStarted: boolean;
|
||||
private finalized: boolean;
|
||||
private inputTokens: number;
|
||||
private outputTokens: number;
|
||||
|
||||
constructor(_thinkingConfig: ThinkingConfig = {}, options: DeltaAccumulatorOptions = {}) {
|
||||
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
|
||||
this.model = null;
|
||||
this.role = 'assistant';
|
||||
@@ -44,19 +122,17 @@ class DeltaAccumulator {
|
||||
// State flags
|
||||
this.messageStarted = false;
|
||||
this.finalized = false;
|
||||
this.usageReceived = false; // Track if usage data has arrived
|
||||
|
||||
// Statistics
|
||||
this.inputTokens = 0;
|
||||
this.outputTokens = 0;
|
||||
this.finishReason = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current content block
|
||||
* @returns {Object|null} Current block or null
|
||||
* @returns Current block or null
|
||||
*/
|
||||
getCurrentBlock() {
|
||||
getCurrentBlock(): ContentBlock | null {
|
||||
if (this.currentBlockIndex >= 0 && this.currentBlockIndex < this.contentBlocks.length) {
|
||||
return this.contentBlocks[this.currentBlockIndex];
|
||||
}
|
||||
@@ -65,22 +141,22 @@ class DeltaAccumulator {
|
||||
|
||||
/**
|
||||
* Start new content block
|
||||
* @param {string} type - Block type ('thinking', 'text', or 'tool_use')
|
||||
* @returns {Object} New block
|
||||
* @param type - Block type ('thinking', 'text', or 'tool_use')
|
||||
* @returns New block
|
||||
*/
|
||||
startBlock(type) {
|
||||
startBlock(type: string): ContentBlock {
|
||||
// C-02 Fix: Enforce max blocks limit
|
||||
if (this.contentBlocks.length >= this.maxBlocks) {
|
||||
throw new Error(`Maximum ${this.maxBlocks} content blocks exceeded (DoS protection)`);
|
||||
}
|
||||
|
||||
this.currentBlockIndex++;
|
||||
const block = {
|
||||
const block: ContentBlock = {
|
||||
index: this.currentBlockIndex,
|
||||
type: type,
|
||||
content: '',
|
||||
started: true,
|
||||
stopped: false
|
||||
stopped: false,
|
||||
};
|
||||
this.contentBlocks.push(block);
|
||||
|
||||
@@ -96,9 +172,9 @@ class DeltaAccumulator {
|
||||
|
||||
/**
|
||||
* Add delta to current block
|
||||
* @param {string} delta - Content delta
|
||||
* @param delta - Content delta
|
||||
*/
|
||||
addDelta(delta) {
|
||||
addDelta(delta: string): void {
|
||||
const block = this.getCurrentBlock();
|
||||
if (!block) {
|
||||
// FIX: Guard against null block (should never happen, but defensive)
|
||||
@@ -132,47 +208,48 @@ class DeltaAccumulator {
|
||||
/**
|
||||
* Mark current block as stopped
|
||||
*/
|
||||
stopCurrentBlock() {
|
||||
stopCurrentBlock(): void {
|
||||
const block = this.getCurrentBlock();
|
||||
if (block) {
|
||||
block.stopped = true;
|
||||
|
||||
// FIX: Log block closure for debugging (helps diagnose timing issues)
|
||||
if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') {
|
||||
console.error(`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`);
|
||||
console.error(
|
||||
`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage statistics
|
||||
* @param {Object} usage - Usage object from OpenAI
|
||||
* @param usage - Usage object from OpenAI
|
||||
*/
|
||||
updateUsage(usage) {
|
||||
updateUsage(usage: UsageStats): void {
|
||||
if (usage) {
|
||||
this.inputTokens = usage.prompt_tokens || usage.input_tokens || 0;
|
||||
this.outputTokens = usage.completion_tokens || usage.output_tokens || 0;
|
||||
this.usageReceived = true; // Mark that we've received usage data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update tool call delta
|
||||
* @param {Object} toolCallDelta - Tool call delta from OpenAI
|
||||
* @param toolCallDelta - Tool call delta from OpenAI
|
||||
*/
|
||||
addToolCallDelta(toolCallDelta) {
|
||||
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
||||
const index = toolCallDelta.index;
|
||||
|
||||
// Initialize tool call if not exists
|
||||
if (!this.toolCallsIndex[index]) {
|
||||
const toolCall = {
|
||||
const toolCall: ToolCall = {
|
||||
index: index,
|
||||
id: '',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: '',
|
||||
arguments: ''
|
||||
}
|
||||
arguments: '',
|
||||
},
|
||||
};
|
||||
this.toolCalls.push(toolCall);
|
||||
this.toolCallsIndex[index] = toolCall;
|
||||
@@ -203,18 +280,18 @@ class DeltaAccumulator {
|
||||
|
||||
/**
|
||||
* Get all tool calls
|
||||
* @returns {Array} Tool calls array
|
||||
* @returns Tool calls array
|
||||
*/
|
||||
getToolCalls() {
|
||||
getToolCalls(): ToolCall[] {
|
||||
return this.toolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for planning loop pattern
|
||||
* Loop = N consecutive thinking blocks with no tool calls
|
||||
* @returns {boolean} True if loop detected
|
||||
* @returns True if loop detected
|
||||
*/
|
||||
checkForLoop() {
|
||||
checkForLoop(): boolean {
|
||||
// Already detected loop
|
||||
if (this.loopDetected) {
|
||||
return true;
|
||||
@@ -229,7 +306,7 @@ class DeltaAccumulator {
|
||||
const recentBlocks = this.contentBlocks.slice(-this.loopDetectionThreshold);
|
||||
|
||||
// Check if all recent blocks are thinking blocks
|
||||
const allThinking = recentBlocks.every(b => b.type === 'thinking');
|
||||
const allThinking = recentBlocks.every((b) => b.type === 'thinking');
|
||||
|
||||
// Check if no tool calls have been made at all
|
||||
const noToolCalls = this.toolCalls.length === 0;
|
||||
@@ -246,15 +323,15 @@ class DeltaAccumulator {
|
||||
/**
|
||||
* Reset loop detection state (for testing)
|
||||
*/
|
||||
resetLoopDetection() {
|
||||
resetLoopDetection(): void {
|
||||
this.loopDetected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of accumulated state
|
||||
* @returns {Object} Summary
|
||||
* @returns Summary
|
||||
*/
|
||||
getSummary() {
|
||||
getSummary(): AccumulatorSummary {
|
||||
return {
|
||||
messageId: this.messageId,
|
||||
model: this.model,
|
||||
@@ -267,10 +344,141 @@ class DeltaAccumulator {
|
||||
loopDetected: this.loopDetected,
|
||||
usage: {
|
||||
input_tokens: this.inputTokens,
|
||||
output_tokens: this.outputTokens
|
||||
}
|
||||
output_tokens: this.outputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeltaAccumulator;
|
||||
// ========== State Getters ==========
|
||||
|
||||
/**
|
||||
* Check if message has been finalized
|
||||
*/
|
||||
isFinalized(): boolean {
|
||||
return this.finalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message has started
|
||||
*/
|
||||
isMessageStarted(): boolean {
|
||||
return this.messageStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message ID
|
||||
*/
|
||||
getMessageId(): string {
|
||||
return this.messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model name
|
||||
*/
|
||||
getModel(): string | null {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role
|
||||
*/
|
||||
getRole(): string {
|
||||
return this.role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get input tokens
|
||||
*/
|
||||
getInputTokens(): number {
|
||||
return this.inputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output tokens
|
||||
*/
|
||||
getOutputTokens(): number {
|
||||
return this.outputTokens;
|
||||
}
|
||||
|
||||
// ========== State Setters ==========
|
||||
|
||||
/**
|
||||
* Set model name
|
||||
*/
|
||||
setModel(model: string): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set message started flag
|
||||
*/
|
||||
setMessageStarted(started: boolean): void {
|
||||
this.messageStarted = started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set role
|
||||
*/
|
||||
setRole(role: string): void {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set finalized flag
|
||||
*/
|
||||
setFinalized(finalized: boolean): void {
|
||||
this.finalized = finalized;
|
||||
}
|
||||
|
||||
// ========== Finish Reason ==========
|
||||
|
||||
private finishReason: string | null = null;
|
||||
private usageReceived: boolean = false;
|
||||
|
||||
/**
|
||||
* Set finish reason
|
||||
*/
|
||||
setFinishReason(reason: string): void {
|
||||
this.finishReason = reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get finish reason
|
||||
*/
|
||||
getFinishReason(): string | null {
|
||||
return this.finishReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if usage stats have been received
|
||||
*/
|
||||
hasUsageReceived(): boolean {
|
||||
return this.usageReceived;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark usage as received
|
||||
*/
|
||||
setUsageReceived(received: boolean): void {
|
||||
this.usageReceived = received;
|
||||
}
|
||||
|
||||
// ========== Tool Call Helpers ==========
|
||||
|
||||
/**
|
||||
* Check if there are any tool calls, or check if a specific index exists
|
||||
*/
|
||||
hasToolCall(index?: number): boolean {
|
||||
if (index === undefined) {
|
||||
return this.toolCalls.length > 0;
|
||||
}
|
||||
return this.toolCallsIndex[index] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool call by index
|
||||
*/
|
||||
getToolCall(index: number): ToolCall | undefined {
|
||||
return this.toolCallsIndex[index];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const GlmtTransformer = require('./glmt-transformer');
|
||||
const SSEParser = require('./sse-parser');
|
||||
const DeltaAccumulator = require('./delta-accumulator');
|
||||
|
||||
/**
|
||||
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
|
||||
*
|
||||
@@ -24,19 +15,80 @@ const DeltaAccumulator = require('./delta-accumulator');
|
||||
* Debugging:
|
||||
* - Verbose: Pass --verbose to see request/response logs
|
||||
* - Debug: Set CCS_DEBUG=1 to write logs to ~/.ccs/logs/
|
||||
*
|
||||
* Usage:
|
||||
* const proxy = new GlmtProxy({ verbose: true });
|
||||
* await proxy.start();
|
||||
*/
|
||||
class GlmtProxy {
|
||||
constructor(config = {}) {
|
||||
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { GlmtTransformer } from './glmt-transformer';
|
||||
import { SSEParser } from './sse-parser';
|
||||
import { DeltaAccumulator } from './delta-accumulator';
|
||||
|
||||
interface GlmtProxyConfig {
|
||||
verbose?: boolean;
|
||||
debugLog?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface ThinkingConfig {
|
||||
thinking: boolean;
|
||||
effort: string;
|
||||
}
|
||||
|
||||
interface OpenAIRequest {
|
||||
model: string;
|
||||
messages: unknown[];
|
||||
max_tokens?: number;
|
||||
stream?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AnthropicRequest {
|
||||
model: string;
|
||||
messages: unknown[];
|
||||
max_tokens?: number;
|
||||
stream?: boolean;
|
||||
thinking?: {
|
||||
type: 'enabled' | 'disabled';
|
||||
budget_tokens?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface OpenAIResponse {
|
||||
id?: string;
|
||||
model?: string;
|
||||
choices?: Array<{
|
||||
message: {
|
||||
role: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: unknown[];
|
||||
};
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class GlmtProxy {
|
||||
private transformer: GlmtTransformer;
|
||||
private upstreamUrl: string;
|
||||
private server: http.Server | null;
|
||||
private port: number | null;
|
||||
private verbose: boolean;
|
||||
private timeout: number;
|
||||
|
||||
constructor(config: GlmtProxyConfig = {}) {
|
||||
this.transformer = new GlmtTransformer({
|
||||
verbose: config.verbose,
|
||||
debugLog: config.debugLog || process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG_LOG === '1'
|
||||
debugLog:
|
||||
config.debugLog || process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG_LOG === '1',
|
||||
});
|
||||
// Use ANTHROPIC_BASE_URL from environment (set by settings.json) or fallback to Z.AI default
|
||||
this.upstreamUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions';
|
||||
this.upstreamUrl =
|
||||
process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions';
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
this.verbose = config.verbose || false;
|
||||
@@ -45,9 +97,8 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Start HTTP server on random port
|
||||
* @returns {Promise<number>} Port number
|
||||
*/
|
||||
async start() {
|
||||
async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res);
|
||||
@@ -55,18 +106,23 @@ class GlmtProxy {
|
||||
|
||||
// Bind to 127.0.0.1:0 (random port for security + avoid conflicts)
|
||||
this.server.listen(0, '127.0.0.1', () => {
|
||||
this.port = this.server.address().port;
|
||||
const address = this.server?.address();
|
||||
this.port = typeof address === 'object' && address ? address.port : 0;
|
||||
// Signal parent process
|
||||
console.log(`PROXY_READY:${this.port}`);
|
||||
|
||||
// Info message (only show in verbose mode)
|
||||
if (this.verbose) {
|
||||
console.error(`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`);
|
||||
console.error(
|
||||
`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`
|
||||
);
|
||||
}
|
||||
|
||||
// Debug mode notice
|
||||
if (this.transformer.debugLog) {
|
||||
console.error(`[glmt] Debug logging enabled: ${this.transformer.debugLogDir}`);
|
||||
if ((this.transformer as unknown as { debugLog: boolean }).debugLog) {
|
||||
console.error(
|
||||
`[glmt] Debug logging enabled: ${(this.transformer as unknown as { debugLogDir: string }).debugLogDir}`
|
||||
);
|
||||
console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
|
||||
}
|
||||
|
||||
@@ -83,10 +139,8 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP request
|
||||
* @param {http.IncomingMessage} req - Request
|
||||
* @param {http.ServerResponse} res - Response
|
||||
*/
|
||||
async handleRequest(req, res) {
|
||||
async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
this.log(`Request: ${req.method} ${req.url}`);
|
||||
|
||||
@@ -99,27 +153,32 @@ class GlmtProxy {
|
||||
}
|
||||
|
||||
// Read request body
|
||||
const body = await this._readBody(req);
|
||||
const body = await this.readBody(req);
|
||||
this.log(`Request body size: ${body.length} bytes`);
|
||||
|
||||
// Parse JSON with error handling
|
||||
let anthropicRequest;
|
||||
let anthropicRequest: AnthropicRequest;
|
||||
try {
|
||||
anthropicRequest = JSON.parse(body);
|
||||
} catch (jsonError) {
|
||||
const err = jsonError as Error;
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: 'Invalid JSON in request body: ' + jsonError.message
|
||||
}
|
||||
}));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: 'Invalid JSON in request body: ' + err.message,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log thinking parameter for debugging
|
||||
if (anthropicRequest.thinking) {
|
||||
this.log(`Request contains thinking parameter: ${JSON.stringify(anthropicRequest.thinking)}`);
|
||||
this.log(
|
||||
`Request contains thinking parameter: ${JSON.stringify(anthropicRequest.thinking)}`
|
||||
);
|
||||
} else {
|
||||
this.log(`Request does NOT contain thinking parameter (will use message tags or default)`);
|
||||
}
|
||||
@@ -129,64 +188,72 @@ class GlmtProxy {
|
||||
|
||||
if (useStreaming) {
|
||||
try {
|
||||
await this._handleStreamingRequest(req, res, anthropicRequest, startTime);
|
||||
await this.handleStreamingRequest(req, res, anthropicRequest, startTime);
|
||||
} catch (streamError) {
|
||||
this.log(`Streaming failed: ${streamError.message}, retrying buffered mode`);
|
||||
const err = streamError as Error;
|
||||
this.log(`Streaming failed: ${err.message}, retrying buffered mode`);
|
||||
try {
|
||||
await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
|
||||
await this.handleBufferedRequest(req, res, anthropicRequest, startTime);
|
||||
} catch (bufferedError) {
|
||||
// Both modes failed, propagate error
|
||||
throw bufferedError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
|
||||
await this.handleBufferedRequest(req, res, anthropicRequest, startTime);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[glmt-proxy] Request error:', error.message);
|
||||
const err = error as Error;
|
||||
console.error('[glmt-proxy] Request error:', err.message);
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Request failed after ${duration}ms: ${error.message}`);
|
||||
this.log(`Request failed after ${duration}ms: ${err.message}`);
|
||||
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
type: 'proxy_error',
|
||||
message: error.message
|
||||
}
|
||||
}));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'proxy_error',
|
||||
message: err.message,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle buffered (non-streaming) request
|
||||
* @private
|
||||
*/
|
||||
async _handleBufferedRequest(req, res, anthropicRequest, startTime) {
|
||||
private async handleBufferedRequest(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
anthropicRequest: AnthropicRequest,
|
||||
startTime: number
|
||||
): Promise<void> {
|
||||
// Transform to OpenAI format
|
||||
const { openaiRequest, thinkingConfig } =
|
||||
this.transformer.transformRequest(anthropicRequest);
|
||||
const { openaiRequest, thinkingConfig } = this.transformer.transformRequest(
|
||||
anthropicRequest as unknown as Parameters<typeof this.transformer.transformRequest>[0]
|
||||
);
|
||||
|
||||
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
|
||||
|
||||
// Forward to Z.AI
|
||||
const openaiResponse = await this._forwardToUpstream(
|
||||
openaiRequest,
|
||||
req.headers
|
||||
);
|
||||
const openaiResponse = (await this.forwardToUpstream(
|
||||
openaiRequest as unknown as OpenAIRequest,
|
||||
{}
|
||||
)) as OpenAIResponse;
|
||||
|
||||
this.log(`Received response from upstream`);
|
||||
|
||||
// Transform back to Anthropic format
|
||||
const anthropicResponse = this.transformer.transformResponse(
|
||||
openaiResponse,
|
||||
openaiResponse as Parameters<typeof this.transformer.transformResponse>[0],
|
||||
thinkingConfig
|
||||
);
|
||||
|
||||
// Return to Claude CLI
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.end(JSON.stringify(anthropicResponse));
|
||||
|
||||
@@ -196,25 +263,30 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Handle streaming request
|
||||
* @private
|
||||
*/
|
||||
async _handleStreamingRequest(req, res, anthropicRequest, startTime) {
|
||||
private async handleStreamingRequest(
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
anthropicRequest: AnthropicRequest,
|
||||
startTime: number
|
||||
): Promise<void> {
|
||||
this.log('Using streaming mode');
|
||||
|
||||
// Transform request
|
||||
const { openaiRequest, thinkingConfig } =
|
||||
this.transformer.transformRequest(anthropicRequest);
|
||||
const { openaiRequest, thinkingConfig } = this.transformer.transformRequest(
|
||||
anthropicRequest as unknown as Parameters<typeof this.transformer.transformRequest>[0]
|
||||
);
|
||||
|
||||
// Force streaming
|
||||
openaiRequest.stream = true;
|
||||
(openaiRequest as OpenAIRequest).stream = true;
|
||||
|
||||
// Set SSE headers
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Accel-Buffering': 'no' // Disable proxy buffering
|
||||
'X-Accel-Buffering': 'no', // Disable proxy buffering
|
||||
});
|
||||
|
||||
// Disable Nagle's algorithm to prevent buffering at socket level
|
||||
@@ -225,9 +297,9 @@ class GlmtProxy {
|
||||
this.log('Starting SSE stream to Claude CLI (socket buffering disabled)');
|
||||
|
||||
// Forward and stream
|
||||
await this._forwardAndStreamUpstream(
|
||||
openaiRequest,
|
||||
req.headers,
|
||||
await this.forwardAndStreamUpstream(
|
||||
openaiRequest as unknown as OpenAIRequest,
|
||||
{},
|
||||
res,
|
||||
thinkingConfig,
|
||||
startTime
|
||||
@@ -236,17 +308,14 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Read request body
|
||||
* @param {http.IncomingMessage} req - Request
|
||||
* @returns {Promise<string>} Body content
|
||||
* @private
|
||||
*/
|
||||
_readBody(req) {
|
||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const chunks: Buffer[] = [];
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB limit
|
||||
let totalSize = 0;
|
||||
|
||||
req.on('data', chunk => {
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > maxSize) {
|
||||
reject(new Error('Request body too large (max 10MB)'));
|
||||
@@ -262,17 +331,16 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Forward request to Z.AI upstream
|
||||
* @param {Object} openaiRequest - OpenAI format request
|
||||
* @param {Object} originalHeaders - Original request headers
|
||||
* @returns {Promise<Object>} OpenAI response
|
||||
* @private
|
||||
*/
|
||||
_forwardToUpstream(openaiRequest, originalHeaders) {
|
||||
private forwardToUpstream(
|
||||
openaiRequest: OpenAIRequest,
|
||||
_originalHeaders: Record<string, string | undefined>
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.upstreamUrl);
|
||||
const requestBody = JSON.stringify(openaiRequest);
|
||||
|
||||
const options = {
|
||||
const options: https.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
||||
@@ -280,10 +348,9 @@ class GlmtProxy {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
// Preserve auth header from original request
|
||||
'Authorization': originalHeaders['authorization'] || '',
|
||||
'User-Agent': 'CCS-GLMT-Proxy/1.0'
|
||||
}
|
||||
Authorization: process.env.ANTHROPIC_AUTH_TOKEN || '',
|
||||
'User-Agent': 'CCS-GLMT-Proxy/1.0',
|
||||
},
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
@@ -298,8 +365,8 @@ class GlmtProxy {
|
||||
const req = https.request(options, (res) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
@@ -308,16 +375,15 @@ class GlmtProxy {
|
||||
|
||||
// Check for non-200 status
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(
|
||||
`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`
|
||||
));
|
||||
reject(new Error(`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = JSON.parse(body);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(new Error('Invalid JSON from upstream: ' + error.message));
|
||||
const err = error as Error;
|
||||
reject(new Error('Invalid JSON from upstream: ' + err.message));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -334,20 +400,19 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Forward request to Z.AI and stream response
|
||||
* @param {Object} openaiRequest - OpenAI format request
|
||||
* @param {Object} originalHeaders - Original request headers
|
||||
* @param {http.ServerResponse} clientRes - Response to Claude CLI
|
||||
* @param {Object} thinkingConfig - Thinking configuration
|
||||
* @param {number} startTime - Request start time
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _forwardAndStreamUpstream(openaiRequest, originalHeaders, clientRes, thinkingConfig, startTime) {
|
||||
private async forwardAndStreamUpstream(
|
||||
openaiRequest: OpenAIRequest,
|
||||
_originalHeaders: Record<string, string | undefined>,
|
||||
clientRes: http.ServerResponse,
|
||||
thinkingConfig: ThinkingConfig,
|
||||
startTime: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.upstreamUrl);
|
||||
const requestBody = JSON.stringify(openaiRequest);
|
||||
|
||||
const options = {
|
||||
const options: https.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
||||
@@ -355,15 +420,15 @@ class GlmtProxy {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'Authorization': originalHeaders['authorization'] || '',
|
||||
Authorization: process.env.ANTHROPIC_AUTH_TOKEN || '',
|
||||
'User-Agent': 'CCS-GLMT-Proxy/1.0',
|
||||
'Accept': 'text/event-stream'
|
||||
}
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
};
|
||||
|
||||
this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`);
|
||||
|
||||
// C-03 Fix: Apply timeout to streaming requests
|
||||
// Apply timeout to streaming requests
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
req.destroy();
|
||||
reject(new Error(`Streaming request timeout after ${this.timeout}ms`));
|
||||
@@ -373,7 +438,7 @@ class GlmtProxy {
|
||||
clearTimeout(timeoutHandle);
|
||||
if (upstreamRes.statusCode !== 200) {
|
||||
let body = '';
|
||||
upstreamRes.on('data', chunk => body += chunk);
|
||||
upstreamRes.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||||
upstreamRes.on('end', () => {
|
||||
reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`));
|
||||
});
|
||||
@@ -381,30 +446,34 @@ class GlmtProxy {
|
||||
}
|
||||
|
||||
const parser = new SSEParser();
|
||||
const accumulator = new DeltaAccumulator(thinkingConfig);
|
||||
const accumulator = new DeltaAccumulator(
|
||||
thinkingConfig as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
upstreamRes.on('data', (chunk: Buffer) => {
|
||||
try {
|
||||
const events = parser.parse(chunk);
|
||||
|
||||
events.forEach(event => {
|
||||
events.forEach((event) => {
|
||||
// Transform OpenAI delta → Anthropic events
|
||||
const anthropicEvents = this.transformer.transformDelta(event, accumulator);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anthropicEvents = this.transformer.transformDelta(event as any, accumulator);
|
||||
|
||||
// Forward to Claude CLI with immediate flush
|
||||
anthropicEvents.forEach(evt => {
|
||||
anthropicEvents.forEach((evt) => {
|
||||
const eventLine = `event: ${evt.event}\n`;
|
||||
const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`;
|
||||
clientRes.write(eventLine + dataLine);
|
||||
|
||||
// Flush immediately if method available (HTTP/2 or custom servers)
|
||||
if (typeof clientRes.flush === 'function') {
|
||||
clientRes.flush();
|
||||
// Flush immediately if method available
|
||||
if (typeof (clientRes as unknown as { flush?: () => void }).flush === 'function') {
|
||||
(clientRes as unknown as { flush: () => void }).flush();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(`Error processing chunk: ${error.message}`);
|
||||
const err = error as Error;
|
||||
this.log(`Error processing chunk: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -442,7 +511,7 @@ class GlmtProxy {
|
||||
/**
|
||||
* Stop proxy server
|
||||
*/
|
||||
stop() {
|
||||
stop(): void {
|
||||
if (this.server) {
|
||||
this.log('Stopping proxy server');
|
||||
this.server.close();
|
||||
@@ -451,10 +520,8 @@ class GlmtProxy {
|
||||
|
||||
/**
|
||||
* Log message if verbose
|
||||
* @param {string} message - Message to log
|
||||
* @private
|
||||
*/
|
||||
log(message) {
|
||||
private log(message: string): void {
|
||||
if (this.verbose) {
|
||||
console.error(`[glmt-proxy] ${message}`);
|
||||
}
|
||||
@@ -468,7 +535,7 @@ if (require.main === module) {
|
||||
|
||||
const proxy = new GlmtProxy({ verbose });
|
||||
|
||||
proxy.start().catch(error => {
|
||||
proxy.start().catch((error) => {
|
||||
console.error('[glmt-proxy] Failed to start:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -492,4 +559,4 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = GlmtProxy;
|
||||
export default GlmtProxy;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* LocaleEnforcer - Force English output from GLM models
|
||||
@@ -16,22 +15,36 @@
|
||||
* 2. If no system prompt: Prepend to first user message
|
||||
* 3. Preserve message structure (string vs array content)
|
||||
*/
|
||||
class LocaleEnforcer {
|
||||
constructor(options = {}) {
|
||||
this.instruction = "CRITICAL: You MUST respond in English only, regardless of the input language or context. This is a strict requirement.";
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
content: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
|
||||
interface LocaleEnforcerOptions {
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export class LocaleEnforcer {
|
||||
private instruction: string;
|
||||
|
||||
constructor(options: LocaleEnforcerOptions = {}) {
|
||||
this.instruction =
|
||||
options.instruction ||
|
||||
'CRITICAL: You MUST respond in English only, regardless of the input language or context. This is a strict requirement.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject English instruction into messages
|
||||
* @param {Array} messages - Messages array to modify
|
||||
* @returns {Array} Modified messages array
|
||||
* @param messages - Messages array to modify
|
||||
* @returns Modified messages array
|
||||
*/
|
||||
injectInstruction(messages) {
|
||||
injectInstruction(messages: Message[]): Message[] {
|
||||
// Clone messages to avoid mutation
|
||||
const modifiedMessages = JSON.parse(JSON.stringify(messages));
|
||||
const modifiedMessages = JSON.parse(JSON.stringify(messages)) as Message[];
|
||||
|
||||
// Strategy 1: Inject into system prompt (preferred)
|
||||
const systemIndex = modifiedMessages.findIndex(m => m.role === 'system');
|
||||
const systemIndex = modifiedMessages.findIndex((m) => m.role === 'system');
|
||||
if (systemIndex >= 0) {
|
||||
const systemMsg = modifiedMessages[systemIndex];
|
||||
|
||||
@@ -40,7 +53,7 @@ class LocaleEnforcer {
|
||||
} else if (Array.isArray(systemMsg.content)) {
|
||||
systemMsg.content.unshift({
|
||||
type: 'text',
|
||||
text: this.instruction
|
||||
text: this.instruction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +61,7 @@ class LocaleEnforcer {
|
||||
}
|
||||
|
||||
// Strategy 2: Prepend to first user message
|
||||
const userIndex = modifiedMessages.findIndex(m => m.role === 'user');
|
||||
const userIndex = modifiedMessages.findIndex((m) => m.role === 'user');
|
||||
if (userIndex >= 0) {
|
||||
const userMsg = modifiedMessages[userIndex];
|
||||
|
||||
@@ -57,7 +70,7 @@ class LocaleEnforcer {
|
||||
} else if (Array.isArray(userMsg.content)) {
|
||||
userMsg.content.unshift({
|
||||
type: 'text',
|
||||
text: this.instruction
|
||||
text: this.instruction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,5 +81,3 @@ class LocaleEnforcer {
|
||||
return modifiedMessages;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocaleEnforcer;
|
||||
@@ -1,16 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ReasoningEnforcer - Inject explicit reasoning instructions into prompts
|
||||
*
|
||||
* Purpose: Force GLM models to use structured reasoning output format (<reasoning_content>)
|
||||
* This complements API parameters (reasoning: true) with explicit prompt instructions.
|
||||
*
|
||||
* Usage:
|
||||
* const enforcer = new ReasoningEnforcer({ enabled: true });
|
||||
* const modifiedMessages = enforcer.injectInstruction(messages, thinkingConfig);
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If system prompt exists: Prepend reasoning instruction
|
||||
* 2. If no system prompt: Prepend to first user message
|
||||
@@ -18,32 +11,59 @@
|
||||
* 4. Preserve message structure (string vs array content)
|
||||
*/
|
||||
|
||||
class ReasoningEnforcer {
|
||||
constructor(options = {}) {
|
||||
type EffortLevel = 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
interface ThinkingConfig {
|
||||
thinking?: boolean;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
interface ReasoningEnforcerOptions {
|
||||
enabled?: boolean;
|
||||
prompts?: Record<EffortLevel, string>;
|
||||
}
|
||||
|
||||
export class ReasoningEnforcer {
|
||||
private enabled: boolean;
|
||||
private prompts: Record<EffortLevel, string>;
|
||||
|
||||
constructor(options: ReasoningEnforcerOptions = {}) {
|
||||
this.enabled = options.enabled ?? false; // Opt-in by default
|
||||
this.prompts = options.prompts || this._getDefaultPrompts();
|
||||
this.prompts = options.prompts || this.getDefaultPrompts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject reasoning instruction into messages
|
||||
* @param {Array} messages - Messages array to modify
|
||||
* @param {Object} thinkingConfig - { thinking: boolean, effort: string }
|
||||
* @returns {Array} Modified messages array
|
||||
* @param messages - Messages array to modify
|
||||
* @param thinkingConfig - { thinking: boolean, effort: string }
|
||||
* @returns Modified messages array
|
||||
*/
|
||||
injectInstruction(messages, thinkingConfig = {}) {
|
||||
injectInstruction(messages: Message[], thinkingConfig: ThinkingConfig = {}): Message[] {
|
||||
// Only inject if enabled or thinking explicitly requested
|
||||
if (!this.enabled && !thinkingConfig.thinking) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Clone messages to avoid mutation
|
||||
const modifiedMessages = JSON.parse(JSON.stringify(messages));
|
||||
const modifiedMessages: Message[] = JSON.parse(JSON.stringify(messages));
|
||||
|
||||
// Select prompt based on effort level
|
||||
const prompt = this._selectPrompt(thinkingConfig.effort || 'medium');
|
||||
const effort = (thinkingConfig.effort?.toLowerCase() || 'medium') as EffortLevel;
|
||||
const prompt = this.selectPrompt(effort);
|
||||
|
||||
// Strategy 1: Inject into system prompt (preferred)
|
||||
const systemIndex = modifiedMessages.findIndex(m => m.role === 'system');
|
||||
const systemIndex = modifiedMessages.findIndex((m) => m.role === 'system');
|
||||
if (systemIndex >= 0) {
|
||||
const systemMsg = modifiedMessages[systemIndex];
|
||||
|
||||
@@ -52,7 +72,7 @@ class ReasoningEnforcer {
|
||||
} else if (Array.isArray(systemMsg.content)) {
|
||||
systemMsg.content.unshift({
|
||||
type: 'text',
|
||||
text: prompt
|
||||
text: prompt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +80,7 @@ class ReasoningEnforcer {
|
||||
}
|
||||
|
||||
// Strategy 2: Prepend to first user message
|
||||
const userIndex = modifiedMessages.findIndex(m => m.role === 'user');
|
||||
const userIndex = modifiedMessages.findIndex((m) => m.role === 'user');
|
||||
if (userIndex >= 0) {
|
||||
const userMsg = modifiedMessages[userIndex];
|
||||
|
||||
@@ -69,7 +89,7 @@ class ReasoningEnforcer {
|
||||
} else if (Array.isArray(userMsg.content)) {
|
||||
userMsg.content.unshift({
|
||||
type: 'text',
|
||||
text: prompt
|
||||
text: prompt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,21 +102,15 @@ class ReasoningEnforcer {
|
||||
|
||||
/**
|
||||
* Select prompt template based on effort level
|
||||
* @param {string} effort - 'low', 'medium', 'high', or 'max'
|
||||
* @returns {string} Prompt template
|
||||
* @private
|
||||
*/
|
||||
_selectPrompt(effort) {
|
||||
const normalizedEffort = effort.toLowerCase();
|
||||
return this.prompts[normalizedEffort] || this.prompts.medium;
|
||||
private selectPrompt(effort: EffortLevel): string {
|
||||
return this.prompts[effort] || this.prompts.medium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default prompt templates
|
||||
* @returns {Object} Map of effort levels to prompts
|
||||
* @private
|
||||
*/
|
||||
_getDefaultPrompts() {
|
||||
private getDefaultPrompts(): Record<EffortLevel, string> {
|
||||
return {
|
||||
low: `You are an expert reasoning model using GLM-4.6 architecture.
|
||||
|
||||
@@ -165,9 +179,9 @@ OUTPUT FORMAT:
|
||||
- Final conclusion with rigorous justification)
|
||||
</reasoning_content>
|
||||
|
||||
(Write your final answer here based on your exhaustive reasoning above)`
|
||||
(Write your final answer here based on your exhaustive reasoning above)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ReasoningEnforcer;
|
||||
export default ReasoningEnforcer;
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SSEParser - Parse Server-Sent Events (SSE) stream
|
||||
@@ -17,8 +16,25 @@
|
||||
* events.forEach(event => { ... });
|
||||
* });
|
||||
*/
|
||||
class SSEParser {
|
||||
constructor(options = {}) {
|
||||
|
||||
interface SSEParserOptions {
|
||||
maxBufferSize?: number;
|
||||
}
|
||||
|
||||
interface SSEEvent {
|
||||
event: string;
|
||||
data: unknown;
|
||||
index?: number;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export class SSEParser {
|
||||
private buffer: string;
|
||||
private eventCount: number;
|
||||
private maxBufferSize: number;
|
||||
|
||||
constructor(options: SSEParserOptions = {}) {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
|
||||
@@ -26,10 +42,10 @@ class SSEParser {
|
||||
|
||||
/**
|
||||
* Parse chunk and extract SSE events
|
||||
* @param {Buffer|string} chunk - Data chunk from stream
|
||||
* @returns {Array<Object>} Array of parsed events
|
||||
* @param chunk - Data chunk from stream
|
||||
* @returns Array of parsed events
|
||||
*/
|
||||
parse(chunk) {
|
||||
parse(chunk: Buffer | string): SSEEvent[] {
|
||||
this.buffer += chunk.toString();
|
||||
|
||||
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
|
||||
@@ -42,8 +58,8 @@ class SSEParser {
|
||||
// Keep incomplete line in buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const events = [];
|
||||
let currentEvent = { event: 'message', data: '' };
|
||||
const events: SSEEvent[] = [];
|
||||
let currentEvent: SSEEvent = { event: 'message', data: '' };
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
@@ -56,7 +72,7 @@ class SSEParser {
|
||||
events.push({
|
||||
event: 'done',
|
||||
data: null,
|
||||
index: this.eventCount
|
||||
index: this.eventCount,
|
||||
});
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} else {
|
||||
@@ -64,12 +80,17 @@ class SSEParser {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push(currentEvent);
|
||||
events.push({ ...currentEvent });
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} catch (e) {
|
||||
// H-01 Fix: Log parse errors for debugging
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error('[SSEParser] Malformed JSON event:', e.message, 'Data:', data.substring(0, 100));
|
||||
console.error(
|
||||
'[SSEParser] Malformed JSON event:',
|
||||
(e as Error).message,
|
||||
'Data:',
|
||||
data.substring(0, 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,10 +108,8 @@ class SSEParser {
|
||||
/**
|
||||
* Reset parser state (for reuse)
|
||||
*/
|
||||
reset() {
|
||||
reset(): void {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SSEParser;
|
||||
@@ -1,47 +1,92 @@
|
||||
'use strict';
|
||||
/**
|
||||
* CCS Health Check and Diagnostics
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
const { colored } = require('../utils/helpers');
|
||||
const { detectClaudeCli } = require('../utils/claude-detector');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { colored } from '../utils/helpers';
|
||||
import { detectClaudeCli } from '../utils/claude-detector';
|
||||
import packageJson from '../../package.json';
|
||||
import {
|
||||
isCLIProxyInstalled,
|
||||
getCLIProxyPath,
|
||||
isPortAvailable,
|
||||
getAllAuthStatus,
|
||||
getConfigPath,
|
||||
CLIPROXY_VERSION,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../cliproxy';
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
// ora v9+ is an ES module, need to use .default for CommonJS
|
||||
let ora = null;
|
||||
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) {
|
||||
} catch (_e) {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
ora = function(text) {
|
||||
ora = function (text: string): Spinner {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg) => console.log(msg || `[i] ${text}`),
|
||||
text: ''
|
||||
})
|
||||
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: '',
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Import cli-table3
|
||||
const Table = require('cli-table3');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check results
|
||||
*/
|
||||
class HealthCheck {
|
||||
constructor() {
|
||||
this.checks = [];
|
||||
this.warnings = [];
|
||||
this.errors = [];
|
||||
this.details = {}; // Store detailed information for summary table
|
||||
}
|
||||
public checks: HealthCheckItem[] = [];
|
||||
public warnings: HealthIssue[] = [];
|
||||
public errors: HealthIssue[] = [];
|
||||
public details: Record<string, HealthCheckDetails> = {};
|
||||
|
||||
addCheck(name, status, message = '', fix = null, details = null) {
|
||||
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 });
|
||||
@@ -53,41 +98,41 @@ class HealthCheck {
|
||||
}
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
hasErrors(): boolean {
|
||||
return this.errors.length > 0;
|
||||
}
|
||||
|
||||
hasWarnings() {
|
||||
hasWarnings(): boolean {
|
||||
return this.warnings.length > 0;
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
isHealthy(): boolean {
|
||||
return !this.hasErrors();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CCS Health Check and Diagnostics
|
||||
* Doctor Class
|
||||
*/
|
||||
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();
|
||||
|
||||
// Get CCS version
|
||||
try {
|
||||
this.ccsVersion = require('../../package.json').version;
|
||||
} catch (e) {
|
||||
this.ccsVersion = 'unknown';
|
||||
}
|
||||
this.ccsVersion = packageJson.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all health checks
|
||||
*/
|
||||
async runAllChecks() {
|
||||
async runAllChecks(): Promise<HealthCheck> {
|
||||
console.log(colored('Running CCS Health Check...', 'cyan'));
|
||||
console.log('');
|
||||
|
||||
@@ -120,6 +165,11 @@ class Doctor {
|
||||
this.checkSettingsSymlinks();
|
||||
console.log('');
|
||||
|
||||
// Group 5: CLIProxy (OAuth profiles)
|
||||
console.log(colored('CLIProxy (OAuth Profiles):', 'bold'));
|
||||
await this.checkCLIProxy();
|
||||
console.log('');
|
||||
|
||||
this.showReport();
|
||||
return this.results;
|
||||
}
|
||||
@@ -127,24 +177,36 @@ class Doctor {
|
||||
/**
|
||||
* Check 1: Claude CLI availability
|
||||
*/
|
||||
async checkClaudeCli() {
|
||||
private async checkClaudeCli(): Promise<void> {
|
||||
const spinner = ora('Checking Claude CLI').start();
|
||||
|
||||
const claudeCli = detectClaudeCli();
|
||||
|
||||
if (!claudeCli) {
|
||||
spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} 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;
|
||||
}
|
||||
|
||||
// Try to execute claude --version
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(claudeCli, ['--version'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
let output = '';
|
||||
child.stdout.on('data', data => output += data);
|
||||
child.stderr.on('data', data => output += data);
|
||||
child.stdout?.on('data', (data: Buffer) => (output += data));
|
||||
child.stderr?.on('data', (data: Buffer) => (output += data));
|
||||
|
||||
child.on('close', code => {
|
||||
child.on('close', (code: number | null) => {
|
||||
if (code === 0) resolve(output);
|
||||
else reject(new Error('Exit code ' + code));
|
||||
});
|
||||
@@ -156,13 +218,17 @@ class Doctor {
|
||||
const versionMatch = result.match(/(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
spinner.succeed(` ${'Claude CLI'.padEnd(26)}${colored('[OK]', 'green')} ${claudeCli} (v${version})`);
|
||||
this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, null, {
|
||||
spinner.succeed(
|
||||
` ${'Claude CLI'.padEnd(26)}${colored('[OK]', 'green')} ${claudeCli} (v${version})`
|
||||
);
|
||||
this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, {
|
||||
status: 'OK',
|
||||
info: `v${version} (${claudeCli})`
|
||||
info: `v${version} (${claudeCli})`,
|
||||
});
|
||||
} catch (err) {
|
||||
spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working`);
|
||||
} catch (_err) {
|
||||
spinner.fail(
|
||||
` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Claude CLI',
|
||||
'error',
|
||||
@@ -176,14 +242,14 @@ class Doctor {
|
||||
/**
|
||||
* Check 2: ~/.ccs/ directory
|
||||
*/
|
||||
checkCcsDirectory() {
|
||||
private checkCcsDirectory(): void {
|
||||
const spinner = ora('Checking ~/.ccs/ directory').start();
|
||||
|
||||
if (fs.existsSync(this.ccsDir)) {
|
||||
spinner.succeed(` ${'CCS Directory'.padEnd(26)}${colored('[OK]', 'green')} ~/.ccs/`);
|
||||
this.results.addCheck('CCS Directory', 'success', null, null, {
|
||||
this.results.addCheck('CCS Directory', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: '~/.ccs/'
|
||||
info: '~/.ccs/',
|
||||
});
|
||||
} else {
|
||||
spinner.fail(` ${'CCS Directory'.padEnd(26)}${colored('[X]', 'red')} Not found`);
|
||||
@@ -200,11 +266,21 @@ class Doctor {
|
||||
/**
|
||||
* Check 3: Config files
|
||||
*/
|
||||
checkConfigFiles() {
|
||||
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' }
|
||||
{
|
||||
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');
|
||||
@@ -227,11 +303,11 @@ class Doctor {
|
||||
// Validate JSON
|
||||
try {
|
||||
const content = fs.readFileSync(file.path, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
JSON.parse(content);
|
||||
|
||||
// Extract useful info based on file type
|
||||
let info = 'Valid';
|
||||
let status = 'OK';
|
||||
let status: 'OK' | 'WARN' = 'OK';
|
||||
|
||||
if (file.profile) {
|
||||
// For settings files, check if API key is configured
|
||||
@@ -257,16 +333,22 @@ class Doctor {
|
||||
spinner.succeed(` ${file.name.padEnd(26)}${statusIcon} ${info}`);
|
||||
}
|
||||
|
||||
this.results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', null, null, {
|
||||
status: status,
|
||||
info: info
|
||||
});
|
||||
this.results.addCheck(
|
||||
file.name,
|
||||
status === 'OK' ? 'success' : 'warning',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: status,
|
||||
info: info,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Invalid JSON`);
|
||||
this.results.addCheck(
|
||||
file.name,
|
||||
'error',
|
||||
`Invalid JSON: ${e.message}`,
|
||||
`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' }
|
||||
);
|
||||
@@ -277,12 +359,14 @@ class Doctor {
|
||||
/**
|
||||
* Check 4: Claude settings
|
||||
*/
|
||||
checkClaudeSettings() {
|
||||
private checkClaudeSettings(): void {
|
||||
const spinner = ora('Checking ~/.claude/settings.json').start();
|
||||
const settingsPath = path.join(this.claudeDir, 'settings.json');
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found`);
|
||||
spinner.warn(
|
||||
` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Claude Settings',
|
||||
'warning',
|
||||
@@ -299,11 +383,13 @@ class Doctor {
|
||||
spinner.succeed(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[OK]', 'green')}`);
|
||||
this.results.addCheck('Claude Settings', 'success');
|
||||
} catch (e) {
|
||||
spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON`);
|
||||
spinner.warn(
|
||||
` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Claude Settings',
|
||||
'warning',
|
||||
`Invalid JSON: ${e.message}`,
|
||||
`Invalid JSON: ${(e as Error).message}`,
|
||||
'Run: claude /login'
|
||||
);
|
||||
}
|
||||
@@ -312,7 +398,7 @@ class Doctor {
|
||||
/**
|
||||
* Check 5: Profile configurations
|
||||
*/
|
||||
checkProfiles() {
|
||||
private checkProfiles(): void {
|
||||
const spinner = ora('Checking profiles').start();
|
||||
const configPath = path.join(this.ccsDir, 'config.json');
|
||||
|
||||
@@ -339,16 +425,24 @@ class Doctor {
|
||||
const profileCount = Object.keys(config.profiles).length;
|
||||
const profileNames = Object.keys(config.profiles).join(', ');
|
||||
|
||||
spinner.succeed(` ${'Profiles'.padEnd(26)}${colored('[OK]', 'green')} ${profileCount} configured (${profileNames})`);
|
||||
this.results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, null, {
|
||||
status: 'OK',
|
||||
info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`
|
||||
});
|
||||
spinner.succeed(
|
||||
` ${'Profiles'.padEnd(26)}${colored('[OK]', 'green')} ${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(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${e.message}`);
|
||||
this.results.addCheck('Profiles', 'error', e.message, null, {
|
||||
spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${(e as Error).message}`);
|
||||
this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
|
||||
status: 'ERROR',
|
||||
info: e.message
|
||||
info: (e as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -356,7 +450,7 @@ class Doctor {
|
||||
/**
|
||||
* Check 6: Instance directories (account-based profiles)
|
||||
*/
|
||||
checkInstances() {
|
||||
private checkInstances(): void {
|
||||
const spinner = ora('Checking instances').start();
|
||||
const instancesDir = path.join(this.ccsDir, 'instances');
|
||||
|
||||
@@ -366,7 +460,7 @@ class Doctor {
|
||||
return;
|
||||
}
|
||||
|
||||
const instances = fs.readdirSync(instancesDir).filter(name => {
|
||||
const instances = fs.readdirSync(instancesDir).filter((name) => {
|
||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
||||
});
|
||||
|
||||
@@ -376,14 +470,16 @@ class Doctor {
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.succeed(` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles`);
|
||||
spinner.succeed(
|
||||
` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles`
|
||||
);
|
||||
this.results.addCheck('Instances', 'success', `${instances.length} account profiles`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: Delegation system
|
||||
*/
|
||||
checkDelegation() {
|
||||
private checkDelegation(): void {
|
||||
const spinner = ora('Checking delegation').start();
|
||||
|
||||
// Check if delegation commands exist in ~/.ccs/.claude/commands/
|
||||
@@ -405,7 +501,7 @@ class Doctor {
|
||||
|
||||
// Check profile validity using DelegationValidator
|
||||
const { DelegationValidator } = require('../utils/delegation-validator');
|
||||
const readyProfiles = [];
|
||||
const readyProfiles: string[] = [];
|
||||
|
||||
for (const profile of ['glm', 'kimi']) {
|
||||
const validation = DelegationValidator.validate(profile);
|
||||
@@ -426,12 +522,14 @@ class Doctor {
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.succeed(` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`);
|
||||
spinner.succeed(
|
||||
` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Delegation',
|
||||
'success',
|
||||
`${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`,
|
||||
null,
|
||||
undefined,
|
||||
{ status: 'OK', info: `${readyProfiles.length} profiles ready` }
|
||||
);
|
||||
}
|
||||
@@ -439,20 +537,24 @@ class Doctor {
|
||||
/**
|
||||
* Check 8: File permissions
|
||||
*/
|
||||
checkPermissions() {
|
||||
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(` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified`);
|
||||
this.results.addCheck('Permissions', 'success', null, null, {
|
||||
spinner.succeed(
|
||||
` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified`
|
||||
);
|
||||
this.results.addCheck('Permissions', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'Write access verified'
|
||||
info: 'Write access verified',
|
||||
});
|
||||
} catch (e) {
|
||||
spinner.fail(` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/`);
|
||||
} catch (_e) {
|
||||
spinner.fail(
|
||||
` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Permissions',
|
||||
'error',
|
||||
@@ -466,23 +568,33 @@ class Doctor {
|
||||
/**
|
||||
* Check 9: CCS symlinks to ~/.claude/
|
||||
*/
|
||||
checkCcsSymlinks() {
|
||||
private checkCcsSymlinks(): void {
|
||||
const spinner = ora('Checking CCS symlinks').start();
|
||||
|
||||
try {
|
||||
const ClaudeSymlinkManager = require('../utils/claude-symlink-manager');
|
||||
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(` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${itemCount}/${itemCount} items linked`);
|
||||
this.results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', null, {
|
||||
status: 'OK',
|
||||
info: `${itemCount}/${itemCount} items synced`
|
||||
});
|
||||
spinner.succeed(
|
||||
` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${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(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found`);
|
||||
spinner.warn(
|
||||
` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'CCS Symlinks',
|
||||
'warning',
|
||||
@@ -496,7 +608,7 @@ class Doctor {
|
||||
this.results.addCheck(
|
||||
'CCS Symlinks',
|
||||
'warning',
|
||||
'Could not check CCS symlinks: ' + e.message,
|
||||
'Could not check CCS symlinks: ' + (e as Error).message,
|
||||
'Run: ccs sync',
|
||||
{ status: 'WARN', info: 'Could not check' }
|
||||
);
|
||||
@@ -506,7 +618,7 @@ class Doctor {
|
||||
/**
|
||||
* Check 10: settings.json symlinks
|
||||
*/
|
||||
checkSettingsSymlinks() {
|
||||
private checkSettingsSymlinks(): void {
|
||||
const spinner = ora('Checking settings.json symlinks').start();
|
||||
|
||||
try {
|
||||
@@ -516,7 +628,9 @@ class Doctor {
|
||||
|
||||
// Check shared settings exists and points to ~/.claude/
|
||||
if (!fs.existsSync(sharedSettings)) {
|
||||
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`);
|
||||
spinner.warn(
|
||||
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Settings Symlinks',
|
||||
'warning',
|
||||
@@ -528,7 +642,9 @@ class Doctor {
|
||||
|
||||
const sharedStats = fs.lstatSync(sharedSettings);
|
||||
if (!sharedStats.isSymbolicLink()) {
|
||||
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`);
|
||||
spinner.warn(
|
||||
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Settings Symlinks',
|
||||
'warning',
|
||||
@@ -542,7 +658,9 @@ class Doctor {
|
||||
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
|
||||
|
||||
if (resolvedShared !== claudeSettings) {
|
||||
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`);
|
||||
spinner.warn(
|
||||
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Settings Symlinks',
|
||||
'warning',
|
||||
@@ -555,15 +673,17 @@ class Doctor {
|
||||
// Check each instance
|
||||
const instancesDir = path.join(this.ccsDir, 'instances');
|
||||
if (!fs.existsSync(instancesDir)) {
|
||||
spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid`);
|
||||
this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', null, {
|
||||
spinner.succeed(
|
||||
` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid`
|
||||
);
|
||||
this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, {
|
||||
status: 'OK',
|
||||
info: 'Shared symlink valid'
|
||||
info: 'Shared symlink valid',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const instances = fs.readdirSync(instancesDir).filter(name => {
|
||||
const instances = fs.readdirSync(instancesDir).filter((name) => {
|
||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
||||
});
|
||||
|
||||
@@ -590,13 +710,15 @@ class Doctor {
|
||||
if (resolved !== sharedSettings) {
|
||||
broken++;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
|
||||
if (broken > 0) {
|
||||
spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`);
|
||||
spinner.warn(
|
||||
` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'Settings Symlinks',
|
||||
'warning',
|
||||
@@ -605,29 +727,142 @@ class Doctor {
|
||||
{ status: 'WARN', info: `${broken} broken instance(s)` }
|
||||
);
|
||||
} else {
|
||||
spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid`);
|
||||
this.results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', null, {
|
||||
status: 'OK',
|
||||
info: `${instances.length} instance(s) valid`
|
||||
});
|
||||
spinner.succeed(
|
||||
` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${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(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`);
|
||||
this.results.addCheck(
|
||||
'Settings Symlinks',
|
||||
'warning',
|
||||
`Failed to check: ${err.message}`,
|
||||
`Failed to check: ${(err as Error).message}`,
|
||||
'Run: ccs sync',
|
||||
{ status: 'WARN', info: 'Check failed' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy)
|
||||
*/
|
||||
private async checkCLIProxy(): Promise<void> {
|
||||
// 1. Binary installed?
|
||||
const binarySpinner = ora('Checking CLIProxy binary').start();
|
||||
|
||||
if (isCLIProxyInstalled()) {
|
||||
const binaryPath = getCLIProxyPath();
|
||||
binarySpinner.succeed(
|
||||
` ${'CLIProxy Binary'.padEnd(26)}${colored('[OK]', 'green')} v${CLIPROXY_VERSION}`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `v${CLIPROXY_VERSION} (${binaryPath})`,
|
||||
});
|
||||
} else {
|
||||
binarySpinner.info(
|
||||
` ${'CLIProxy Binary'.padEnd(26)}${colored('[i]', 'cyan')} 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?
|
||||
const configSpinner = ora('Checking CLIProxy config').start();
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
configSpinner.succeed(
|
||||
` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy/config.yaml`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: 'cliproxy/config.yaml',
|
||||
});
|
||||
} else {
|
||||
configSpinner.info(
|
||||
` ${'CLIProxy Config'.padEnd(26)}${colored('[i]', 'cyan')} Not created (generated 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(
|
||||
` ${`${providerName} Auth`.padEnd(26)}${colored('[OK]', 'green')} Authenticated${lastAuth}`
|
||||
);
|
||||
this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `Authenticated${lastAuth}`,
|
||||
});
|
||||
} else {
|
||||
authSpinner.info(
|
||||
` ${`${providerName} Auth`.padEnd(26)}${colored('[i]', 'cyan')} Not authenticated`
|
||||
);
|
||||
this.results.addCheck(
|
||||
`${providerName} Auth`,
|
||||
'success',
|
||||
'Not authenticated',
|
||||
`Run: ccs ${status.provider} --auth`,
|
||||
{ status: 'OK', info: 'Not authenticated (run ccs <profile> to login)' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Port availability
|
||||
const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start();
|
||||
const portAvailable = await isPortAvailable(CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
if (portAvailable) {
|
||||
portSpinner.succeed(
|
||||
` ${'CLIProxy Port'.padEnd(26)}${colored('[OK]', 'green')} ${CLIPROXY_DEFAULT_PORT} available`
|
||||
);
|
||||
this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
|
||||
status: 'OK',
|
||||
info: `Port ${CLIPROXY_DEFAULT_PORT} available`,
|
||||
});
|
||||
} else {
|
||||
portSpinner.warn(
|
||||
` ${'CLIProxy Port'.padEnd(26)}${colored('[!]', 'yellow')} ${CLIPROXY_DEFAULT_PORT} in use`
|
||||
);
|
||||
this.results.addCheck(
|
||||
'CLIProxy Port',
|
||||
'warning',
|
||||
`Port ${CLIPROXY_DEFAULT_PORT} is in use`,
|
||||
`Check: lsof -i :${CLIPROXY_DEFAULT_PORT}`,
|
||||
{ status: 'WARN', info: `Port ${CLIPROXY_DEFAULT_PORT} in use` }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show health check report
|
||||
*/
|
||||
showReport() {
|
||||
private showReport(): void {
|
||||
console.log('');
|
||||
|
||||
// Calculate exact table width to match header bars
|
||||
@@ -645,21 +880,29 @@ class Doctor {
|
||||
colWidths: [20, 10, 35],
|
||||
wordWrap: true,
|
||||
chars: {
|
||||
'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: '│',
|
||||
},
|
||||
});
|
||||
|
||||
// Populate table with collected details
|
||||
for (const [component, detail] of Object.entries(this.results.details)) {
|
||||
const statusColor = detail.status === 'OK' ? 'green' : detail.status === 'ERROR' ? 'red' : 'yellow';
|
||||
table.push([
|
||||
component,
|
||||
colored(detail.status, statusColor),
|
||||
detail.info || ''
|
||||
]);
|
||||
const statusColor =
|
||||
detail.status === 'OK' ? 'green' : detail.status === 'ERROR' ? 'red' : 'yellow';
|
||||
table.push([component, colored(detail.status, statusColor), detail.info || '']);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
@@ -668,7 +911,7 @@ class Doctor {
|
||||
// Show errors and warnings if present
|
||||
if (this.results.hasErrors()) {
|
||||
console.log(colored('Errors:', 'red'));
|
||||
this.results.errors.forEach(err => {
|
||||
this.results.errors.forEach((err) => {
|
||||
console.log(` [X] ${err.name}: ${err.message}`);
|
||||
if (err.fix) {
|
||||
console.log(` Fix: ${err.fix}`);
|
||||
@@ -679,7 +922,7 @@ class Doctor {
|
||||
|
||||
if (this.results.hasWarnings()) {
|
||||
console.log(colored('Warnings:', 'yellow'));
|
||||
this.results.warnings.forEach(warn => {
|
||||
this.results.warnings.forEach((warn) => {
|
||||
console.log(` [!] ${warn.name}: ${warn.message}`);
|
||||
if (warn.fix) {
|
||||
console.log(` Fix: ${warn.fix}`);
|
||||
@@ -704,18 +947,29 @@ class Doctor {
|
||||
/**
|
||||
* Generate JSON report
|
||||
*/
|
||||
generateJsonReport() {
|
||||
return JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
ccsVersion: require('../package.json').version,
|
||||
checks: this.results.checks,
|
||||
errors: this.results.errors,
|
||||
warnings: this.results.warnings,
|
||||
healthy: this.results.isHealthy()
|
||||
}, null, 2);
|
||||
generateJsonReport(): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
ccsVersion: packageJson.version,
|
||||
checks: this.results.checks,
|
||||
errors: this.results.errors,
|
||||
warnings: this.results.warnings,
|
||||
healthy: this.results.isHealthy(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the health check results are healthy
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.results.isHealthy();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Doctor;
|
||||
export default Doctor;
|
||||
@@ -1,10 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const SharedManager = require('./shared-manager');
|
||||
|
||||
/**
|
||||
* Instance Manager (Simplified)
|
||||
*
|
||||
@@ -12,7 +5,19 @@ const SharedManager = require('./shared-manager');
|
||||
* Each instance is an isolated CLAUDE_CONFIG_DIR where users login directly.
|
||||
* No credential copying/encryption - Claude manages credentials per instance.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import SharedManager from './shared-manager';
|
||||
|
||||
/**
|
||||
* Instance Manager Class
|
||||
*/
|
||||
class InstanceManager {
|
||||
private readonly instancesDir: string;
|
||||
private readonly sharedManager: SharedManager;
|
||||
|
||||
constructor() {
|
||||
this.instancesDir = path.join(os.homedir(), '.ccs', 'instances');
|
||||
this.sharedManager = new SharedManager();
|
||||
@@ -20,10 +25,8 @@ class InstanceManager {
|
||||
|
||||
/**
|
||||
* Ensure instance exists for profile (lazy init only)
|
||||
* @param {string} profileName - Profile name
|
||||
* @returns {string} Instance path
|
||||
*/
|
||||
ensureInstance(profileName) {
|
||||
ensureInstance(profileName: string): string {
|
||||
const instancePath = this.getInstancePath(profileName);
|
||||
|
||||
// Lazy initialization
|
||||
@@ -39,21 +42,16 @@ class InstanceManager {
|
||||
|
||||
/**
|
||||
* Get instance path for profile
|
||||
* @param {string} profileName - Profile name
|
||||
* @returns {string} Instance directory path
|
||||
*/
|
||||
getInstancePath(profileName) {
|
||||
const safeName = this._sanitizeName(profileName);
|
||||
getInstancePath(profileName: string): string {
|
||||
const safeName = this.sanitizeName(profileName);
|
||||
return path.join(this.instancesDir, safeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize new instance directory
|
||||
* @param {string} profileName - Profile name
|
||||
* @param {string} instancePath - Instance directory path
|
||||
* @throws {Error} If initialization fails
|
||||
*/
|
||||
initializeInstance(profileName, instancePath) {
|
||||
private initializeInstance(profileName: string, instancePath: string): void {
|
||||
try {
|
||||
// Create base directory
|
||||
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
|
||||
@@ -66,10 +64,10 @@ class InstanceManager {
|
||||
'file-history',
|
||||
'shell-snapshots',
|
||||
'debug',
|
||||
'.anthropic'
|
||||
'.anthropic',
|
||||
];
|
||||
|
||||
subdirs.forEach(dir => {
|
||||
subdirs.forEach((dir) => {
|
||||
const dirPath = path.join(instancePath, dir);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
@@ -80,17 +78,18 @@ class InstanceManager {
|
||||
this.sharedManager.linkSharedDirectories(instancePath);
|
||||
|
||||
// Copy global configs if exist (settings.json only)
|
||||
this._copyGlobalConfigs(instancePath);
|
||||
this.copyGlobalConfigs(instancePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize instance for ${profileName}: ${error.message}`);
|
||||
throw new Error(
|
||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate instance directory structure (auto-fix missing directories)
|
||||
* @param {string} instancePath - Instance path
|
||||
*/
|
||||
validateInstance(instancePath) {
|
||||
private validateInstance(instancePath: string): void {
|
||||
// Check required directories (auto-create if missing for migration)
|
||||
const requiredDirs = [
|
||||
'session-env',
|
||||
@@ -99,7 +98,7 @@ class InstanceManager {
|
||||
'file-history',
|
||||
'shell-snapshots',
|
||||
'debug',
|
||||
'.anthropic'
|
||||
'.anthropic',
|
||||
];
|
||||
|
||||
for (const dir of requiredDirs) {
|
||||
@@ -115,9 +114,8 @@ class InstanceManager {
|
||||
|
||||
/**
|
||||
* Delete instance for profile
|
||||
* @param {string} profileName - Profile name
|
||||
*/
|
||||
deleteInstance(profileName) {
|
||||
deleteInstance(profileName: string): void {
|
||||
const instancePath = this.getInstancePath(profileName);
|
||||
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
@@ -130,46 +128,40 @@ class InstanceManager {
|
||||
|
||||
/**
|
||||
* List all instance names
|
||||
* @returns {Array<string>} Instance names
|
||||
*/
|
||||
listInstances() {
|
||||
listInstances(): string[] {
|
||||
if (!fs.existsSync(this.instancesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(this.instancesDir)
|
||||
.filter(name => {
|
||||
const instancePath = path.join(this.instancesDir, name);
|
||||
return fs.statSync(instancePath).isDirectory();
|
||||
});
|
||||
return fs.readdirSync(this.instancesDir).filter((name) => {
|
||||
const instancePath = path.join(this.instancesDir, name);
|
||||
return fs.statSync(instancePath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if instance exists for profile
|
||||
* @param {string} profileName - Profile name
|
||||
* @returns {boolean} True if exists
|
||||
*/
|
||||
hasInstance(profileName) {
|
||||
hasInstance(profileName: string): boolean {
|
||||
const instancePath = this.getInstancePath(profileName);
|
||||
return fs.existsSync(instancePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy global configs to instance (optional)
|
||||
* @param {string} instancePath - Instance path
|
||||
*/
|
||||
_copyGlobalConfigs(instancePath) {
|
||||
private copyGlobalConfigs(_instancePath: string): void {
|
||||
// No longer needed - settings.json now symlinked via SharedManager
|
||||
// Keeping method for backward compatibility (empty implementation)
|
||||
// Can be removed in future major version
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy directory recursively
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* Copy directory recursively - Currently unused
|
||||
*/
|
||||
_copyDirectory(src, dest) {
|
||||
/*
|
||||
private copyDirectory(src: string, dest: string): void {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
@@ -181,22 +173,22 @@ class InstanceManager {
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
this._copyDirectory(srcPath, destPath);
|
||||
this.copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitize profile name for filesystem
|
||||
* @param {string} name - Profile name
|
||||
* @returns {string} Safe name
|
||||
*/
|
||||
_sanitizeName(name) {
|
||||
private sanitizeName(name: string): string {
|
||||
// Replace unsafe characters with dash
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = InstanceManager;
|
||||
export { InstanceManager };
|
||||
export default InstanceManager;
|
||||
@@ -1,13 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
* Auto-recovery for missing or corrupted configuration
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* Recovery Manager Class
|
||||
*/
|
||||
class RecoveryManager {
|
||||
private readonly homedir: string;
|
||||
private readonly ccsDir: string;
|
||||
private readonly claudeDir: string;
|
||||
private recovered: string[];
|
||||
|
||||
constructor() {
|
||||
this.homedir = os.homedir();
|
||||
this.ccsDir = path.join(this.homedir, '.ccs');
|
||||
@@ -18,7 +25,7 @@ class RecoveryManager {
|
||||
/**
|
||||
* Ensure ~/.ccs/ directory exists
|
||||
*/
|
||||
ensureCcsDirectory() {
|
||||
ensureCcsDirectory(): boolean {
|
||||
if (!fs.existsSync(this.ccsDir)) {
|
||||
fs.mkdirSync(this.ccsDir, { recursive: true, mode: 0o755 });
|
||||
this.recovered.push('Created ~/.ccs/ directory');
|
||||
@@ -30,7 +37,7 @@ class RecoveryManager {
|
||||
/**
|
||||
* Ensure ~/.ccs/config.json exists with defaults
|
||||
*/
|
||||
ensureConfigJson() {
|
||||
ensureConfigJson(): boolean {
|
||||
const configPath = path.join(this.ccsDir, 'config.json');
|
||||
|
||||
// Check if exists and valid
|
||||
@@ -39,7 +46,7 @@ class RecoveryManager {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
JSON.parse(content); // Validate JSON
|
||||
return false; // No recovery needed
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Corrupted - backup and recreate
|
||||
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||||
fs.renameSync(configPath, backupPath);
|
||||
@@ -52,8 +59,8 @@ class RecoveryManager {
|
||||
profiles: {
|
||||
glm: '~/.ccs/glm.settings.json',
|
||||
kimi: '~/.ccs/kimi.settings.json',
|
||||
default: '~/.claude/settings.json'
|
||||
}
|
||||
default: '~/.claude/settings.json',
|
||||
},
|
||||
};
|
||||
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
@@ -67,7 +74,7 @@ class RecoveryManager {
|
||||
/**
|
||||
* Ensure ~/.claude/settings.json exists
|
||||
*/
|
||||
ensureClaudeSettings() {
|
||||
ensureClaudeSettings(): boolean {
|
||||
const claudeSettingsPath = path.join(this.claudeDir, 'settings.json');
|
||||
|
||||
// Create ~/.claude/ if missing
|
||||
@@ -91,9 +98,8 @@ class RecoveryManager {
|
||||
|
||||
/**
|
||||
* Run all recovery operations
|
||||
* @returns {boolean} True if any recovery performed
|
||||
*/
|
||||
recoverAll() {
|
||||
recoverAll(): boolean {
|
||||
this.recovered = [];
|
||||
|
||||
this.ensureCcsDirectory();
|
||||
@@ -105,24 +111,23 @@ class RecoveryManager {
|
||||
|
||||
/**
|
||||
* Get recovery summary
|
||||
* @returns {string[]} List of recovery actions performed
|
||||
*/
|
||||
getRecoverySummary() {
|
||||
getRecoverySummary(): string[] {
|
||||
return this.recovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show recovery hints
|
||||
*/
|
||||
showRecoveryHints() {
|
||||
showRecoveryHints(): void {
|
||||
if (this.recovered.length === 0) return;
|
||||
|
||||
console.log('');
|
||||
console.log('[i] Auto-recovery completed:');
|
||||
this.recovered.forEach(msg => console.log(` - ${msg}`));
|
||||
this.recovered.forEach((msg) => console.log(` - ${msg}`));
|
||||
|
||||
// Show login hint if created Claude settings
|
||||
if (this.recovered.some(msg => msg.includes('settings.json'))) {
|
||||
if (this.recovered.some((msg) => msg.includes('settings.json'))) {
|
||||
console.log('');
|
||||
console.log('[i] Next step: Login to Claude CLI');
|
||||
console.log(' Run: claude /login');
|
||||
@@ -132,4 +137,4 @@ class RecoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RecoveryManager;
|
||||
export default RecoveryManager;
|
||||
@@ -1,9 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
* SharedManager - Manages symlinked shared directories for CCS
|
||||
* v3.2.0: Symlink-based architecture
|
||||
@@ -11,7 +5,26 @@ const os = require('os');
|
||||
* Purpose: Eliminates duplication by symlinking:
|
||||
* ~/.claude/ ← ~/.ccs/shared/ ← instance/
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
interface SharedItem {
|
||||
name: string;
|
||||
type: 'directory' | 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedManager Class
|
||||
*/
|
||||
class SharedManager {
|
||||
private readonly homeDir: string;
|
||||
private readonly sharedDir: string;
|
||||
private readonly claudeDir: string;
|
||||
private readonly instancesDir: string;
|
||||
private readonly sharedItems: SharedItem[];
|
||||
|
||||
constructor() {
|
||||
this.homeDir = os.homedir();
|
||||
this.sharedDir = path.join(this.homeDir, '.ccs', 'shared');
|
||||
@@ -22,18 +35,14 @@ class SharedManager {
|
||||
{ name: 'skills', type: 'directory' },
|
||||
{ name: 'agents', type: 'directory' },
|
||||
{ name: 'plugins', type: 'directory' },
|
||||
{ name: 'settings.json', type: 'file' }
|
||||
{ name: 'settings.json', type: 'file' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect circular symlink before creation
|
||||
* @param {string} target - Target path to link to
|
||||
* @param {string} linkPath - Path where symlink will be created
|
||||
* @returns {boolean} True if circular
|
||||
* @private
|
||||
*/
|
||||
_detectCircularSymlink(target, linkPath) {
|
||||
private detectCircularSymlink(target: string, linkPath: string): boolean {
|
||||
// Check if target exists and is symlink
|
||||
if (!fs.existsSync(target)) {
|
||||
return false;
|
||||
@@ -55,7 +64,7 @@ class SharedManager {
|
||||
console.log(`[!] Circular symlink detected: ${target} → ${resolvedTarget}`);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// If can't read, assume not circular
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +76,7 @@ class SharedManager {
|
||||
* Ensure shared directories exist as symlinks to ~/.claude/
|
||||
* Creates ~/.claude/ structure if missing
|
||||
*/
|
||||
ensureSharedDirectories() {
|
||||
ensureSharedDirectories(): void {
|
||||
// Create ~/.claude/ if missing
|
||||
if (!fs.existsSync(this.claudeDir)) {
|
||||
console.log('[i] Creating ~/.claude/ directory structure');
|
||||
@@ -95,7 +104,7 @@ class SharedManager {
|
||||
}
|
||||
|
||||
// Check for circular symlink
|
||||
if (this._detectCircularSymlink(claudePath, sharedPath)) {
|
||||
if (this.detectCircularSymlink(claudePath, sharedPath)) {
|
||||
console.log(`[!] Skipping ${item.name}: circular symlink detected`);
|
||||
continue;
|
||||
}
|
||||
@@ -111,7 +120,7 @@ class SharedManager {
|
||||
continue; // Already correct
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue to recreate
|
||||
}
|
||||
|
||||
@@ -127,17 +136,19 @@ class SharedManager {
|
||||
try {
|
||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||
fs.symlinkSync(claudePath, sharedPath, symlinkType);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Windows fallback: copy
|
||||
if (process.platform === 'win32') {
|
||||
if (item.type === 'directory') {
|
||||
this._copyDirectoryFallback(claudePath, sharedPath);
|
||||
this.copyDirectoryFallback(claudePath, sharedPath);
|
||||
} else if (item.type === 'file') {
|
||||
fs.copyFileSync(claudePath, sharedPath);
|
||||
}
|
||||
console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`);
|
||||
console.log(
|
||||
`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
throw _err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,9 +156,8 @@ class SharedManager {
|
||||
|
||||
/**
|
||||
* Link shared directories to instance
|
||||
* @param {string} instancePath - Path to instance directory
|
||||
*/
|
||||
linkSharedDirectories(instancePath) {
|
||||
linkSharedDirectories(instancePath: string): void {
|
||||
this.ensureSharedDirectories();
|
||||
|
||||
for (const item of this.sharedItems) {
|
||||
@@ -167,17 +177,19 @@ class SharedManager {
|
||||
try {
|
||||
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
|
||||
fs.symlinkSync(targetPath, linkPath, symlinkType);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Windows fallback
|
||||
if (process.platform === 'win32') {
|
||||
if (item.type === 'directory') {
|
||||
this._copyDirectoryFallback(targetPath, linkPath);
|
||||
this.copyDirectoryFallback(targetPath, linkPath);
|
||||
} else if (item.type === 'file') {
|
||||
fs.copyFileSync(targetPath, linkPath);
|
||||
}
|
||||
console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`);
|
||||
console.log(
|
||||
`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
throw _err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +199,7 @@ class SharedManager {
|
||||
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
|
||||
* Runs once on upgrade
|
||||
*/
|
||||
migrateFromV311() {
|
||||
migrateFromV311(): void {
|
||||
// Check if migration already done (shared dirs are symlinks)
|
||||
const commandsPath = path.join(this.sharedDir, 'commands');
|
||||
if (fs.existsSync(commandsPath)) {
|
||||
@@ -195,7 +207,7 @@ class SharedManager {
|
||||
if (fs.lstatSync(commandsPath).isSymbolicLink()) {
|
||||
return; // Already migrated
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Continue with migration
|
||||
}
|
||||
}
|
||||
@@ -256,8 +268,8 @@ class SharedManager {
|
||||
console.log(`[OK] Migrated ${item.name} to ~/.claude/${item.name}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[!] Failed to migrate ${item.name}: ${err.message}`);
|
||||
} catch (_err) {
|
||||
console.log(`[!] Failed to migrate ${item.name}: ${(_err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,11 +287,11 @@ class SharedManager {
|
||||
if (fs.statSync(instancePath).isDirectory()) {
|
||||
this.linkSharedDirectories(instancePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[!] Failed to update instance ${instance}: ${err.message}`);
|
||||
} catch (_err) {
|
||||
console.log(`[!] Failed to update instance ${instance}: ${(_err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// No instances to update
|
||||
}
|
||||
}
|
||||
@@ -291,7 +303,7 @@ class SharedManager {
|
||||
* Migrate existing instances from isolated to shared settings.json (v4.4+)
|
||||
* Runs once on upgrade
|
||||
*/
|
||||
migrateToSharedSettings() {
|
||||
migrateToSharedSettings(): void {
|
||||
console.log('[i] Migrating instances to shared settings.json...');
|
||||
|
||||
// Ensure ~/.claude/settings.json exists (authoritative source)
|
||||
@@ -311,7 +323,7 @@ class SharedManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const instances = fs.readdirSync(this.instancesDir).filter(name => {
|
||||
const instances = fs.readdirSync(this.instancesDir).filter((name) => {
|
||||
const instancePath = path.join(this.instancesDir, name);
|
||||
return fs.statSync(instancePath).isDirectory();
|
||||
});
|
||||
@@ -349,19 +361,18 @@ class SharedManager {
|
||||
try {
|
||||
fs.symlinkSync(sharedSettings, instanceSettings, 'file');
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Windows fallback
|
||||
if (process.platform === 'win32') {
|
||||
fs.copyFileSync(sharedSettings, instanceSettings);
|
||||
console.log(`[!] Symlink failed for ${instance}, copied instead`);
|
||||
migrated++;
|
||||
} else {
|
||||
throw err;
|
||||
throw _err;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.log(`[!] Failed to migrate ${instance}: ${err.message}`);
|
||||
} catch (_err) {
|
||||
console.log(`[!] Failed to migrate ${instance}: ${(_err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,11 +381,8 @@ class SharedManager {
|
||||
|
||||
/**
|
||||
* Copy directory as fallback (Windows without Developer Mode)
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* @private
|
||||
*/
|
||||
_copyDirectoryFallback(src, dest) {
|
||||
private copyDirectoryFallback(src: string, dest: string): void {
|
||||
if (!fs.existsSync(src)) {
|
||||
fs.mkdirSync(src, { recursive: true, mode: 0o700 });
|
||||
return;
|
||||
@@ -391,7 +399,7 @@ class SharedManager {
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
this._copyDirectoryFallback(srcPath, destPath);
|
||||
this.copyDirectoryFallback(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
@@ -399,4 +407,4 @@ class SharedManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SharedManager;
|
||||
export default SharedManager;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SpawnOptions as NodeSpawnOptions } from 'child_process';
|
||||
|
||||
/**
|
||||
* CLI Runtime Types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parsed CLI arguments
|
||||
*/
|
||||
export interface ParsedArgs {
|
||||
profile?: string; // Profile name (glm, kimi, work, etc.)
|
||||
prompt?: string; // -p/--prompt flag value
|
||||
isDelegation: boolean; // -p flag present
|
||||
isContinue: boolean; // :continue suffix detected
|
||||
remainingArgs: string[]; // Args to pass to Claude CLI
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn options for Claude CLI execution
|
||||
*/
|
||||
export interface ClaudeSpawnOptions extends NodeSpawnOptions {
|
||||
stdio: 'inherit' | 'pipe';
|
||||
windowsHide?: boolean;
|
||||
shell?: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform detection
|
||||
*/
|
||||
export type Platform = 'darwin' | 'linux' | 'win32';
|
||||
|
||||
/**
|
||||
* Claude CLI detection result
|
||||
*/
|
||||
export interface ClaudeCliInfo {
|
||||
path: string;
|
||||
version?: string;
|
||||
isWindows: boolean;
|
||||
needsShell: boolean; // .cmd/.bat/.ps1 files
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit codes
|
||||
*/
|
||||
export enum ExitCode {
|
||||
SUCCESS = 0,
|
||||
GENERIC_ERROR = 1,
|
||||
CLAUDE_NOT_FOUND = 127,
|
||||
CONFIG_ERROR = 2,
|
||||
DELEGATION_ERROR = 3,
|
||||
TIMEOUT = 124,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* CCS Configuration Types
|
||||
* Source: ~/.ccs/config.json
|
||||
*/
|
||||
|
||||
/**
|
||||
* Profile configuration mapping
|
||||
* Maps profile names to settings.json paths
|
||||
*/
|
||||
export interface ProfilesConfig {
|
||||
[profileName: string]: string; // Path to settings.json
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CCS configuration
|
||||
* Located at: ~/.ccs/config.json
|
||||
*/
|
||||
export interface Config {
|
||||
profiles: ProfilesConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variables (string-only constraint)
|
||||
* CRITICAL: All values MUST be strings (no booleans/objects)
|
||||
* Reason: PowerShell crashes on non-string values
|
||||
*/
|
||||
export type EnvValue = string;
|
||||
export type EnvVars = Record<string, EnvValue>;
|
||||
|
||||
/**
|
||||
* Claude CLI settings.json structure
|
||||
* Located at: ~/.claude/settings.json or profile-specific
|
||||
*/
|
||||
export interface Settings {
|
||||
env?: EnvVars;
|
||||
[key: string]: unknown; // Allow other settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile metadata (profiles.json)
|
||||
* Located at: ~/.ccs/profiles.json
|
||||
*/
|
||||
export interface ProfileMetadata {
|
||||
type?: string; // Profile type (e.g., 'account')
|
||||
created: string; // Creation time
|
||||
last_used?: string | null; // Last usage time
|
||||
}
|
||||
|
||||
export interface ProfilesRegistry {
|
||||
profiles: Record<string, ProfileMetadata>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guards
|
||||
*/
|
||||
export function isConfig(obj: unknown): obj is Config {
|
||||
return (
|
||||
typeof obj === 'object' && obj !== null && 'profiles' in obj && typeof obj.profiles === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
export function isSettings(obj: unknown): obj is Settings {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
if (!('env' in obj)) return true; // env is optional
|
||||
if (typeof obj.env !== 'object' || obj.env === null) return false;
|
||||
// Validate all env values are strings
|
||||
return Object.values(obj.env).every((v) => typeof v === 'string');
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Delegation System Types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session metadata for delegation tracking
|
||||
*/
|
||||
export interface SessionMetadata {
|
||||
id: string; // Unique session ID
|
||||
profile: string; // Target profile (glm, kimi)
|
||||
prompt: string; // Initial prompt
|
||||
workingDir: string; // CWD at execution time
|
||||
startTime: number; // Unix timestamp (ms)
|
||||
endTime?: number; // Unix timestamp (ms)
|
||||
exitCode?: number; // Process exit code
|
||||
duration?: number; // Execution duration (seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegation session (persisted)
|
||||
*/
|
||||
export interface DelegationSession {
|
||||
metadata: SessionMetadata;
|
||||
turns: number; // Conversation turns
|
||||
lastPrompt?: string; // Last user prompt (for :continue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions registry
|
||||
* Located at: ~/.ccs/delegation-sessions.json
|
||||
*/
|
||||
export interface DelegationSessionsRegistry {
|
||||
sessions: Record<string, DelegationSession>; // sessionId → session
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time output event
|
||||
*/
|
||||
export interface ToolEvent {
|
||||
type: 'tool';
|
||||
tool: string; // Tool name (Write, Edit, Bash, etc.)
|
||||
args: string; // Simplified args (file path, command, etc.)
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface OutputEvent {
|
||||
type: 'stdout' | 'stderr';
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type DelegationEvent = ToolEvent | OutputEvent;
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Type shims for incomplete external dependencies
|
||||
*/
|
||||
|
||||
declare module 'cli-table3' {
|
||||
interface TableOptions {
|
||||
head?: string[];
|
||||
colWidths?: number[];
|
||||
colAligns?: ('left' | 'center' | 'right')[];
|
||||
style?: {
|
||||
head?: string[];
|
||||
border?: string[];
|
||||
};
|
||||
chars?: Record<string, string>;
|
||||
}
|
||||
|
||||
class Table extends Array {
|
||||
constructor(options?: TableOptions);
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export = Table;
|
||||
}
|
||||
|
||||
// ora v9 has types but we want to ensure compatibility
|
||||
declare module 'ora' {
|
||||
interface Options {
|
||||
text?: string;
|
||||
color?: 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white';
|
||||
spinner?: string | object;
|
||||
stream?: NodeJS.WritableStream;
|
||||
}
|
||||
|
||||
interface Ora {
|
||||
start(text?: string): Ora;
|
||||
stop(): Ora;
|
||||
succeed(text?: string): Ora;
|
||||
fail(text?: string): Ora;
|
||||
warn(text?: string): Ora;
|
||||
info(text?: string): Ora;
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function ora(options?: string | Options): Ora;
|
||||
export = ora;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GLMT Proxy Types
|
||||
* For Anthropic ↔ OpenAI format transformation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Anthropic API types
|
||||
*/
|
||||
export interface AnthropicMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'thinking' | 'tool_use' | 'tool_result';
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface AnthropicRequest {
|
||||
model: string;
|
||||
messages: AnthropicMessage[];
|
||||
max_tokens: number;
|
||||
temperature?: number;
|
||||
tools?: AnthropicTool[];
|
||||
stream?: boolean;
|
||||
thinking?: {
|
||||
type: 'enabled' | 'disabled';
|
||||
budget_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnthropicTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API types (Z.AI endpoint)
|
||||
*/
|
||||
export interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
reasoning_content?: string; // Thinking blocks
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string; // JSON string
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIRequest {
|
||||
model: string;
|
||||
messages: OpenAIMessage[];
|
||||
max_completion_tokens?: number;
|
||||
temperature?: number;
|
||||
tools?: OpenAITool[];
|
||||
stream?: boolean;
|
||||
reasoning?: boolean; // Enable thinking mode
|
||||
}
|
||||
|
||||
export interface OpenAITool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE (Server-Sent Events) types
|
||||
*/
|
||||
export interface SSEEvent {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface DeltaChunk {
|
||||
reasoning_content?: string;
|
||||
content?: string;
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformation context
|
||||
*/
|
||||
export interface TransformationContext {
|
||||
verbose: boolean;
|
||||
debugLog: boolean;
|
||||
streaming: boolean;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* CCS Type Definitions
|
||||
* Single entry point for all types
|
||||
*/
|
||||
|
||||
// Config types
|
||||
export type {
|
||||
Config,
|
||||
ProfilesConfig,
|
||||
Settings,
|
||||
EnvVars,
|
||||
EnvValue,
|
||||
ProfileMetadata,
|
||||
ProfilesRegistry,
|
||||
} from './config';
|
||||
export { isConfig, isSettings } from './config';
|
||||
|
||||
// CLI types
|
||||
export type { ParsedArgs, ClaudeSpawnOptions, Platform, ClaudeCliInfo } from './cli';
|
||||
export { ExitCode } from './cli';
|
||||
|
||||
// Delegation types
|
||||
export type {
|
||||
SessionMetadata,
|
||||
DelegationSession,
|
||||
DelegationSessionsRegistry,
|
||||
ExecutionResult,
|
||||
ToolEvent,
|
||||
OutputEvent,
|
||||
DelegationEvent,
|
||||
} from './delegation';
|
||||
|
||||
// GLMT types
|
||||
export type {
|
||||
AnthropicMessage,
|
||||
ContentBlock,
|
||||
AnthropicRequest,
|
||||
AnthropicTool,
|
||||
OpenAIMessage,
|
||||
OpenAIToolCall,
|
||||
OpenAIRequest,
|
||||
OpenAITool,
|
||||
SSEEvent,
|
||||
DeltaChunk,
|
||||
TransformationContext,
|
||||
} from './glmt';
|
||||
|
||||
// Utility types
|
||||
export { ErrorCode, LogLevel } from './utils';
|
||||
export type { ColorName, TerminalInfo, Result } from './utils';
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Utility Types
|
||||
*/
|
||||
|
||||
// Re-export from error-codes for consistency
|
||||
export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Color codes (TTY-aware)
|
||||
*/
|
||||
export type ColorName =
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'bold'
|
||||
| 'cyanBold'
|
||||
| 'reset';
|
||||
|
||||
/**
|
||||
* Terminal capabilities
|
||||
*/
|
||||
export interface TerminalInfo {
|
||||
isTTY: boolean;
|
||||
supportsColor: boolean;
|
||||
noColorEnv: boolean; // NO_COLOR env var set
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper result types
|
||||
*/
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
import * as fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { expandPath } from './helpers';
|
||||
import { ClaudeCliInfo } from '../types';
|
||||
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { expandPath } = require('./helpers');
|
||||
|
||||
// Detect Claude CLI executable
|
||||
function detectClaudeCli() {
|
||||
/**
|
||||
* Detect Claude CLI executable
|
||||
*/
|
||||
export function detectClaudeCli(): string | null {
|
||||
// Priority 1: CCS_CLAUDE_PATH environment variable (if user wants custom path)
|
||||
if (process.env.CCS_CLAUDE_PATH) {
|
||||
const ccsPath = expandPath(process.env.CCS_CLAUDE_PATH);
|
||||
@@ -28,16 +29,19 @@ function detectClaudeCli() {
|
||||
const result = execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000 // 5 second timeout to prevent hangs
|
||||
timeout: 5000, // 5 second timeout to prevent hangs
|
||||
}).trim();
|
||||
|
||||
// where.exe may return multiple lines (all matches in PATH order)
|
||||
const matches = result.split('\n').map(p => p.trim()).filter(p => p);
|
||||
const matches = result
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, prefer executables with extensions (.exe, .cmd, .bat)
|
||||
// where.exe often returns file without extension first, then the actual .cmd wrapper
|
||||
const withExtension = matches.find(p => /\.(exe|cmd|bat|ps1)$/i.test(p));
|
||||
const withExtension = matches.find((p) => /\.(exe|cmd|bat|ps1)$/i.test(p));
|
||||
const claudePath = withExtension || matches[0];
|
||||
|
||||
if (claudePath && fs.existsSync(claudePath)) {
|
||||
@@ -51,7 +55,7 @@ function detectClaudeCli() {
|
||||
return claudePath;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// Command failed - claude not in PATH
|
||||
// Fall through to return null
|
||||
}
|
||||
@@ -60,14 +64,31 @@ function detectClaudeCli() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show Claude not found error
|
||||
function showClaudeNotFoundError() {
|
||||
/**
|
||||
* Get detailed Claude CLI information
|
||||
*/
|
||||
export function getClaudeCliInfo(): ClaudeCliInfo | null {
|
||||
const claudePath = detectClaudeCli();
|
||||
|
||||
if (!claudePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudePath);
|
||||
|
||||
return {
|
||||
path: claudePath,
|
||||
isWindows,
|
||||
needsShell,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Claude not found error
|
||||
*/
|
||||
export function showClaudeNotFoundError(): never {
|
||||
console.error('ERROR: Claude CLI not found in PATH');
|
||||
console.error('Install from: https://docs.claude.com/en/docs/claude-code/installation');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectClaudeCli,
|
||||
showClaudeNotFoundError
|
||||
};
|
||||
@@ -1,38 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
// ora v9+ is an ES module, need to use .default for CommonJS
|
||||
let ora = null;
|
||||
try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch (e) {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
ora = function(text) {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg) => console.log(msg || `[i] ${text}`),
|
||||
text: ''
|
||||
})
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const { colored } = require('./helpers');
|
||||
|
||||
/**
|
||||
* ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/
|
||||
* v4.1.1: Fix for npm install not copying .claude/ directory
|
||||
*/
|
||||
class ClaudeDirInstaller {
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { colored } from './helpers';
|
||||
|
||||
// Ora fallback type for when ora is not available
|
||||
interface OraSpinner {
|
||||
text: string;
|
||||
succeed: (msg?: string) => void;
|
||||
fail: (msg?: string) => void;
|
||||
warn: (msg?: string) => void;
|
||||
info: (msg?: string) => void;
|
||||
}
|
||||
|
||||
interface OraInstance {
|
||||
start: () => OraSpinner;
|
||||
}
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
let ora: ((text: string) => OraInstance) | null = null;
|
||||
try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
ora = function (text: string): OraInstance {
|
||||
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 ItemCount {
|
||||
files: number;
|
||||
dirs: number;
|
||||
}
|
||||
|
||||
interface CleanupResult {
|
||||
success: boolean;
|
||||
cleanedFiles: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/
|
||||
*/
|
||||
export class ClaudeDirInstaller {
|
||||
private homeDir: string;
|
||||
private ccsClaudeDir: string;
|
||||
|
||||
constructor() {
|
||||
this.homeDir = os.homedir();
|
||||
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
|
||||
@@ -40,11 +66,12 @@ class ClaudeDirInstaller {
|
||||
|
||||
/**
|
||||
* Copy .claude/ directory from package to ~/.ccs/.claude/
|
||||
* @param {string} packageDir - Package installation directory (default: auto-detect)
|
||||
* @param {boolean} silent - Suppress spinner output
|
||||
* @param packageDir - Package installation directory (default: auto-detect)
|
||||
* @param silent - Suppress spinner output
|
||||
*/
|
||||
install(packageDir, silent = false) {
|
||||
const spinner = (silent || !ora) ? null : ora('Copying .claude/ items to ~/.ccs/.claude/').start();
|
||||
install(packageDir?: string, silent = false): boolean {
|
||||
const spinner =
|
||||
silent || !ora ? null : ora('Copying .claude/ items to ~/.ccs/.claude/').start();
|
||||
|
||||
try {
|
||||
// Auto-detect package directory if not provided
|
||||
@@ -76,18 +103,17 @@ class ClaudeDirInstaller {
|
||||
}
|
||||
|
||||
// Use fs.cpSync for recursive copy (Node.js 16.7.0+)
|
||||
// Fallback to manual copy for older Node.js versions
|
||||
if (spinner) spinner.text = 'Copying .claude/ items...';
|
||||
|
||||
if (fs.cpSync) {
|
||||
fs.cpSync(packageClaudeDir, this.ccsClaudeDir, { recursive: true });
|
||||
} else {
|
||||
// Fallback for Node.js < 16.7.0
|
||||
this._copyDirRecursive(packageClaudeDir, this.ccsClaudeDir);
|
||||
this.copyDirRecursive(packageClaudeDir, this.ccsClaudeDir);
|
||||
}
|
||||
|
||||
// Count files and directories
|
||||
const itemCount = this._countItems(this.ccsClaudeDir);
|
||||
const itemCount = this.countItems(this.ccsClaudeDir);
|
||||
const msg = `Copied .claude/ items (${itemCount.files} files, ${itemCount.dirs} directories)`;
|
||||
|
||||
if (spinner) {
|
||||
@@ -97,7 +123,8 @@ class ClaudeDirInstaller {
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = `Failed to copy .claude/ directory: ${err.message}`;
|
||||
const error = err as Error;
|
||||
const msg = `Failed to copy .claude/ directory: ${error.message}`;
|
||||
if (spinner) {
|
||||
spinner.fail(colored('[!]', 'yellow') + ` ${msg}`);
|
||||
console.warn(' CCS items may not be available');
|
||||
@@ -111,11 +138,8 @@ class ClaudeDirInstaller {
|
||||
|
||||
/**
|
||||
* Recursively copy directory (fallback for Node.js < 16.7.0)
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* @private
|
||||
*/
|
||||
_copyDirRecursive(src, dest) {
|
||||
private copyDirRecursive(src: string, dest: string): void {
|
||||
// Create destination directory
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
@@ -130,7 +154,7 @@ class ClaudeDirInstaller {
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively copy subdirectory
|
||||
this._copyDirRecursive(srcPath, destPath);
|
||||
this.copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
// Copy file
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
@@ -140,15 +164,12 @@ class ClaudeDirInstaller {
|
||||
|
||||
/**
|
||||
* Count files and directories in a path
|
||||
* @param {string} dirPath - Directory to count
|
||||
* @returns {Object} { files: number, dirs: number }
|
||||
* @private
|
||||
*/
|
||||
_countItems(dirPath) {
|
||||
private countItems(dirPath: string): ItemCount {
|
||||
let files = 0;
|
||||
let dirs = 0;
|
||||
|
||||
const countRecursive = (p) => {
|
||||
const countRecursive = (p: string): void => {
|
||||
const entries = fs.readdirSync(p, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
@@ -162,7 +183,7 @@ class ClaudeDirInstaller {
|
||||
|
||||
try {
|
||||
countRecursive(dirPath);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
@@ -172,14 +193,18 @@ class ClaudeDirInstaller {
|
||||
/**
|
||||
* Clean up deprecated files from previous installations
|
||||
* Removes ccs-delegator.md that was deprecated in v4.3.2
|
||||
* @param {boolean} silent - Suppress console output
|
||||
*/
|
||||
cleanupDeprecated(silent = false) {
|
||||
cleanupDeprecated(silent = false): CleanupResult {
|
||||
const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md');
|
||||
const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md');
|
||||
const migrationMarker = path.join(this.homeDir, '.ccs', '.migrations', 'v435-delegator-cleanup');
|
||||
const migrationMarker = path.join(
|
||||
this.homeDir,
|
||||
'.ccs',
|
||||
'.migrations',
|
||||
'v435-delegator-cleanup'
|
||||
);
|
||||
|
||||
let cleanedFiles = [];
|
||||
const cleanedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if cleanup already done
|
||||
@@ -188,7 +213,6 @@ class ClaudeDirInstaller {
|
||||
}
|
||||
|
||||
// Clean up user symlink in ~/.claude/agents/ccs-delegator.md FIRST
|
||||
// This ensures we can detect broken symlinks before deleting the target
|
||||
try {
|
||||
const userStats = fs.lstatSync(userSymlinkFile);
|
||||
if (userStats.isSymbolicLink()) {
|
||||
@@ -203,9 +227,10 @@ class ClaudeDirInstaller {
|
||||
cleanedFiles.push('user file (backed up)');
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
// File doesn't exist or other error - that's okay
|
||||
if (err.code !== 'ENOENT' && !silent) {
|
||||
console.log(`[!] Failed to remove user symlink: ${err.message}`);
|
||||
if (error.code !== 'ENOENT' && !silent) {
|
||||
console.log(`[!] Failed to remove user symlink: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,19 +238,21 @@ class ClaudeDirInstaller {
|
||||
if (fs.existsSync(deprecatedFile)) {
|
||||
try {
|
||||
// Check if file was modified by user (compare with expected content)
|
||||
const shouldBackup = this._shouldBackupDeprecatedFile(deprecatedFile);
|
||||
const shouldBackup = this.shouldBackupDeprecatedFile(deprecatedFile);
|
||||
|
||||
if (shouldBackup) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const backupPath = `${deprecatedFile}.backup-${timestamp}`;
|
||||
fs.renameSync(deprecatedFile, backupPath);
|
||||
if (!silent) console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`);
|
||||
if (!silent)
|
||||
console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`);
|
||||
} else {
|
||||
fs.rmSync(deprecatedFile, { force: true });
|
||||
}
|
||||
cleanedFiles.push('package copy');
|
||||
} catch (err) {
|
||||
if (!silent) console.log(`[!] Failed to remove package copy: ${err.message}`);
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to remove package copy: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,18 +271,16 @@ class ClaudeDirInstaller {
|
||||
|
||||
return { success: true, cleanedFiles };
|
||||
} catch (err) {
|
||||
if (!silent) console.log(`[!] Cleanup failed: ${err.message}`);
|
||||
return { success: false, error: err.message, cleanedFiles };
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Cleanup failed: ${error.message}`);
|
||||
return { success: false, error: error.message, cleanedFiles };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deprecated file should be backed up (user modified)
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean} True if file should be backed up
|
||||
* @private
|
||||
*/
|
||||
_shouldBackupDeprecatedFile(filePath) {
|
||||
private shouldBackupDeprecatedFile(filePath: string): boolean {
|
||||
try {
|
||||
// Simple heuristic: if file size differs significantly from expected, assume user modified
|
||||
// Expected size for ccs-delegator.md was around 2-3KB
|
||||
@@ -265,7 +290,7 @@ class ClaudeDirInstaller {
|
||||
|
||||
// If size is outside expected range, likely user modified
|
||||
return stats.size < expectedMinSize || stats.size > expectedMaxSize;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If we can't determine, err on side of caution and backup
|
||||
return true;
|
||||
}
|
||||
@@ -273,11 +298,10 @@ class ClaudeDirInstaller {
|
||||
|
||||
/**
|
||||
* Check if ~/.ccs/.claude/ exists and is valid
|
||||
* @returns {boolean} True if directory exists
|
||||
*/
|
||||
isInstalled() {
|
||||
isInstalled(): boolean {
|
||||
return fs.existsSync(this.ccsClaudeDir);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeDirInstaller;
|
||||
export default ClaudeDirInstaller;
|
||||
@@ -1,32 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
// ora v9+ is an ES module, need to use .default for CommonJS
|
||||
let ora = null;
|
||||
try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch (e) {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
ora = function(text) {
|
||||
return {
|
||||
start: () => ({
|
||||
succeed: (msg) => console.log(msg || `[OK] ${text}`),
|
||||
fail: (msg) => console.log(msg || `[X] ${text}`),
|
||||
warn: (msg) => console.log(msg || `[!] ${text}`),
|
||||
info: (msg) => console.log(msg || `[i] ${text}`),
|
||||
text: ''
|
||||
})
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const { colored } = require('./helpers');
|
||||
|
||||
/**
|
||||
* ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/
|
||||
* v4.1.0: Selective symlinking for CCS items
|
||||
@@ -40,7 +11,65 @@ const { colored } = require('./helpers');
|
||||
* Symlink Chain:
|
||||
* profile -> ~/.ccs/shared/ -> ~/.claude/ (which has symlinks to ~/.ccs/.claude/)
|
||||
*/
|
||||
class ClaudeSymlinkManager {
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { colored } from './helpers';
|
||||
|
||||
// Ora fallback type for when ora is not available
|
||||
interface OraSpinner {
|
||||
text: string;
|
||||
succeed: (msg?: string) => void;
|
||||
fail: (msg?: string) => void;
|
||||
warn: (msg?: string) => void;
|
||||
info: (msg?: string) => void;
|
||||
}
|
||||
|
||||
interface OraInstance {
|
||||
start: () => OraSpinner;
|
||||
}
|
||||
|
||||
// Make ora optional (might not be available during npm install postinstall)
|
||||
let ora: ((text: string) => OraInstance) | null = null;
|
||||
try {
|
||||
const oraModule = require('ora');
|
||||
ora = oraModule.default || oraModule;
|
||||
} catch {
|
||||
// ora not available, create fallback spinner that uses console.log
|
||||
ora = function (text: string): OraInstance {
|
||||
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 CcsItem {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
healthy: boolean;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/
|
||||
*/
|
||||
export class ClaudeSymlinkManager {
|
||||
private homeDir: string;
|
||||
private ccsClaudeDir: string;
|
||||
private userClaudeDir: string;
|
||||
private ccsItems: CcsItem[];
|
||||
|
||||
constructor() {
|
||||
this.homeDir = os.homedir();
|
||||
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
|
||||
@@ -50,7 +79,7 @@ class ClaudeSymlinkManager {
|
||||
this.ccsItems = [
|
||||
{ source: 'commands/ccs.md', target: 'commands/ccs.md', type: 'file' },
|
||||
{ source: 'commands/ccs', target: 'commands/ccs', type: 'directory' },
|
||||
{ source: 'skills/ccs-delegation', target: 'skills/ccs-delegation', type: 'directory' }
|
||||
{ source: 'skills/ccs-delegation', target: 'skills/ccs-delegation', type: 'directory' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,8 +87,8 @@ class ClaudeSymlinkManager {
|
||||
* Install CCS items to user's ~/.claude/ via selective symlinks
|
||||
* Safe: backs up existing files before creating symlinks
|
||||
*/
|
||||
install(silent = false) {
|
||||
const spinner = (silent || !ora) ? null : ora('Installing CCS items to ~/.claude/').start();
|
||||
install(silent = false): void {
|
||||
const spinner = silent || !ora ? null : ora('Installing CCS items to ~/.claude/').start();
|
||||
|
||||
// Ensure ~/.ccs/.claude/ exists (should be shipped with package)
|
||||
if (!fs.existsSync(this.ccsClaudeDir)) {
|
||||
@@ -86,7 +115,7 @@ class ClaudeSymlinkManager {
|
||||
if (!silent && spinner) {
|
||||
spinner.text = `Installing ${item.target}...`;
|
||||
}
|
||||
const result = this._installItem(item, silent);
|
||||
const result = this.installItem(item, silent);
|
||||
if (result) installed++;
|
||||
}
|
||||
|
||||
@@ -100,12 +129,8 @@ class ClaudeSymlinkManager {
|
||||
|
||||
/**
|
||||
* Install a single CCS item with conflict handling
|
||||
* @param {Object} item - Item descriptor {source, target, type}
|
||||
* @param {boolean} silent - Suppress individual item messages
|
||||
* @returns {boolean} True if installed successfully
|
||||
* @private
|
||||
*/
|
||||
_installItem(item, silent = false) {
|
||||
private installItem(item: CcsItem, silent = false): boolean {
|
||||
const sourcePath = path.join(this.ccsClaudeDir, item.source);
|
||||
const targetPath = path.join(this.userClaudeDir, item.target);
|
||||
const targetDir = path.dirname(targetPath);
|
||||
@@ -124,12 +149,12 @@ class ClaudeSymlinkManager {
|
||||
// Check if target already exists
|
||||
if (fs.existsSync(targetPath)) {
|
||||
// Check if it's already the correct symlink
|
||||
if (this._isOurSymlink(targetPath, sourcePath)) {
|
||||
if (this.isOurSymlink(targetPath, sourcePath)) {
|
||||
return true; // Already correct, counts as success
|
||||
}
|
||||
|
||||
// Backup existing file/directory
|
||||
this._backupItem(targetPath, silent);
|
||||
this.backupItem(targetPath, silent);
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
@@ -146,7 +171,8 @@ class ClaudeSymlinkManager {
|
||||
console.log(`[i] Enable Developer Mode or wait for next update`);
|
||||
}
|
||||
} else {
|
||||
if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${err.message}`);
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -154,12 +180,8 @@ class ClaudeSymlinkManager {
|
||||
|
||||
/**
|
||||
* Check if target is already the correct symlink pointing to source
|
||||
* @param {string} targetPath - Target path to check
|
||||
* @param {string} expectedSource - Expected source path
|
||||
* @returns {boolean} True if target is correct symlink
|
||||
* @private
|
||||
*/
|
||||
_isOurSymlink(targetPath, expectedSource) {
|
||||
private isOurSymlink(targetPath: string, expectedSource: string): boolean {
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
|
||||
@@ -171,18 +193,15 @@ class ClaudeSymlinkManager {
|
||||
const resolvedTarget = path.resolve(path.dirname(targetPath), actualTarget);
|
||||
|
||||
return resolvedTarget === expectedSource;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup existing item before replacing with symlink
|
||||
* @param {string} itemPath - Path to item to backup
|
||||
* @param {boolean} silent - Suppress backup messages
|
||||
* @private
|
||||
*/
|
||||
_backupItem(itemPath, silent = false) {
|
||||
private backupItem(itemPath: string, silent = false): void {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const backupPath = `${itemPath}.backup-${timestamp}`;
|
||||
|
||||
@@ -198,7 +217,8 @@ class ClaudeSymlinkManager {
|
||||
fs.renameSync(itemPath, finalBackupPath);
|
||||
if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`);
|
||||
} catch (err) {
|
||||
if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${err.message}`);
|
||||
const error = err as Error;
|
||||
if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${error.message}`);
|
||||
throw err; // Don't proceed if backup fails
|
||||
}
|
||||
}
|
||||
@@ -207,7 +227,7 @@ class ClaudeSymlinkManager {
|
||||
* Uninstall CCS items from ~/.claude/ (remove symlinks only)
|
||||
* Safe: only removes items that are CCS symlinks
|
||||
*/
|
||||
uninstall() {
|
||||
uninstall(): void {
|
||||
let removed = 0;
|
||||
|
||||
for (const item of this.ccsItems) {
|
||||
@@ -215,13 +235,14 @@ class ClaudeSymlinkManager {
|
||||
const sourcePath = path.join(this.ccsClaudeDir, item.source);
|
||||
|
||||
// Only remove if it's our symlink
|
||||
if (fs.existsSync(targetPath) && this._isOurSymlink(targetPath, sourcePath)) {
|
||||
if (fs.existsSync(targetPath) && this.isOurSymlink(targetPath, sourcePath)) {
|
||||
try {
|
||||
fs.unlinkSync(targetPath);
|
||||
console.log(`[OK] Removed ${item.target}`);
|
||||
removed++;
|
||||
} catch (err) {
|
||||
console.log(`[!] Failed to remove ${item.target}: ${err.message}`);
|
||||
const error = err as Error;
|
||||
console.log(`[!] Failed to remove ${item.target}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,10 +257,9 @@ class ClaudeSymlinkManager {
|
||||
/**
|
||||
* Check symlink health and report issues
|
||||
* Used by 'ccs doctor' command
|
||||
* @returns {Object} Health check results {healthy: boolean, issues: string[]}
|
||||
*/
|
||||
checkHealth() {
|
||||
const issues = [];
|
||||
checkHealth(): HealthCheckResult {
|
||||
const issues: string[] = [];
|
||||
let healthy = true;
|
||||
|
||||
// Check if ~/.ccs/.claude/ exists
|
||||
@@ -265,7 +285,7 @@ class ClaudeSymlinkManager {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
issues.push(`Not installed: ${item.target} (run 'ccs sync' to install)`);
|
||||
healthy = false;
|
||||
} else if (!this._isOurSymlink(targetPath, sourcePath)) {
|
||||
} else if (!this.isOurSymlink(targetPath, sourcePath)) {
|
||||
issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`);
|
||||
healthy = false;
|
||||
}
|
||||
@@ -278,7 +298,7 @@ class ClaudeSymlinkManager {
|
||||
* Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command)
|
||||
* Same as install() but with explicit sync message
|
||||
*/
|
||||
sync() {
|
||||
sync(): void {
|
||||
console.log('');
|
||||
console.log(colored('Syncing CCS Components...', 'cyan'));
|
||||
console.log('');
|
||||
@@ -286,4 +306,4 @@ class ClaudeSymlinkManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeSymlinkManager;
|
||||
export default ClaudeSymlinkManager;
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Config, isConfig, Settings, isSettings } from '../types';
|
||||
import { expandPath } from './helpers';
|
||||
import { error } from './helpers';
|
||||
|
||||
// TODO: Replace with proper imports after converting these files
|
||||
// const { ErrorManager } = require('./error-manager');
|
||||
// const RecoveryManager = require('./recovery-manager');
|
||||
|
||||
/**
|
||||
* Get the CCS home directory (respects CCS_HOME env var for test isolation)
|
||||
* @returns Home directory path
|
||||
*/
|
||||
export function getCcsHome(): string {
|
||||
return process.env.CCS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CCS directory path (~/.ccs)
|
||||
* @returns Path to .ccs directory
|
||||
*/
|
||||
export function getCcsDir(): string {
|
||||
return path.join(getCcsHome(), '.ccs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config file path
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate config.json
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// TODO: Add recovery manager logic
|
||||
// const recovery = new RecoveryManager();
|
||||
// recovery.ensureConfigJson();
|
||||
|
||||
error(`Config not found: ${configPath}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
||||
if (!isConfig(parsed)) {
|
||||
error(`Invalid config format: ${configPath}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate settings.json
|
||||
*/
|
||||
export function loadSettings(settingsPath: string): Settings {
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
error(`Settings not found: ${settingsPath}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(settingsPath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
||||
if (!isSettings(parsed)) {
|
||||
error(`Invalid settings format: ${settingsPath}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse config (legacy compatibility)
|
||||
*/
|
||||
export function readConfig(): Config {
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings path for profile
|
||||
*/
|
||||
export function getSettingsPath(profile: string): string {
|
||||
const config = readConfig();
|
||||
|
||||
// Get settings path
|
||||
const settingsPath = config.profiles[profile];
|
||||
|
||||
if (!settingsPath) {
|
||||
const availableProfiles = Object.keys(config.profiles);
|
||||
const profileList = availableProfiles.map((p) => ` - ${p}`);
|
||||
error(`Profile '${profile}' not found. Available profiles:\n${profileList.join('\n')}`);
|
||||
}
|
||||
|
||||
// Expand path
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
|
||||
// Validate settings file exists
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
// Auto-create if it's ~/.claude/settings.json
|
||||
if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) {
|
||||
// TODO: Add recovery manager logic
|
||||
// const recovery = new RecoveryManager();
|
||||
// recovery.ensureClaudeSettings();
|
||||
|
||||
console.log('[i] Auto-created missing settings file');
|
||||
} else {
|
||||
error(`Settings file not found: ${expandedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings file is valid JSON
|
||||
try {
|
||||
const settingsContent = fs.readFileSync(expandedPath, 'utf8');
|
||||
JSON.parse(settingsContent);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error(`Invalid JSON in settings file: ${expandedPath} - ${e.message}`);
|
||||
} else {
|
||||
error(`Invalid JSON in settings file: ${expandedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedPath;
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Settings } from '../types';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
suggestion?: string;
|
||||
settingsPath?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates delegation profiles for CCS delegation system
|
||||
* Ensures profiles exist and have valid API keys configured
|
||||
*/
|
||||
class DelegationValidator {
|
||||
export class DelegationValidator {
|
||||
/**
|
||||
* Validate a delegation profile
|
||||
* @param {string} profileName - Name of profile to validate (e.g., 'glm', 'kimi')
|
||||
* @returns {Object} Validation result { valid: boolean, error?: string, settingsPath?: string }
|
||||
* @param profileName - Name of profile to validate (e.g., 'glm', 'kimi')
|
||||
* @returns Validation result { valid: boolean, error?: string, settingsPath?: string }
|
||||
*/
|
||||
static validate(profileName) {
|
||||
static validate(profileName: string): ValidationResult {
|
||||
const homeDir = os.homedir();
|
||||
const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`);
|
||||
|
||||
@@ -24,16 +32,17 @@ class DelegationValidator {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Profile not found: ${profileName}`,
|
||||
suggestion: `Profile settings missing at: ${settingsPath}\n\n` +
|
||||
`To set up ${profileName} profile:\n` +
|
||||
` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` +
|
||||
` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` +
|
||||
` 3. Set your API key in ANTHROPIC_AUTH_TOKEN field`
|
||||
suggestion:
|
||||
`Profile settings missing at: ${settingsPath}\n\n` +
|
||||
`To set up ${profileName} profile:\n` +
|
||||
` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` +
|
||||
` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` +
|
||||
` 3. Set your API key in ANTHROPIC_AUTH_TOKEN field`,
|
||||
};
|
||||
}
|
||||
|
||||
// Read and parse settings.json
|
||||
let settings;
|
||||
let settings: Settings;
|
||||
try {
|
||||
const settingsContent = fs.readFileSync(settingsPath, 'utf8');
|
||||
settings = JSON.parse(settingsContent);
|
||||
@@ -41,11 +50,12 @@ class DelegationValidator {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to parse settings.json for ${profileName}`,
|
||||
suggestion: `Settings file is corrupted or invalid JSON.\n\n` +
|
||||
`Location: ${settingsPath}\n` +
|
||||
`Parse error: ${error.message}\n\n` +
|
||||
`Fix: Restore from base config:\n` +
|
||||
` cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json`
|
||||
suggestion:
|
||||
`Settings file is corrupted or invalid JSON.\n\n` +
|
||||
`Location: ${settingsPath}\n` +
|
||||
`Parse error: ${(error as Error).message}\n\n` +
|
||||
`Fix: Restore from base config:\n` +
|
||||
` cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,9 +66,10 @@ class DelegationValidator {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key not configured for ${profileName}`,
|
||||
suggestion: `Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` +
|
||||
`Edit: ${settingsPath}\n` +
|
||||
`Set: env.ANTHROPIC_AUTH_TOKEN to your API key`
|
||||
suggestion:
|
||||
`Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` +
|
||||
`Edit: ${settingsPath}\n` +
|
||||
`Set: env.ANTHROPIC_AUTH_TOKEN to your API key`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,20 +79,21 @@ class DelegationValidator {
|
||||
'YOUR_KIMI_API_KEY_HERE',
|
||||
'YOUR_API_KEY_HERE',
|
||||
'your-api-key-here',
|
||||
'PLACEHOLDER'
|
||||
'PLACEHOLDER',
|
||||
];
|
||||
|
||||
if (defaultPlaceholders.some(placeholder => apiKey.includes(placeholder))) {
|
||||
if (defaultPlaceholders.some((placeholder) => apiKey.includes(placeholder))) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Default API key placeholder detected for ${profileName}`,
|
||||
suggestion: `API key is still set to default placeholder.\n\n` +
|
||||
`To configure your profile:\n` +
|
||||
` 1. Edit: ${settingsPath}\n` +
|
||||
` 2. Replace ANTHROPIC_AUTH_TOKEN with your actual API key\n\n` +
|
||||
`Get API key:\n` +
|
||||
` GLM: https://z.ai/manage-apikey/apikey-list\n` +
|
||||
` Kimi: https://platform.moonshot.cn/console/api-keys`
|
||||
suggestion:
|
||||
`API key is still set to default placeholder.\n\n` +
|
||||
`To configure your profile:\n` +
|
||||
` 1. Edit: ${settingsPath}\n` +
|
||||
` 2. Replace ANTHROPIC_AUTH_TOKEN with your actual API key\n\n` +
|
||||
`Get API key:\n` +
|
||||
` GLM: https://z.ai/manage-apikey/apikey-list\n` +
|
||||
` Kimi: https://platform.moonshot.cn/console/api-keys`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,16 +101,16 @@ class DelegationValidator {
|
||||
return {
|
||||
valid: true,
|
||||
settingsPath,
|
||||
apiKey: apiKey.substring(0, 8) + '...' // Show first 8 chars for verification
|
||||
apiKey: apiKey.substring(0, 8) + '...', // Show first 8 chars for verification
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation error for display
|
||||
* @param {Object} result - Validation result from validate()
|
||||
* @returns {string} Formatted error message
|
||||
* @param result - Validation result from validate()
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
static formatError(result) {
|
||||
static formatError(result: ValidationResult): string {
|
||||
if (result.valid) {
|
||||
return '';
|
||||
}
|
||||
@@ -114,19 +126,19 @@ class DelegationValidator {
|
||||
|
||||
/**
|
||||
* Check if profile is delegation-ready (shorthand)
|
||||
* @param {string} profileName - Profile to check
|
||||
* @returns {boolean} True if ready for delegation
|
||||
* @param profileName - Profile to check
|
||||
* @returns True if ready for delegation
|
||||
*/
|
||||
static isReady(profileName) {
|
||||
static isReady(profileName: string): boolean {
|
||||
const result = this.validate(profileName);
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all delegation-ready profiles
|
||||
* @returns {Array<string>} List of profile names ready for delegation
|
||||
* @returns List of profile names ready for delegation
|
||||
*/
|
||||
static getReadyProfiles() {
|
||||
static getReadyProfiles(): string[] {
|
||||
const homeDir = os.homedir();
|
||||
const ccsDir = path.join(homeDir, '.ccs');
|
||||
|
||||
@@ -134,7 +146,7 @@ class DelegationValidator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const profiles = [];
|
||||
const profiles: string[] = [];
|
||||
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
|
||||
|
||||
// Look for *.settings.json files
|
||||
@@ -150,5 +162,3 @@ class DelegationValidator {
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DelegationValidator };
|
||||
@@ -1,7 +1,9 @@
|
||||
// CCS Error Codes
|
||||
// Documentation: ../../docs/errors/README.md
|
||||
/**
|
||||
* CCS Error Codes
|
||||
* Documentation: ../../docs/errors/README.md
|
||||
*/
|
||||
|
||||
const ERROR_CODES = {
|
||||
export const ERROR_CODES = {
|
||||
// Configuration Errors (E100-E199)
|
||||
CONFIG_MISSING: 'E101',
|
||||
CONFIG_INVALID_JSON: 'E102',
|
||||
@@ -32,16 +34,22 @@ const ERROR_CODES = {
|
||||
|
||||
// Internal Errors (E900-E999)
|
||||
INTERNAL_ERROR: 'E900',
|
||||
INVALID_STATE: 'E901'
|
||||
};
|
||||
INVALID_STATE: 'E901',
|
||||
} as const;
|
||||
|
||||
// Error code documentation URL generator
|
||||
function getErrorDocUrl(errorCode) {
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
/**
|
||||
* Error code documentation URL generator
|
||||
*/
|
||||
export function getErrorDocUrl(errorCode: ErrorCode): string {
|
||||
return `https://github.com/kaitranntt/ccs/blob/main/docs/errors/README.md#${errorCode.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Get error category from code
|
||||
function getErrorCategory(errorCode) {
|
||||
/**
|
||||
* Get error category from code
|
||||
*/
|
||||
export function getErrorCategory(errorCode: ErrorCode): string {
|
||||
const code = parseInt(errorCode.substring(1));
|
||||
if (code >= 100 && code < 200) return 'Configuration';
|
||||
if (code >= 200 && code < 300) return 'Profile Management';
|
||||
@@ -51,9 +59,3 @@ function getErrorCategory(errorCode) {
|
||||
if (code >= 900 && code < 1000) return 'Internal';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ERROR_CODES,
|
||||
getErrorDocUrl,
|
||||
getErrorCategory
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { colored } from './helpers';
|
||||
import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes';
|
||||
|
||||
/**
|
||||
* Error types with structured messages (Legacy - kept for compatibility)
|
||||
*/
|
||||
export const ErrorTypes = {
|
||||
NO_CLAUDE_CLI: 'NO_CLAUDE_CLI',
|
||||
MISSING_SETTINGS: 'MISSING_SETTINGS',
|
||||
INVALID_CONFIG: 'INVALID_CONFIG',
|
||||
UNKNOWN_PROFILE: 'UNKNOWN_PROFILE',
|
||||
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
||||
GENERIC: 'GENERIC',
|
||||
} as const;
|
||||
|
||||
export type ErrorType = (typeof ErrorTypes)[keyof typeof ErrorTypes];
|
||||
|
||||
/**
|
||||
* Enhanced error manager with context-aware messages
|
||||
*/
|
||||
export class ErrorManager {
|
||||
/**
|
||||
* Show error code and documentation URL
|
||||
*/
|
||||
static showErrorCode(errorCode: ErrorCode): void {
|
||||
console.error(colored(`Error: ${errorCode}`, 'yellow'));
|
||||
console.error(colored(getErrorDocUrl(errorCode), 'yellow'));
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Claude CLI not found error
|
||||
*/
|
||||
static showClaudeNotFound(): void {
|
||||
console.error('');
|
||||
console.error(colored('[X] Claude CLI not found', 'red'));
|
||||
console.error('');
|
||||
console.error('CCS requires Claude CLI to be installed and available in PATH.');
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' 1. Install Claude CLI:');
|
||||
console.error(' https://docs.claude.com/en/docs/claude-code/installation');
|
||||
console.error('');
|
||||
console.error(' 2. Verify installation:');
|
||||
console.error(' command -v claude (Unix)');
|
||||
console.error(' Get-Command claude (Windows)');
|
||||
console.error('');
|
||||
console.error(' 3. Custom path (if installed elsewhere):');
|
||||
console.error(' export CCS_CLAUDE_PATH="/path/to/claude"');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show settings file not found error
|
||||
*/
|
||||
static showSettingsNotFound(settingsPath: string): void {
|
||||
const isClaudeSettings =
|
||||
settingsPath.includes('.claude') && settingsPath.endsWith('settings.json');
|
||||
|
||||
console.error('');
|
||||
console.error(colored('[X] Settings file not found', 'red'));
|
||||
console.error('');
|
||||
console.error(`File: ${settingsPath}`);
|
||||
console.error('');
|
||||
|
||||
if (isClaudeSettings) {
|
||||
console.error('This file is auto-created when you login to Claude CLI.');
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(` echo '{}' > ${settingsPath}`);
|
||||
console.error(' claude /login');
|
||||
console.error('');
|
||||
console.error('Why: Newer Claude CLI versions require explicit login.');
|
||||
} else {
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
console.error('This will recreate missing profile settings.');
|
||||
}
|
||||
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_PROFILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show invalid configuration error
|
||||
*/
|
||||
static showInvalidConfig(configPath: string, errorDetail: string): void {
|
||||
console.error('');
|
||||
console.error(colored('[X] Configuration invalid', 'red'));
|
||||
console.error('');
|
||||
console.error(`File: ${configPath}`);
|
||||
console.error(`Issue: ${errorDetail}`);
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Backup corrupted file');
|
||||
console.error(` mv ${configPath} ${configPath}.backup`);
|
||||
console.error('');
|
||||
console.error(' # Reinstall CCS');
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
console.error('Your profile settings will be preserved.');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show profile not found error
|
||||
*/
|
||||
static showProfileNotFound(
|
||||
profileName: string,
|
||||
availableProfiles: string[],
|
||||
suggestions: string[] = []
|
||||
): void {
|
||||
console.error('');
|
||||
console.error(colored(`[X] Profile '${profileName}' not found`, 'red'));
|
||||
console.error('');
|
||||
|
||||
if (suggestions && suggestions.length > 0) {
|
||||
console.error(colored('Did you mean:', 'yellow'));
|
||||
suggestions.forEach((s) => console.error(` ${s}`));
|
||||
console.error('');
|
||||
}
|
||||
|
||||
console.error(colored('Available profiles:', 'cyan'));
|
||||
availableProfiles.forEach((line) => console.error(` ${line}`));
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Use existing profile');
|
||||
console.error(' ccs <profile> "your prompt"');
|
||||
console.error('');
|
||||
console.error(' # Create new account profile');
|
||||
console.error(' ccs auth create <name>');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show permission denied error
|
||||
*/
|
||||
static showPermissionDenied(path: string): void {
|
||||
console.error('');
|
||||
console.error(colored('[X] Permission denied', 'red'));
|
||||
console.error('');
|
||||
console.error(`Cannot write to: ${path}`);
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' # Fix ownership');
|
||||
console.error(' sudo chown -R $USER ~/.ccs ~/.claude');
|
||||
console.error('');
|
||||
console.error(' # Fix permissions');
|
||||
console.error(' chmod 755 ~/.ccs ~/.claude');
|
||||
console.error('');
|
||||
console.error(' # Retry installation');
|
||||
console.error(' npm install -g @kaitranntt/ccs --force');
|
||||
console.error('');
|
||||
this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show CLIProxy OAuth timeout error
|
||||
*/
|
||||
static showOAuthTimeout(provider: string): void {
|
||||
console.error('');
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error(colored('| OAuth Timeout |', 'red'));
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error('');
|
||||
console.error('Authentication did not complete within 2 minutes.');
|
||||
console.error('');
|
||||
console.error(colored('Troubleshooting:', 'yellow'));
|
||||
console.error(' 1. Check if browser opened (popup blocker?)');
|
||||
console.error(' 2. Complete login in browser, then return here');
|
||||
console.error(' 3. Try different browser');
|
||||
console.error(' 4. Disable browser extensions temporarily');
|
||||
console.error('');
|
||||
console.error(colored('For headless/SSH environments:', 'cyan'));
|
||||
console.error(` ccs ${provider} --auth --headless`);
|
||||
console.error('');
|
||||
console.error('This displays manual authentication steps.');
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show CLIProxy port conflict error
|
||||
*/
|
||||
static showPortConflict(port: number): void {
|
||||
console.error('');
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error(colored('| Port Conflict |', 'red'));
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error('');
|
||||
console.error(`CLIProxy port ${port} is already in use.`);
|
||||
console.error('');
|
||||
console.error(colored('Solutions:', 'yellow'));
|
||||
console.error(' 1. Find process using port:');
|
||||
console.error(` lsof -i :${port} (macOS/Linux)`);
|
||||
console.error(` netstat -ano | findstr ${port} (Windows)`);
|
||||
console.error('');
|
||||
console.error(' 2. Kill the process:');
|
||||
console.error(` lsof -ti:${port} | xargs kill -9`);
|
||||
console.error('');
|
||||
console.error(' 3. Wait and retry (process may exit on its own)');
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show CLIProxy binary download failure error
|
||||
*/
|
||||
static showBinaryDownloadFailed(url: string, error: string): void {
|
||||
console.error('');
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error(colored('| Binary Download Failed |', 'red'));
|
||||
console.error(colored('+---------------------------------------------------------+', 'red'));
|
||||
console.error('');
|
||||
console.error(`Error: ${error}`);
|
||||
console.error('');
|
||||
console.error(colored('Troubleshooting:', 'yellow'));
|
||||
console.error(' 1. Check internet connection');
|
||||
console.error(' 2. Check firewall/proxy settings');
|
||||
console.error(' 3. Try again in a few minutes');
|
||||
console.error('');
|
||||
console.error(colored('Manual download:', 'cyan'));
|
||||
console.error(` URL: ${url}`);
|
||||
console.error(' Save to: ~/.ccs/bin/cliproxyapi');
|
||||
console.error(' chmod +x ~/.ccs/bin/cliproxyapi');
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show CLIProxy authentication required error
|
||||
*/
|
||||
static showAuthRequired(provider: string): void {
|
||||
console.error('');
|
||||
console.error(colored(`[X] ${provider} authentication required`, 'red'));
|
||||
console.error('');
|
||||
console.error(colored('To authenticate:', 'yellow'));
|
||||
console.error(` ccs ${provider} --auth`);
|
||||
console.error('');
|
||||
console.error('This will open a browser for OAuth login.');
|
||||
console.error('After login, you can use the profile normally.');
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
'use strict';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ColorName, TerminalInfo } from '../types';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// TTY-aware color detection (matches lib/ccs bash logic)
|
||||
function getColors() {
|
||||
/**
|
||||
* TTY-aware color detection (matches lib/ccs bash logic)
|
||||
*/
|
||||
function getColors(): Record<ColorName, string> {
|
||||
const forcedColors = process.env.FORCE_COLOR;
|
||||
const noColor = process.env.NO_COLOR;
|
||||
const isTTY = process.stdout.isTTY === true; // Must be explicitly true
|
||||
const isTTY = process.stdout.isTTY === true; // Must be explicitly true
|
||||
|
||||
const useColors = forcedColors || (isTTY && !noColor);
|
||||
const useColors = !!forcedColors || (isTTY && !noColor);
|
||||
|
||||
if (useColors) {
|
||||
return {
|
||||
@@ -18,34 +18,41 @@ function getColors() {
|
||||
yellow: '\x1b[1;33m',
|
||||
cyan: '\x1b[0;36m',
|
||||
green: '\x1b[0;32m',
|
||||
blue: '\x1b[0;34m',
|
||||
bold: '\x1b[1m',
|
||||
reset: '\x1b[0m'
|
||||
cyanBold: '\x1b[1;36m',
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
}
|
||||
|
||||
return { red: '', yellow: '', cyan: '', green: '', bold: '', reset: '' };
|
||||
return { red: '', yellow: '', cyan: '', green: '', blue: '', bold: '', cyanBold: '', reset: '' };
|
||||
}
|
||||
|
||||
|
||||
// Colors object (dynamic)
|
||||
const colors = getColors();
|
||||
export const colors = getColors();
|
||||
|
||||
// Helper: Apply color to text (returns plain text if colors disabled)
|
||||
function colored(text, colorName = 'reset') {
|
||||
/**
|
||||
* Helper: Apply color to text (returns plain text if colors disabled)
|
||||
*/
|
||||
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
|
||||
function error(message) {
|
||||
/**
|
||||
* Simple error formatting
|
||||
*/
|
||||
export function error(message: string): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
console.error('Try: npm install -g @kaitranntt/ccs --force');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Path expansion (~ and env vars)
|
||||
function expandPath(pathStr) {
|
||||
/**
|
||||
* Path expansion (~ and env vars)
|
||||
*/
|
||||
export function expandPath(pathStr: string): string {
|
||||
// Handle tilde expansion
|
||||
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
|
||||
pathStr = path.join(os.homedir(), pathStr.slice(2));
|
||||
@@ -64,16 +71,24 @@ function expandPath(pathStr) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
* @param {string} a - First string
|
||||
* @param {string} b - Second string
|
||||
* @returns {number} Edit distance
|
||||
* Detect terminal capabilities
|
||||
*/
|
||||
function levenshteinDistance(a, b) {
|
||||
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
|
||||
*/
|
||||
export function levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
const matrix = [];
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize first row and column
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
@@ -92,8 +107,8 @@ function levenshteinDistance(a, b) {
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j - 1] + 1, // substitution
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j] + 1 // deletion
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j] + 1 // deletion
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,33 +119,23 @@ function levenshteinDistance(a, b) {
|
||||
|
||||
/**
|
||||
* Find similar strings using fuzzy matching
|
||||
* @param {string} target - Target string
|
||||
* @param {string[]} candidates - List of candidate strings
|
||||
* @param {number} maxDistance - Maximum edit distance (default: 2)
|
||||
* @returns {string[]} Similar strings sorted by distance
|
||||
*/
|
||||
function findSimilarStrings(target, candidates, maxDistance = 2) {
|
||||
export function findSimilarStrings(
|
||||
target: string,
|
||||
candidates: string[],
|
||||
maxDistance: number = 2
|
||||
): string[] {
|
||||
const targetLower = target.toLowerCase();
|
||||
|
||||
const matches = candidates
|
||||
.map(candidate => ({
|
||||
.map((candidate) => ({
|
||||
name: candidate,
|
||||
distance: levenshteinDistance(targetLower, candidate.toLowerCase())
|
||||
distance: levenshteinDistance(targetLower, candidate.toLowerCase()),
|
||||
}))
|
||||
.filter(item => item.distance <= maxDistance && item.distance > 0)
|
||||
.filter((item) => item.distance <= maxDistance && item.distance > 0)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, 3) // Show at most 3 suggestions
|
||||
.map(item => item.name);
|
||||
.slice(0, 3) // Show at most 3 suggestions
|
||||
.map((item) => item.name);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
colored,
|
||||
error,
|
||||
expandPath,
|
||||
levenshteinDistance,
|
||||
findSimilarStrings
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Package Manager Detector Utilities
|
||||
*
|
||||
* Cross-platform package manager detection utilities for CCS.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Detect installation method
|
||||
*/
|
||||
export function detectInstallationMethod(): 'npm' | 'direct' {
|
||||
const scriptPath = process.argv[1];
|
||||
|
||||
// Method 1: Check if script is inside node_modules
|
||||
if (scriptPath.includes('node_modules')) {
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
// Method 2: Check if script is in npm global bin directory
|
||||
const npmGlobalBinPatterns = [
|
||||
/\.npm\/global\/bin\//,
|
||||
/\/\.nvm\/versions\/node\/[^/]+\/bin\//,
|
||||
/\/usr\/local\/bin\//,
|
||||
/\/usr\/bin\//,
|
||||
];
|
||||
|
||||
for (const pattern of npmGlobalBinPatterns) {
|
||||
if (pattern.test(scriptPath)) {
|
||||
try {
|
||||
const binDir = path.dirname(scriptPath);
|
||||
const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs');
|
||||
const globalModulesDir = path.join(binDir, '..', 'node_modules', '@kaitranntt', 'ccs');
|
||||
|
||||
if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue checking other patterns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check if package.json exists in parent directory
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === '@kaitranntt/ccs') {
|
||||
return 'npm';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Check if script is a symlink pointing to node_modules
|
||||
try {
|
||||
const stats = fs.lstatSync(scriptPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const targetPath = fs.readlinkSync(scriptPath);
|
||||
if (targetPath.includes('node_modules') || targetPath.includes('@kaitranntt/ccs')) {
|
||||
return 'npm';
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to default
|
||||
}
|
||||
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager was used for installation
|
||||
*/
|
||||
export function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' {
|
||||
const scriptPath = process.argv[1];
|
||||
|
||||
// Check if script path contains package manager indicators
|
||||
if (scriptPath.includes('.pnpm')) return 'pnpm';
|
||||
if (scriptPath.includes('yarn')) return 'yarn';
|
||||
if (scriptPath.includes('bun')) return 'bun';
|
||||
|
||||
// Check parent directories for lock files
|
||||
const binDir = path.dirname(scriptPath);
|
||||
|
||||
let checkDir = binDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
if (fs.existsSync(path.join(checkDir, 'yarn.lock'))) return 'yarn';
|
||||
if (fs.existsSync(path.join(checkDir, 'bun.lockb'))) return 'bun';
|
||||
checkDir = path.dirname(checkDir);
|
||||
}
|
||||
|
||||
// Check if package managers are available on the system
|
||||
try {
|
||||
const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'yarn';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to next check
|
||||
}
|
||||
|
||||
try {
|
||||
const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'pnpm';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to next check
|
||||
}
|
||||
|
||||
try {
|
||||
const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) {
|
||||
return 'bun';
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to default
|
||||
}
|
||||
|
||||
return 'npm';
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Simple Progress Indicator (no external dependencies)
|
||||
*
|
||||
@@ -10,15 +8,25 @@
|
||||
* - CI environment detection
|
||||
*/
|
||||
|
||||
class ProgressIndicator {
|
||||
interface ProgressOptions {
|
||||
frames?: string[];
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export class ProgressIndicator {
|
||||
private message: string;
|
||||
private frames: string[];
|
||||
private frameIndex: number;
|
||||
private interval: NodeJS.Timeout | null;
|
||||
private startTime: number;
|
||||
private isTTY: boolean;
|
||||
|
||||
/**
|
||||
* Create a progress indicator
|
||||
* @param {string} message - Message to display
|
||||
* @param {Object} options - Options
|
||||
* @param {string[]} options.frames - Spinner frames (default: ASCII)
|
||||
* @param {number} options.interval - Frame interval in ms (default: 80)
|
||||
* @param message - Message to display
|
||||
* @param options - Options
|
||||
*/
|
||||
constructor(message, options = {}) {
|
||||
constructor(message: string, options: ProgressOptions = {}) {
|
||||
this.message = message;
|
||||
// ASCII-only frames for cross-platform compatibility
|
||||
this.frames = options.frames || ['|', '/', '-', '\\'];
|
||||
@@ -33,7 +41,7 @@ class ProgressIndicator {
|
||||
/**
|
||||
* Start the spinner
|
||||
*/
|
||||
start() {
|
||||
start(): void {
|
||||
if (!this.isTTY) {
|
||||
// Non-TTY: just print message once
|
||||
process.stderr.write(`[i] ${this.message}...\n`);
|
||||
@@ -51,9 +59,9 @@ class ProgressIndicator {
|
||||
|
||||
/**
|
||||
* Stop spinner with success message
|
||||
* @param {string} message - Optional success message (defaults to original message)
|
||||
* @param message - Optional success message (defaults to original message)
|
||||
*/
|
||||
succeed(message) {
|
||||
succeed(message?: string): void {
|
||||
this.stop();
|
||||
const finalMessage = message || this.message;
|
||||
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
||||
@@ -69,9 +77,9 @@ class ProgressIndicator {
|
||||
|
||||
/**
|
||||
* Stop spinner with failure message
|
||||
* @param {string} message - Optional failure message (defaults to original message)
|
||||
* @param message - Optional failure message (defaults to original message)
|
||||
*/
|
||||
fail(message) {
|
||||
fail(message?: string): void {
|
||||
this.stop();
|
||||
const finalMessage = message || this.message;
|
||||
|
||||
@@ -86,16 +94,16 @@ class ProgressIndicator {
|
||||
|
||||
/**
|
||||
* Update spinner message (while running)
|
||||
* @param {string} newMessage - New message to display
|
||||
* @param newMessage - New message to display
|
||||
*/
|
||||
update(newMessage) {
|
||||
update(newMessage: string): void {
|
||||
this.message = newMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the spinner without showing success/failure
|
||||
*/
|
||||
stop() {
|
||||
stop(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
@@ -108,4 +116,4 @@ class ProgressIndicator {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProgressIndicator };
|
||||
export default ProgressIndicator;
|
||||
@@ -1,6 +1,4 @@
|
||||
'use strict';
|
||||
|
||||
const readline = require('readline');
|
||||
import * as readline from 'readline';
|
||||
|
||||
/**
|
||||
* Interactive Prompt Utilities (NO external dependencies)
|
||||
@@ -13,19 +11,28 @@ const readline = require('readline');
|
||||
* - Input validation with retry
|
||||
*/
|
||||
|
||||
class InteractivePrompt {
|
||||
interface ConfirmOptions {
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
interface InputOptions {
|
||||
default?: string;
|
||||
validate?: (value: string) => string | null;
|
||||
}
|
||||
|
||||
export class InteractivePrompt {
|
||||
/**
|
||||
* Ask for confirmation
|
||||
* @param {string} message - Confirmation message
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.default - Default value (true=Yes, false=No)
|
||||
* @returns {Promise<boolean>} User confirmation
|
||||
*/
|
||||
static async confirm(message, options = {}) {
|
||||
static async confirm(message: string, options: ConfirmOptions = {}): Promise<boolean> {
|
||||
const { default: defaultValue = false } = options;
|
||||
|
||||
// Check for --yes flag (automation) - always returns true
|
||||
if (process.env.CCS_YES === '1' || process.argv.includes('--yes') || process.argv.includes('-y')) {
|
||||
if (
|
||||
process.env.CCS_YES === '1' ||
|
||||
process.argv.includes('--yes') ||
|
||||
process.argv.includes('-y')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -43,15 +50,13 @@ class InteractivePrompt {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
const promptText = defaultValue
|
||||
? `${message} [Y/n]: `
|
||||
: `${message} [y/N]: `;
|
||||
const promptText = defaultValue ? `${message} [Y/n]: ` : `${message} [y/N]: `;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(promptText, (answer) => {
|
||||
rl.question(promptText, (answer: string) => {
|
||||
rl.close();
|
||||
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
@@ -82,13 +87,8 @@ class InteractivePrompt {
|
||||
|
||||
/**
|
||||
* Get text input from user
|
||||
* @param {string} message - Prompt message
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.default - Default value
|
||||
* @param {Function} options.validate - Validation function
|
||||
* @returns {Promise<string>} User input
|
||||
*/
|
||||
static async input(message, options = {}) {
|
||||
static async input(message: string, options: InputOptions = {}): Promise<string> {
|
||||
const { default: defaultValue = '', validate = null } = options;
|
||||
|
||||
// Non-TTY: use default or error
|
||||
@@ -102,15 +102,13 @@ class InteractivePrompt {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
const promptText = defaultValue
|
||||
? `${message} [${defaultValue}]: `
|
||||
: `${message}: `;
|
||||
const promptText = defaultValue ? `${message} [${defaultValue}]: ` : `${message}: `;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(promptText, (answer) => {
|
||||
rl.question(promptText, (answer: string) => {
|
||||
rl.close();
|
||||
|
||||
const value = answer.trim() || defaultValue;
|
||||
@@ -130,5 +128,3 @@ class InteractivePrompt {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { InteractivePrompt };
|
||||
@@ -1,16 +1,27 @@
|
||||
'use strict';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
// import { execSync } from 'child_process'; // Currently unused
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync } = require('child_process');
|
||||
type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
|
||||
|
||||
interface InstallResult {
|
||||
success: boolean;
|
||||
alreadyInstalled?: boolean;
|
||||
message?: string;
|
||||
reload?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell Completion Installer
|
||||
* Auto-configures shell completion for bash, zsh, fish, PowerShell
|
||||
*/
|
||||
export class ShellCompletionInstaller {
|
||||
private homeDir: string;
|
||||
private ccsDir: string;
|
||||
private completionDir: string;
|
||||
private scriptsDir: string;
|
||||
|
||||
class ShellCompletionInstaller {
|
||||
constructor() {
|
||||
this.homeDir = os.homedir();
|
||||
this.ccsDir = path.join(this.homeDir, '.ccs');
|
||||
@@ -21,7 +32,7 @@ class ShellCompletionInstaller {
|
||||
/**
|
||||
* Detect current shell
|
||||
*/
|
||||
detectShell() {
|
||||
detectShell(): ShellType {
|
||||
const shell = process.env.SHELL || '';
|
||||
|
||||
if (shell.includes('bash')) return 'bash';
|
||||
@@ -35,14 +46,14 @@ class ShellCompletionInstaller {
|
||||
/**
|
||||
* Ensure completion files are in ~/.ccs/completions/
|
||||
*/
|
||||
ensureCompletionFiles() {
|
||||
ensureCompletionFiles(): void {
|
||||
if (!fs.existsSync(this.completionDir)) {
|
||||
fs.mkdirSync(this.completionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy completion scripts
|
||||
const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1'];
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
const src = path.join(this.scriptsDir, file);
|
||||
const dest = path.join(this.completionDir, file);
|
||||
|
||||
@@ -54,16 +65,14 @@ class ShellCompletionInstaller {
|
||||
|
||||
/**
|
||||
* Safely create directory, checking for file conflicts
|
||||
* @param {string} dirPath - Path to create
|
||||
* @throws {Error} If path exists but is a file
|
||||
*/
|
||||
ensureDirectory(dirPath) {
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const stat = fs.statSync(dirPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Cannot create directory: ${dirPath} exists but is a file.\n` +
|
||||
`Please remove or rename this file and try again.`
|
||||
`Please remove or rename this file and try again.`
|
||||
);
|
||||
}
|
||||
// Directory exists, nothing to do
|
||||
@@ -83,7 +92,7 @@ class ShellCompletionInstaller {
|
||||
/**
|
||||
* Install bash completion
|
||||
*/
|
||||
installBash() {
|
||||
private installBash(): InstallResult {
|
||||
const rcFile = path.join(this.homeDir, '.bashrc');
|
||||
const completionPath = path.join(this.completionDir, 'ccs.bash');
|
||||
|
||||
@@ -109,14 +118,14 @@ class ShellCompletionInstaller {
|
||||
return {
|
||||
success: true,
|
||||
message: `Added to ${rcFile}`,
|
||||
reload: 'source ~/.bashrc'
|
||||
reload: 'source ~/.bashrc',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install zsh completion
|
||||
*/
|
||||
installZsh() {
|
||||
private installZsh(): InstallResult {
|
||||
const rcFile = path.join(this.homeDir, '.zshrc');
|
||||
const completionPath = path.join(this.completionDir, 'ccs.zsh');
|
||||
const zshCompDir = path.join(this.homeDir, '.zsh', 'completion');
|
||||
@@ -133,10 +142,7 @@ class ShellCompletionInstaller {
|
||||
fs.copyFileSync(completionPath, destFile);
|
||||
|
||||
const marker = '# CCS shell completion';
|
||||
const setupCmds = [
|
||||
'fpath=(~/.zsh/completion $fpath)',
|
||||
'autoload -Uz compinit && compinit'
|
||||
];
|
||||
const setupCmds = ['fpath=(~/.zsh/completion $fpath)', 'autoload -Uz compinit && compinit'];
|
||||
const block = `\n${marker}\n${setupCmds.join('\n')}\n`;
|
||||
|
||||
// Check if already installed
|
||||
@@ -153,14 +159,14 @@ class ShellCompletionInstaller {
|
||||
return {
|
||||
success: true,
|
||||
message: `Added to ${rcFile}`,
|
||||
reload: 'source ~/.zshrc'
|
||||
reload: 'source ~/.zshrc',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install fish completion
|
||||
*/
|
||||
installFish() {
|
||||
private installFish(): InstallResult {
|
||||
const completionPath = path.join(this.completionDir, 'ccs.fish');
|
||||
const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions');
|
||||
|
||||
@@ -178,20 +184,17 @@ class ShellCompletionInstaller {
|
||||
return {
|
||||
success: true,
|
||||
message: `Installed to ${destFile}`,
|
||||
reload: 'Fish auto-loads completions (no reload needed)'
|
||||
reload: 'Fish auto-loads completions (no reload needed)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install PowerShell completion
|
||||
*/
|
||||
installPowerShell() {
|
||||
const profilePath = process.env.PROFILE || path.join(
|
||||
this.homeDir,
|
||||
'Documents',
|
||||
'PowerShell',
|
||||
'Microsoft.PowerShell_profile.ps1'
|
||||
);
|
||||
private installPowerShell(): InstallResult {
|
||||
const profilePath =
|
||||
process.env.PROFILE ||
|
||||
path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
|
||||
const completionPath = path.join(this.completionDir, 'ccs.ps1');
|
||||
|
||||
if (!fs.existsSync(completionPath)) {
|
||||
@@ -220,18 +223,20 @@ class ShellCompletionInstaller {
|
||||
return {
|
||||
success: true,
|
||||
message: `Added to ${profilePath}`,
|
||||
reload: '. $PROFILE'
|
||||
reload: '. $PROFILE',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install for detected or specified shell
|
||||
*/
|
||||
install(shell = null) {
|
||||
install(shell: ShellType = null): InstallResult {
|
||||
const targetShell = shell || this.detectShell();
|
||||
|
||||
if (!targetShell) {
|
||||
throw new Error('Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell');
|
||||
throw new Error(
|
||||
'Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell'
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure completion files exist
|
||||
@@ -252,5 +257,3 @@ class ShellCompletionInstaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ShellCompletionInstaller };
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shell Executor Utilities
|
||||
*
|
||||
* Cross-platform shell execution utilities for CCS.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ErrorManager } from './error-manager';
|
||||
|
||||
/**
|
||||
* Escape arguments for shell execution (Windows compatibility)
|
||||
*/
|
||||
export function escapeShellArg(arg: string): string {
|
||||
return '"' + String(arg).replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with unified spawn logic
|
||||
*/
|
||||
export function execClaude(
|
||||
claudeCli: string,
|
||||
args: string[],
|
||||
envVars: NodeJS.ProcessEnv | null = null
|
||||
): void {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
// Prepare environment (merge with process.env if envVars provided)
|
||||
const env = envVars ? { ...process.env, ...envVars } : process.env;
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
// When no shell needed: use array form (faster, no shell overhead)
|
||||
child = spawn(claudeCli, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Update Checker - Check for new CCS versions from npm registry or GitHub
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const https = require('https');
|
||||
const { colored } = require('./helpers');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as https from 'https';
|
||||
import { colored } from './helpers';
|
||||
|
||||
const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json');
|
||||
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -12,13 +14,25 @@ const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/lat
|
||||
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@kaitranntt/ccs/latest';
|
||||
const REQUEST_TIMEOUT = 5000; // 5 seconds
|
||||
|
||||
interface UpdateCache {
|
||||
last_check: number;
|
||||
latest_version: string | null;
|
||||
dismissed_version: string | null;
|
||||
}
|
||||
|
||||
interface UpdateResult {
|
||||
status: 'update_available' | 'no_update' | 'check_failed';
|
||||
reason?: string;
|
||||
latest?: string;
|
||||
current?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic versions
|
||||
* @param {string} v1 - First version (e.g., "4.1.6")
|
||||
* @param {string} v2 - Second version
|
||||
* @returns {number} - 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
function compareVersions(v1, v2) {
|
||||
export function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.replace(/^v/, '').split('.').map(Number);
|
||||
const parts2 = v2.replace(/^v/, '').split('.').map(Number);
|
||||
|
||||
@@ -33,35 +47,38 @@ function compareVersions(v1, v2) {
|
||||
|
||||
/**
|
||||
* Fetch latest version from GitHub releases
|
||||
* @returns {Promise<string|null>} - Latest version or null on error
|
||||
*/
|
||||
function fetchLatestVersionFromGitHub() {
|
||||
function fetchLatestVersionFromGitHub(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get(GITHUB_API_URL, {
|
||||
headers: { 'User-Agent': 'CCS-Update-Checker' },
|
||||
timeout: REQUEST_TIMEOUT
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
const req = https.get(
|
||||
GITHUB_API_URL,
|
||||
{
|
||||
headers: { 'User-Agent': 'CCS-Update-Checker' },
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const release = JSON.parse(data) as { tag_name?: string };
|
||||
const version = release.tag_name?.replace(/^v/, '') || null;
|
||||
resolve(version);
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const release = JSON.parse(data);
|
||||
const version = release.tag_name?.replace(/^v/, '') || null;
|
||||
resolve(version);
|
||||
} catch (err) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', () => resolve(null));
|
||||
req.on('timeout', () => {
|
||||
@@ -73,35 +90,38 @@ function fetchLatestVersionFromGitHub() {
|
||||
|
||||
/**
|
||||
* Fetch latest version from npm registry
|
||||
* @returns {Promise<string|null>} - Latest version or null on error
|
||||
*/
|
||||
function fetchLatestVersionFromNpm() {
|
||||
function fetchLatestVersionFromNpm(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.get(NPM_REGISTRY_URL, {
|
||||
headers: { 'User-Agent': 'CCS-Update-Checker' },
|
||||
timeout: REQUEST_TIMEOUT
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
const req = https.get(
|
||||
NPM_REGISTRY_URL,
|
||||
{
|
||||
headers: { 'User-Agent': 'CCS-Update-Checker' },
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageData = JSON.parse(data) as { version?: string };
|
||||
const version = packageData.version || null;
|
||||
resolve(version);
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageData = JSON.parse(data);
|
||||
const version = packageData.version || null;
|
||||
resolve(version);
|
||||
} catch (err) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', () => resolve(null));
|
||||
req.on('timeout', () => {
|
||||
@@ -113,26 +133,24 @@ function fetchLatestVersionFromNpm() {
|
||||
|
||||
/**
|
||||
* Read update check cache
|
||||
* @returns {Object} - Cache object
|
||||
*/
|
||||
function readCache() {
|
||||
export function readCache(): UpdateCache {
|
||||
try {
|
||||
if (!fs.existsSync(UPDATE_CHECK_FILE)) {
|
||||
return { last_check: 0, latest_version: null, dismissed_version: null };
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (err) {
|
||||
return JSON.parse(data) as UpdateCache;
|
||||
} catch {
|
||||
return { last_check: 0, latest_version: null, dismissed_version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write update check cache
|
||||
* @param {Object} cache - Cache object to write
|
||||
*/
|
||||
function writeCache(cache) {
|
||||
export function writeCache(cache: UpdateCache): void {
|
||||
try {
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
@@ -140,24 +158,27 @@ function writeCache(cache) {
|
||||
}
|
||||
|
||||
fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Silently fail - not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates (async, non-blocking)
|
||||
* @param {string} currentVersion - Current CCS version
|
||||
* @param {boolean} force - Force check even if within interval
|
||||
* @param {string} installMethod - Installation method ('npm' or 'direct')
|
||||
* @returns {Promise<Object>} - Update result object with status and data
|
||||
* @param currentVersion - Current CCS version
|
||||
* @param force - Force check even if within interval
|
||||
* @param installMethod - Installation method ('npm' or 'direct')
|
||||
*/
|
||||
async function checkForUpdates(currentVersion, force = false, installMethod = 'direct') {
|
||||
export async function checkForUpdates(
|
||||
currentVersion: string,
|
||||
force = false,
|
||||
installMethod: 'npm' | 'direct' = 'direct'
|
||||
): Promise<UpdateResult> {
|
||||
const cache = readCache();
|
||||
const now = Date.now();
|
||||
|
||||
// Check if we should check for updates
|
||||
if (!force && (now - cache.last_check < CHECK_INTERVAL)) {
|
||||
if (!force && now - cache.last_check < CHECK_INTERVAL) {
|
||||
// Use cached result if available
|
||||
if (cache.latest_version && compareVersions(cache.latest_version, currentVersion) > 0) {
|
||||
// Don't show if user dismissed this version
|
||||
@@ -170,8 +191,8 @@ async function checkForUpdates(currentVersion, force = false, installMethod = 'd
|
||||
}
|
||||
|
||||
// Fetch latest version from appropriate source
|
||||
let latestVersion;
|
||||
let fetchError = null;
|
||||
let latestVersion: string | null;
|
||||
let fetchError: string | null = null;
|
||||
|
||||
if (installMethod === 'npm') {
|
||||
latestVersion = await fetchLatestVersionFromNpm();
|
||||
@@ -193,7 +214,7 @@ async function checkForUpdates(currentVersion, force = false, installMethod = 'd
|
||||
return {
|
||||
status: 'check_failed',
|
||||
reason: fetchError,
|
||||
message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}`
|
||||
message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,12 +232,13 @@ async function checkForUpdates(currentVersion, force = false, installMethod = 'd
|
||||
|
||||
/**
|
||||
* Show update notification
|
||||
* @param {Object} updateInfo - Update information
|
||||
*/
|
||||
function showUpdateNotification(updateInfo) {
|
||||
export function showUpdateNotification(updateInfo: { current: string; latest: string }): void {
|
||||
console.log('');
|
||||
console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
|
||||
console.log(colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow'));
|
||||
console.log(
|
||||
colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow')
|
||||
);
|
||||
console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
|
||||
console.log('');
|
||||
console.log(` Run ${colored('ccs update', 'yellow')} to update`);
|
||||
@@ -225,19 +247,9 @@ function showUpdateNotification(updateInfo) {
|
||||
|
||||
/**
|
||||
* Dismiss update notification for a specific version
|
||||
* @param {string} version - Version to dismiss
|
||||
*/
|
||||
function dismissUpdate(version) {
|
||||
export function dismissUpdate(version: string): void {
|
||||
const cache = readCache();
|
||||
cache.dismissed_version = version;
|
||||
writeCache(cache);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareVersions,
|
||||
checkForUpdates,
|
||||
showUpdateNotification,
|
||||
dismissUpdate,
|
||||
readCache,
|
||||
writeCache
|
||||
};
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Test Environment Isolation
|
||||
*
|
||||
* Provides isolated ~/.ccs/ directory for tests to prevent
|
||||
* interference with the user's actual CCS configuration.
|
||||
*
|
||||
* Usage:
|
||||
* const { createTestEnvironment } = require('../fixtures/test-environment');
|
||||
*
|
||||
* describe('my tests', () => {
|
||||
* let testEnv;
|
||||
*
|
||||
* before(() => {
|
||||
* testEnv = createTestEnvironment();
|
||||
* });
|
||||
*
|
||||
* after(() => {
|
||||
* testEnv.cleanup();
|
||||
* });
|
||||
*
|
||||
* it('test something', () => {
|
||||
* // Tests run with CCS_HOME pointing to temp directory
|
||||
* // Your test code here
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
* Create an isolated test environment
|
||||
* Sets CCS_HOME to a temporary directory and provides cleanup
|
||||
*
|
||||
* @returns {object} Test environment with paths and cleanup function
|
||||
*/
|
||||
function createTestEnvironment() {
|
||||
// Create unique temp directory for this test run
|
||||
const tempBase = path.join(os.tmpdir(), 'ccs-test');
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const testHome = path.join(tempBase, uniqueId);
|
||||
const testCcsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create directories
|
||||
fs.mkdirSync(testCcsDir, { recursive: true });
|
||||
|
||||
// Store original environment
|
||||
const originalHome = process.env.HOME;
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
|
||||
// Set test environment - use CCS_HOME for isolation
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
// Return environment object
|
||||
return {
|
||||
/** Path to the test home directory (like ~) */
|
||||
testHome,
|
||||
|
||||
/** Path to the test .ccs directory (like ~/.ccs) */
|
||||
ccsDir: testCcsDir,
|
||||
|
||||
/** Original HOME value for reference */
|
||||
originalHome,
|
||||
|
||||
/**
|
||||
* Get a path within the test CCS directory
|
||||
* @param {...string} parts - Path segments
|
||||
* @returns {string} Full path
|
||||
*/
|
||||
getCcsPath(...parts) {
|
||||
return path.join(testCcsDir, ...parts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a file in the test CCS directory
|
||||
* @param {string} relativePath - Path relative to .ccs/
|
||||
* @param {string|object} content - File content (objects are JSON stringified)
|
||||
*/
|
||||
createFile(relativePath, content) {
|
||||
const fullPath = path.join(testCcsDir, relativePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const data = typeof content === 'object' ? JSON.stringify(content, null, 2) : content;
|
||||
fs.writeFileSync(fullPath, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a file from the test CCS directory
|
||||
* @param {string} relativePath - Path relative to .ccs/
|
||||
* @param {boolean} [asJson=false] - Parse as JSON
|
||||
* @returns {string|object} File content
|
||||
*/
|
||||
readFile(relativePath, asJson = false) {
|
||||
const fullPath = path.join(testCcsDir, relativePath);
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
return asJson ? JSON.parse(content) : content;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a file exists in the test CCS directory
|
||||
* @param {string} relativePath - Path relative to .ccs/
|
||||
* @returns {boolean}
|
||||
*/
|
||||
fileExists(relativePath) {
|
||||
return fs.existsSync(path.join(testCcsDir, relativePath));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clean up the test environment and restore original settings
|
||||
*/
|
||||
cleanup() {
|
||||
// Restore original environment
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
console.warn(`[test-environment] Cleanup warning: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CCS home directory (respects CCS_HOME env var)
|
||||
* This should be used instead of os.homedir() for CCS paths
|
||||
*
|
||||
* @returns {string} Home directory path
|
||||
*/
|
||||
function getCcsHome() {
|
||||
return process.env.CCS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CCS directory path
|
||||
*
|
||||
* @returns {string} Path to .ccs directory
|
||||
*/
|
||||
function getCcsDir() {
|
||||
return path.join(getCcsHome(), '.ccs');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTestEnvironment,
|
||||
getCcsHome,
|
||||
getCcsDir
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const GlmtTransformer = require('../bin/glmt-transformer');
|
||||
const DeltaAccumulator = require('../bin/delta-accumulator');
|
||||
const GlmtTransformer = require('../dist/glmt/glmt-transformer').default;
|
||||
const { DeltaAccumulator } = require('../dist/glmt/delta-accumulator');
|
||||
|
||||
/**
|
||||
* Token Counting Validation Tests
|
||||
|
||||
+42
-23
@@ -1,28 +1,47 @@
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { createTestEnvironment } = require('../fixtures/test-environment');
|
||||
|
||||
describe('npm CLI', () => {
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js');
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js');
|
||||
let testEnv;
|
||||
let testCcsHome;
|
||||
|
||||
before(() => {
|
||||
// Ensure CCS is installed and configured
|
||||
if (!fs.existsSync(configPath)) {
|
||||
const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js');
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
// Create isolated test environment
|
||||
testEnv = createTestEnvironment();
|
||||
testCcsHome = testEnv.testHome;
|
||||
|
||||
// Run postinstall to create config in test environment
|
||||
const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js');
|
||||
execSync(`node "${postinstallScript}"`, {
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, CCS_HOME: testCcsHome }
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Clean up test environment
|
||||
if (testEnv) {
|
||||
testEnv.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to run CLI with test environment
|
||||
function runCli(args, options = {}) {
|
||||
return execSync(`node "${ccsPath}" ${args}`, {
|
||||
...options,
|
||||
env: { ...process.env, CCS_HOME: testCcsHome }
|
||||
});
|
||||
}
|
||||
|
||||
describe('Argument parsing', () => {
|
||||
it('handles flag -c without profile error', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" -c`, { stdio: 'pipe' });
|
||||
runCli('-c', { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
// Should NOT show "Profile '-c' not found" error
|
||||
@@ -34,7 +53,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" --verbose`, { stdio: 'pipe' });
|
||||
runCli('--verbose', { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
assert(!output.includes("Profile '--verbose' not found"), 'Should not treat --verbose as profile');
|
||||
@@ -45,7 +64,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(10000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" -p "test prompt"`, { stdio: 'pipe', timeout: 8000 });
|
||||
runCli('-p "test prompt"', { stdio: 'pipe', timeout: 8000 });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
assert(!output.includes("Profile '-p' not found"), 'Should not treat -p as profile');
|
||||
@@ -56,7 +75,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" -c --verbose`, { stdio: 'pipe' });
|
||||
runCli('-c --verbose', { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
assert(!output.includes("Profile '-c' not found"), 'Should not treat flags as profiles');
|
||||
@@ -70,7 +89,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" glm --help`, { stdio: 'pipe' });
|
||||
runCli('glm --help', { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || '';
|
||||
assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist');
|
||||
@@ -81,7 +100,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" invalid-profile-name`, { stdio: 'pipe' });
|
||||
runCli('invalid-profile-name', { stdio: 'pipe' });
|
||||
assert(false, 'Should have thrown an error for invalid profile');
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
@@ -93,7 +112,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}" glm -c`, { stdio: 'pipe' });
|
||||
runCli('glm -c', { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || '';
|
||||
assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist');
|
||||
@@ -106,28 +125,28 @@ describe('npm CLI', () => {
|
||||
it('shows version with --version flag', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
const output = execSync(`node "${ccsPath}" --version`, { encoding: 'utf8' });
|
||||
const output = runCli('--version', { encoding: 'utf8' });
|
||||
assert(/\d+\.\d+\.\d+/.test(output), 'Should show version number');
|
||||
});
|
||||
|
||||
it('shows version with -v flag', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
const output = execSync(`node "${ccsPath}" -v`, { encoding: 'utf8' });
|
||||
const output = runCli('-v', { encoding: 'utf8' });
|
||||
assert(/\d+\.\d+\.\d+/.test(output), 'Should show version number');
|
||||
});
|
||||
|
||||
it('shows help with --help flag', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
const output = execSync(`node "${ccsPath}" --help`, { encoding: 'utf8' });
|
||||
const output = runCli('--help', { encoding: 'utf8' });
|
||||
assert(/usage|help|options/i.test(output), 'Should show help information');
|
||||
});
|
||||
|
||||
it('shows help with -h flag', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
const output = execSync(`node "${ccsPath}" -h`, { encoding: 'utf8' });
|
||||
const output = runCli('-h', { encoding: 'utf8' });
|
||||
assert(/usage|help|options/i.test(output), 'Should show help information');
|
||||
});
|
||||
});
|
||||
@@ -137,7 +156,7 @@ describe('npm CLI', () => {
|
||||
this.timeout(5000);
|
||||
|
||||
try {
|
||||
execSync(`node "${ccsPath}"`, { stdio: 'pipe' });
|
||||
runCli('', { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
// Should either succeed or fail gracefully with a helpful error
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
@@ -150,7 +169,7 @@ describe('npm CLI', () => {
|
||||
|
||||
const longArg = 'a'.repeat(1000);
|
||||
try {
|
||||
execSync(`node "${ccsPath}" "${longArg}"`, { stdio: 'pipe' });
|
||||
runCli(`"${longArg}"`, { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
// Should handle gracefully, not crash
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
@@ -158,4 +177,4 @@ describe('npm CLI', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,10 @@ const assert = require('assert');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Import the expandPath function from bin/utils/helpers.js
|
||||
// Import the expandPath function from dist/utils/helpers.js
|
||||
let expandPath;
|
||||
try {
|
||||
expandPath = require('../../bin/utils/helpers').expandPath;
|
||||
expandPath = require('../../dist/utils/helpers').expandPath;
|
||||
} catch (e) {
|
||||
// If helpers module doesn't exist or doesn't export expandPath, create a mock
|
||||
expandPath = function(p) {
|
||||
@@ -110,9 +110,9 @@ describe('cross-platform', () => {
|
||||
describe('npm package structure', () => {
|
||||
it('has required executable files', () => {
|
||||
const fs = require('fs');
|
||||
const binDir = path.join(__dirname, '..', '..', 'bin');
|
||||
const distDir = path.join(__dirname, '..', '..', 'dist');
|
||||
|
||||
assert(fs.existsSync(path.join(binDir, 'ccs.js')), 'ccs.js should exist in bin directory');
|
||||
assert(fs.existsSync(path.join(distDir, 'ccs.js')), 'ccs.js should exist in dist directory');
|
||||
});
|
||||
|
||||
it('has required script files', () => {
|
||||
|
||||
@@ -1,53 +1,56 @@
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createTestEnvironment } = require('../fixtures/test-environment');
|
||||
|
||||
describe('npm postinstall', () => {
|
||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
const glmPath = path.join(ccsDir, 'glm.settings.json');
|
||||
let testEnv;
|
||||
const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js');
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean slate before each test
|
||||
if (fs.existsSync(ccsDir)) {
|
||||
fs.rmSync(ccsDir, { recursive: true, force: true });
|
||||
}
|
||||
// Create isolated test environment for each test
|
||||
testEnv = createTestEnvironment();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// Cleanup after all tests
|
||||
if (fs.existsSync(ccsDir)) {
|
||||
fs.rmSync(ccsDir, { recursive: true, force: true });
|
||||
afterEach(() => {
|
||||
// Clean up test environment
|
||||
if (testEnv) {
|
||||
testEnv.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates config.json', () => {
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, {
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
||||
});
|
||||
|
||||
assert(fs.existsSync(configPath), 'config.json should be created');
|
||||
assert(testEnv.fileExists('config.json'), 'config.json should be created');
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const config = testEnv.readFile('config.json', true);
|
||||
assert(config.profiles, 'config.json should have profiles');
|
||||
assert(typeof config.profiles === 'object', 'profiles should be an object');
|
||||
});
|
||||
|
||||
it('creates glm.settings.json', () => {
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, {
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
||||
});
|
||||
|
||||
assert(fs.existsSync(glmPath), 'glm.settings.json should be created');
|
||||
assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created');
|
||||
|
||||
const glmSettings = JSON.parse(fs.readFileSync(glmPath, 'utf8'));
|
||||
const glmSettings = testEnv.readFile('glm.settings.json', true);
|
||||
assert(glmSettings.env, 'glm.settings.json should have env section');
|
||||
assert(glmSettings.env.ANTHROPIC_MODEL, 'should have ANTHROPIC_MODEL set');
|
||||
assert.strictEqual(glmSettings.env.ANTHROPIC_MODEL, 'glm-4.6');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const env = { ...process.env, CCS_HOME: testEnv.testHome };
|
||||
|
||||
// Run postinstall first time
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
||||
|
||||
// Create custom config
|
||||
const customConfig = {
|
||||
@@ -56,19 +59,22 @@ describe('npm postinstall', () => {
|
||||
glm: '~/.ccs/glm.settings.json'
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(customConfig, null, 2));
|
||||
testEnv.createFile('config.json', customConfig);
|
||||
|
||||
// Run postinstall again
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
|
||||
|
||||
// Verify custom config preserved
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const config = testEnv.readFile('config.json', true);
|
||||
assert(config.profiles.custom, 'Custom profile should be preserved');
|
||||
assert.strictEqual(config.profiles.custom, '~/.custom.json');
|
||||
});
|
||||
|
||||
it('uses ASCII symbols', () => {
|
||||
const output = execSync(`node "${postinstallScript}"`, { encoding: 'utf8' });
|
||||
const output = execSync(`node "${postinstallScript}"`, {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
||||
});
|
||||
|
||||
// Check for ASCII symbols [OK], [!], [X], [i] - not emojis
|
||||
assert(/\[(OK|!|X|i)\]/.test(output), 'Should use ASCII symbols, not emojis');
|
||||
@@ -80,23 +86,27 @@ describe('npm postinstall', () => {
|
||||
|
||||
it('handles existing directory gracefully', () => {
|
||||
// Create directory manually first
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'existing.txt'), 'exists');
|
||||
testEnv.createFile('existing.txt', 'exists');
|
||||
|
||||
// Run postinstall
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, {
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
||||
});
|
||||
|
||||
// Verify existing file still exists and new files are created
|
||||
assert(fs.existsSync(path.join(ccsDir, 'existing.txt')), 'Existing files should be preserved');
|
||||
assert(fs.existsSync(configPath), 'config.json should be created');
|
||||
assert(fs.existsSync(glmPath), 'glm.settings.json should be created');
|
||||
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
|
||||
assert(testEnv.fileExists('config.json'), 'config.json should be created');
|
||||
assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created');
|
||||
});
|
||||
|
||||
it('does not create VERSION file', () => {
|
||||
execSync(`node "${postinstallScript}"`, { stdio: 'ignore' });
|
||||
execSync(`node "${postinstallScript}"`, {
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, CCS_HOME: testEnv.testHome }
|
||||
});
|
||||
|
||||
const versionPath = path.join(ccsDir, 'VERSION');
|
||||
// The postinstall script doesn't create VERSION file (only native install does)
|
||||
assert(!fs.existsSync(versionPath), 'VERSION file should NOT be created by npm postinstall');
|
||||
assert(!testEnv.fileExists('VERSION'), 'VERSION file should NOT be created by npm postinstall');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
describe('integration: special commands', () => {
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js');
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js');
|
||||
|
||||
it('shows version with --version', () => {
|
||||
const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { expandPath } = require('../../../bin/utils/helpers');
|
||||
const { expandPath } = require('../../../dist/utils/helpers');
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('expandPath', () => {
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user