feat: add GLMT proxy and transformer tools

- Add GLMT proxy server for GLM model routing
- Add GLMT transformer for output format conversion
- Update CLI with new proxy and transformer commands
- Add comprehensive test suite for new functionality
- Update documentation and architecture guides
- Bump version and update changelog
This commit is contained in:
kaitranntt
2025-11-11 03:08:56 -05:00
parent daf075dc69
commit 657f99bfe8
21 changed files with 2597 additions and 347 deletions
+44
View File
@@ -4,6 +4,50 @@ All notable changes to CCS will be documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [3.3.0] - 2025-11-11
### Added
**GLMT Improvements**:
- Debug mode: `CCS_DEBUG_LOG=1` logs raw API request/response to `~/.ccs/logs/`
- Verbose flag support: `ccs glmt --verbose` shows detailed transformation info
- Config defaults: Added `alwaysThinkingEnabled`, temperature, timeouts, telemetry settings
- Reasoning detection verbose output: Shows length, preview, validation
- Config migration: v3.2.0 users auto-upgraded with API keys preserved
### Changed
**Log Cleanup**:
- Removed duplicate streaming warnings per request
- Added one-time startup info message
- Improved error messages with actionable troubleshooting steps
### Fixed
**Config Consistency**:
- GLMT profile now has `alwaysThinkingEnabled: true` (matches Kimi)
- Added optimal defaults for thinking mode (temperature 0.2, extended timeouts)
- Migration preserves user-modified values
### Documentation
**Troubleshooting Guide**:
- Clarified duplicate "Enchanting" lines are Claude CLI issue (out of CCS scope)
- Added debugging workflow for thinking visibility issues
- Documented new verbose and debug modes
- Added config customization examples
### Important Notes
**GLMT Implementation Status**:
-**Node.js version** (`bin/ccs.js`): Fully implemented and tested
-**Native shell versions** (`lib/ccs`, `lib/ccs.ps1`): Not yet implemented
- **Reason**: GLMT requires embedded proxy server (Node.js HTTP server)
- **Workaround**: Use npm package installation for GLMT support
- **Future**: Native shell GLMT support planned for future release
---
## [3.2.0] - 2025-11-10
### Changed
+210 -183
View File
@@ -6,141 +6,166 @@ Guidance for Claude Code when working with this repository.
CCS (Claude Code Switch): CLI wrapper for instant switching between multiple Claude accounts (work, personal, team) and alternative models (GLM 4.6, Kimi). Built on v3.1 login-per-profile architecture with shared data support.
**Installation**:
- npm: `npm install -g @kaitranntt/ccs` (recommended)
- macOS/Linux: `curl -fsSL ccs.kaitran.ca/install | bash`
- Windows: `irm ccs.kaitran.ca/install | iex`
**Core function**: Switch Claude accounts/models without manual config editing.
## Core Design Principles
## Design Principles
- **YAGNI**: No features "just in case"
- **KISS**: Simple bash/PowerShell/Node.js, no complexity
- **DRY**: One source of truth (config.json)
- **CLI-First UX**: Command-line is primary interface
- **CLI-First**: Command-line primary interface
Tool does ONE thing: instant switching between Claude accounts and alternative models.
### CLI Documentation (CRITICAL)
**All functionality changes MUST update `--help` in ALL implementations:**
- `bin/ccs.js` - handleHelpCommand()
- `lib/ccs` - show_help()
- `lib/ccs.ps1` - Show-Help
Non-negotiable. If not in `--help`, it doesn't exist for users.
## Key Constraints
## Critical Constraints
1. **NO EMOJIS** - ASCII only: [OK], [!], [X], [i]
2. **TTY-aware colors** - Respect NO_COLOR env var
3. **Install locations**:
- Unix: `~/.local/bin` (auto PATH, no sudo)
- Windows: `%USERPROFILE%\.ccs`
4. **Auto PATH config** - Detects bash/zsh/fish, adds automatically
4. **Auto PATH config** - Detect shell (bash/zsh/fish), add automatically
5. **Idempotent installs** - Safe to run multiple times
6. **Non-invasive** - Never modify `~/.claude/settings.json`
7. **Cross-platform parity** - Identical behavior everywhere
8. **Graceful error handling** - See tests/edge-cases.sh
8. **CLI documentation** - ALL changes MUST update `--help` in bin/ccs.js, lib/ccs, lib/ccs.ps1
## Architecture
### v3.1 Features
### v3.2 GLMT Profile
**Login-Per-Profile**: Each profile = isolated Claude instance via `ccs auth create <profile>`. No credential copying/encryption.
**Implementation**: Embedded HTTP proxy converts Anthropic ↔ OpenAI formats
**Profile Types**:
1. **Settings-based**: GLM, Kimi, default - uses `--settings` flag
2. **Account-based**: work, personal, team - uses `CLAUDE_CONFIG_DIR`
**[!] Important**: GLMT only available in Node.js version (`bin/ccs.js`). Native shell versions (`lib/ccs`, `lib/ccs.ps1`) do not support GLMT yet (requires HTTP server).
**Shared Data** (v3.1): commands/, skills/, agents/ symlinked from `~/.ccs/shared/`
**Flow**:
1. User: `ccs glmt "solve problem"`
2. `bin/ccs.js` spawns `bin/glmt-proxy.js` on localhost random port
3. Modifies `glmt.settings.json`: `ANTHROPIC_BASE_URL=http://127.0.0.1:<port>`
4. Spawns Claude CLI with modified settings
5. Proxy intercepts requests:
- `glmt-transformer.js` converts Anthropic → OpenAI format
- Injects reasoning parameters (`reasoning: true`, `reasoning_effort`)
- Forwards to `api.z.ai/api/coding/paas/v4/chat/completions`
- Converts response: `reasoning_content` → thinking blocks
- Returns Anthropic format to Claude CLI
6. Thinking blocks appear in Claude Code UI
**Concurrent Sessions**: Multiple profiles run simultaneously via isolated config dirs.
**Files**:
- `bin/glmt-proxy.js` (275 lines): HTTP proxy server
- `bin/glmt-transformer.js` (267 lines): Format conversion
- `config/base-glmt.settings.json`: Template with Z.AI endpoint
- `tests/glmt-transformer.test.js` (285 lines): Unit tests
**Implementations**:
- npm package: Pure Node.js (bin/ccs.js) using child_process.spawn
- Traditional: bash (lib/ccs) or PowerShell (lib/ccs.ps1)
**Control tags**:
- `<Thinking:On|Off>` - Enable/disable reasoning
- `<Effort:Low|Medium|High>` - Control reasoning depth
### File Structure
**Limitations**:
- Streaming not supported (buffered mode only)
- Requires Z.AI API key with coding plan access
- Proxy lifecycle tied to Claude CLI
### v3.1 Shared Data
**Commands/skills/agents symlinked from `~/.ccs/shared/`** - no duplication across profiles.
```
~/.ccs/
├── shared/ # Shared across all profiles
│ ├── commands/
│ ├── skills/
│ └── agents/
├── instances/ # Profile-specific
│ └── work/
│ ├── commands@ → shared/commands/
│ ├── skills@ → shared/skills/
│ ├── agents@ → shared/agents/
│ ├── settings.json # API keys, credentials
│ ├── sessions/ # Conversation history
│ ├── todolists/
│ └── logs/
```
**Shared**: commands/, skills/, agents/
**Profile-specific**: settings.json, sessions/, todolists/, logs/
**Windows fallback**: Copies dirs if symlinks unavailable (enable Developer Mode for symlinks)
### Profile Types
**Settings-based**: GLM, GLMT, Kimi, default
- GLM: Uses `--settings` flag (Anthropic endpoint, no thinking)
- GLMT: Embedded proxy (OpenAI endpoint, thinking enabled)
- Kimi: Uses `--settings` flag
**Account-based**: work, personal, team
- Uses `CLAUDE_CONFIG_DIR` for isolated instances
- Create: `ccs auth create <profile>`
### Concurrent Sessions
Multiple profiles run simultaneously via isolated config dirs.
## File Structure
**Key Files**:
- `package.json`: npm manifest + postinstall
- `bin/ccs.js`: Node.js entry point
- `bin/instance-manager.js`: Instance orchestration
- `bin/shared-manager.js`: Shared data symlinks (v3.1)
- `bin/glmt-proxy.js`: Embedded HTTP proxy (v3.2)
- `bin/glmt-transformer.js`: Anthropic ↔ OpenAI conversion (v3.2)
- `scripts/postinstall.js`: Auto-creates configs (idempotent)
- `lib/ccs` / `lib/ccs.ps1`: Platform-specific executables
- `installers/*.sh` / `installers/*.ps1`: Install/uninstall scripts
- `lib/ccs`: bash executable
- `lib/ccs.ps1`: PowerShell executable
- `installers/*.sh|*.ps1`: Install/uninstall scripts
- `tests/glmt-transformer.test.js`: GLMT unit tests
- `VERSION`: Version source of truth (MAJOR.MINOR.PATCH)
- `.claude/`: Commands/skills for Claude Code
**Executables**:
- Unix: `~/.local/bin/ccs``~/.ccs/ccs`
- Windows: `%USERPROFILE%\.ccs\ccs.ps1`
**Config Directory** (v3.1):
```
~/.ccs/
├── ccs / ccs.ps1 # Executable
├── config.json # Settings-based profiles
├── profiles.json # Account-based profiles
├── shared/ # Shared across all (v3.1)
│ ├── commands/ # Slash commands
│ ├── skills/ # Claude skills
│ └── agents/ # Agent configs
├── instances/ # Isolated per-profile
│ └── work/
│ ├── commands@ → shared/commands/ # Symlink
│ ├── skills@ → shared/skills/ # Symlink
│ ├── agents@ → shared/agents/ # Symlink
│ ├── settings.json # Profile-specific
│ ├── sessions/ # Profile-specific
│ ├── todolists/ # Profile-specific
│ └── logs/ # Profile-specific
├── glm.settings.json # GLM template
├── kimi.settings.json # Kimi template
└── .claude/ # Integration
```
**Config Files**:
- `~/.ccs/config.json`: Settings-based profiles
- `~/.ccs/profiles.json`: Account-based profiles
- `~/.ccs/glm.settings.json`: GLM template (Anthropic endpoint)
- `~/.ccs/glmt.settings.json`: GLMT template (proxy + thinking)
- `~/.ccs/kimi.settings.json`: Kimi template
**v3.1 Data Structure**:
- **Shared**: commands/, skills/, agents/ → symlinked from `~/.ccs/shared/`
- **Profile-specific**: settings.json, sessions/, todolists/, logs/
- **Windows**: Copies dirs if symlinks fail (enable Developer Mode for symlinks)
## Implementations
### Technical Implementation
**npm package**: Pure Node.js (`bin/ccs.js`) using `child_process.spawn`
**Account-Based Profiles**:
```bash
# Create profile
ccs auth create work # Opens Claude CLI for login
**Traditional install**: bash (`lib/ccs`) or PowerShell (`lib/ccs.ps1`)
# Usage
CLAUDE_CONFIG_DIR=~/.ccs/instances/work claude [args]
```
## Code Standards
**Shared Data** (v3.1):
```javascript
// bin/shared-manager.js
ensureSharedDirectories() // Creates shared/{commands,skills,agents}
linkSharedDirectories(path) // Symlinks instance to shared
migrateToSharedStructure() // Auto-migrates v3.0 (idempotent)
```
### Bash
- Compatibility: bash 3.2+
- Quote vars: `"$VAR"` not `$VAR`
- Tests: `[[ ]]` not `[ ]`
- Shebang: `#!/usr/bin/env bash`
- Safety: `set -euo pipefail`
- Dependency: `jq` only
**Migration** (v3.0 → v3.1):
1. Detect if `~/.ccs/shared/` exists (skip if present)
2. Create `~/.ccs/shared/{commands,skills,agents}`
3. Copy from `~/.claude/` (preserves data)
4. Symlink all instances to shared
5. Windows: Copy dirs if symlinks fail
### PowerShell
- Compatibility: PowerShell 5.1+
- `$ErrorActionPreference = "Stop"`
- Native JSON: ConvertFrom-Json / ConvertTo-Json
- No external dependencies
**Settings-Based Profiles**:
```bash
claude --settings ~/.ccs/glm.settings.json [args]
```
### Node.js
- Compatibility: Node.js 14+
- `child_process.spawn` for Claude CLI
- Handle SIGINT/SIGTERM
- `path` module for cross-platform paths
**Profile Detection**:
1. Check `profiles.json` (account-based) → use `CLAUDE_CONFIG_DIR`
2. Check `config.json` (settings-based) → use `--settings`
3. Not found → show error + available profiles
### Terminal Output
- TTY detect: `[[ -t 2 ]]` before colors
- Respect `NO_COLOR` env var
- ASCII only: [OK], [!], [X], [i]
- Errors: Box borders (╔═╗║╚╝)
- Colors: Disable when not TTY
## Development
@@ -171,8 +196,6 @@ rm -rf ~/.ccs # Clean environment
### Publishing
```bash
# First-time: npm login, add NPM_TOKEN to GitHub Secrets
# Release workflow
./scripts/bump-version.sh patch
git add VERSION package.json lib/* installers/*
@@ -184,106 +207,26 @@ git push origin main && git push origin vX.Y.Z # Triggers CI
npm publish --dry-run && npm publish --access public
```
## Code Standards
### Bash
- Compatibility: bash 3.2+
- Quote vars: `"$VAR"` not `$VAR`
- Tests: `[[ ]]` not `[ ]`
- Shebang: `#!/usr/bin/env bash`
- Safety: `set -euo pipefail`
- Dependency: `jq` only
### Terminal Output
- TTY detect: `[[ -t 2 ]]` before colors
- Respect `NO_COLOR` env var
- ASCII only: [OK], [!], [X], [i]
- Errors: Box borders (╔═╗║╚╝)
- Colors: Disable when not TTY
### PowerShell
- Compatibility: PowerShell 5.1+
- `$ErrorActionPreference = "Stop"`
- Native JSON: ConvertFrom-Json / ConvertTo-Json
- No external dependencies
### Node.js
- Compatibility: Node.js 14+
- `child_process.spawn` for Claude CLI
- Handle SIGINT/SIGTERM
- `path` module for cross-platform paths
### Versioning
Update all three atomically via `./scripts/bump-version.sh`:
1. `VERSION`
2. `installers/install.sh` (CCS_VERSION)
3. `installers/install.ps1` ($CcsVersion)
## Implementation Details
### Profile Detection
- No args OR first arg starts with `-` → default profile
- First arg no `-` → profile name
- Special flags first: `--version`, `-v`, `--help`, `-h`
- `ccs auth create <profile>` → create account-based profile
### Installation Modes
- **Git**: Cloned repo (symlinks executables)
- **Standalone**: curl/irm (downloads from GitHub)
- Detection: Check if `ccs` exists in script dir/parent
### Idempotency
Install scripts safe to run multiple times:
- Check existing files before create
- Single backup: `config.json.backup` (no timestamps)
- Skip existing `.claude/` install
- Handle clean + existing installs
### Settings Format
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "key",
"ANTHROPIC_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.6"
}
}
```
All values = strings (not booleans/objects) to prevent PowerShell crashes.
### Profile Files
**profiles.json** (account-based):
```json
{"profiles": {"work": "~/.ccs/instances/work"}}
```
**config.json** (settings-based):
```json
{"profiles": {"glm": "~/.ccs/glm.settings.json"}}
```
## Common Tasks
### New Feature
1. Verify YAGNI/KISS/DRY alignment
2. Implement for bash/PowerShell/Node.js
3. **Update `--help` in ALL three** (bin/ccs.js, lib/ccs, lib/ccs.ps1) - REQUIRED
3. **REQUIRED**: Update `--help` in bin/ccs.js, lib/ccs, lib/ccs.ps1
4. Test on macOS/Linux/Windows
5. Update tests/edge-cases.*
6. Update CONTRIBUTING.md if needed
7. Update README.md if user-facing
6. Update README.md if user-facing
### Bug Fix
1. Add test case reproducing bug
2. Fix in bash/PowerShell/Node.js
3. Verify no regression
4. Test all platforms
### Release
1. `./scripts/bump-version.sh [major|minor|patch]`
2. Review VERSION, install scripts
3. Test git + standalone modes
@@ -308,15 +251,93 @@ Before PR:
- [ ] No PATH duplication
- [ ] Manual PATH instructions clear
- [ ] Concurrent sessions work
- [ ] Instance isolation (no contamination)
- [ ] `--help` updated in bin/ccs.js, lib/ccs, lib/ccs.ps1 (if feature changed)
- [ ] Instance isolation
- [ ] `--help` updated in bin/ccs.js, lib/ccs, lib/ccs.ps1
- [ ] `--help` consistent across all three
## Claude Code Integration
## Technical Details
`.claude/` contains:
- `/ccs` command: Task delegation to different models
- `ccs-delegation` skill: Delegation patterns
### Profile Detection
1. Check `profiles.json` (account-based) → use `CLAUDE_CONFIG_DIR`
2. Check `config.json` (settings-based) → use `--settings`
3. Not found → show error + available profiles
### Installation Modes
- **Git**: Cloned repo (symlinks executables)
- **Standalone**: curl/irm (downloads from GitHub)
- Detection: Check if `ccs` exists in script dir/parent
### Idempotency
Install scripts safe to run multiple times:
- Check existing files before create
- Single backup: `config.json.backup` (no timestamps)
- Skip existing `.claude/` install
- Handle clean + existing installs
### Settings Format
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "key",
"ANTHROPIC_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.6"
}
}
```
All values = strings (not booleans/objects) to prevent PowerShell crashes.
### Profile Files
**profiles.json** (account-based):
```json
{"profiles": {"work": "~/.ccs/instances/work"}}
```
**config.json** (settings-based):
```json
{"profiles": {"glm": "~/.ccs/glm.settings.json"}}
```
## GLMT Troubleshooting
**API Key Issues**:
```bash
# Error: GLMT profile requires Z.AI API key
# Fix: Edit ~/.ccs/glmt.settings.json, set ANTHROPIC_AUTH_TOKEN
```
**Proxy Failures**:
- Timeout (>30s): Proxy didn't start → check Node.js ≥14
- Port conflicts: Uses random port, unlikely
- Connection refused: Firewall blocking 127.0.0.1
**No Thinking Blocks**:
- Check Z.AI API plan supports reasoning_content
- Verify `<Thinking:On>` tag not overridden
- Test with `ccs glm` (no thinking) to isolate proxy issues
**Streaming Disclaimer**:
- GLMT uses buffered mode (streaming not supported)
- Trade-off: thinking capability vs streaming speed
**Debug Mode**:
```bash
# Verbose logging
ccs glmt --verbose "test"
# File logging
export CCS_DEBUG_LOG=1
ccs glmt --verbose "test"
# Logs: ~/.ccs/logs/
```
## Error Handling
@@ -325,3 +346,9 @@ Before PR:
- Suggest recovery steps
- Never leave broken state
- Guide to `ccs auth create` if profile missing
## Claude Code Integration
`.claude/` contains:
- `/ccs` command: Task delegation to different models
- `ccs-delegation` skill: Delegation patterns
+138 -135
View File
@@ -30,7 +30,7 @@ Stop hitting rate limits. Keep working continuously.
claude /login
```
### Primary Installation Methods
### Installation
#### Option 1: npm Package (Recommended)
@@ -75,6 +75,7 @@ irm ccs.kaitran.ca/install | iex
{
"profiles": {
"glm": "~/.ccs/glm.settings.json",
"glmt": "~/.ccs/glmt.settings.json",
"kimi": "~/.ccs/kimi.settings.json",
"default": "~/.claude/settings.json"
}
@@ -106,19 +107,23 @@ $env:CCS_CLAUDE_PATH = "D:\Tools\Claude\claude.exe" # Windows
### Your First Switch
> **⚠️ Important**: Before using GLM or Kimi profiles, you need to update your API keys in their respective settings files:
> **⚠️ Important**: Before using GLM/GLMT or Kimi profiles, update API keys in settings files:
> - **GLM**: Edit `~/.ccs/glm.settings.json` and add your GLM API key
> - **GLMT**: Edit `~/.ccs/glmt.settings.json` and add your Z.AI API key (requires coding plan)
> - **Kimi**: Edit `~/.ccs/kimi.settings.json` and add your Kimi API key
```bash
# Use Claude subscription (default) for high-level planning
ccs "Plan the implementation of a microservices architecture"
# Default Claude subscription
ccs "Plan microservices architecture"
# Switch to GLM for cost-optimized tasks
ccs glm "Create a simple REST API"
# Switch to GLM (cost-optimized)
ccs glm "Create REST API"
# Switch to Kimi for its thinking capabilities
ccs kimi "Write integration tests with proper error handling"
# GLM with thinking mode
ccs glmt "Solve algorithmic problem"
# Kimi for coding
ccs kimi "Write integration tests"
```
---
@@ -149,186 +154,183 @@ Manual context switching breaks your workflow. **CCS manages it seamlessly**.
</div>
**The Solution**:
```bash
ccs work # Use company Claude account
ccs personal # Switch to personal Claude account
ccs glm # Switch to GLM for cost-effective tasks
ccs kimi # Switch to Kimi for alternative option
# Hit rate limit? Switch instantly:
ccs glm # Continue working with GLM
# Need different company account?
ccs work-2 # Switch to second company account
```
---
## 📁 Shared Data Architecture
## Architecture
**v3.1 Shared Global Data**: Commands and skills are symlinked across all profiles via `~/.ccs/shared/`, eliminating duplication.
### Profile Types
**Settings-based**: GLM, GLMT, Kimi, default
- Uses `--settings` flag pointing to config files
- GLMT: Embedded proxy for thinking mode support
**Account-based**: work, personal, team
- Uses `CLAUDE_CONFIG_DIR` for isolated instances
- Create with `ccs auth create <profile>`
### Shared Data (v3.1)
Commands and skills symlinked from `~/.ccs/shared/` - no duplication across profiles.
**Directory Structure**:
```
~/.ccs/
├── shared/ # Shared across all profiles
│ ├── commands/ # Custom slash commands
── skills/ # Claude Code skills
│ ├── agents/
── commands/
│ └── skills/
├── instances/ # Profile-specific data
── work/
├── commands@ → ~/.ccs/shared/commands/ # Symlink
├── skills@ → ~/.ccs/shared/skills/ # Symlink
├── settings.json # Profile-specific config
── sessions/ # Profile-specific sessions
└── personal/
── work/
├── agents@ → shared/agents/
├── commands@ → shared/commands/
├── skills@ → shared/skills/
── settings.json # API keys, credentials
└── sessions/ # Conversation history
│ └── ...
```
**Benefits**:
- No duplication of commands/skills across profiles
- Single source of truth for shared resources
- Automatic migration from v3.0 (runs on first use)
- Windows fallback: copies if symlinks unavailable (enable Developer Mode for true symlinks)
**Shared**: commands/, skills/, agents/
**Profile-specific**: settings.json, sessions/, todolists/, logs/
**What's Shared**:
- `.claude/commands/` - Custom slash commands
- `.claude/skills/` - Claude Code skills
**What's Profile-Specific**:
- `settings.json` - API keys, credentials
- `sessions/` - Conversation history
- `todolists/` - Task tracking
- `logs/` - Profile-specific logs
**[i] Windows**: Copies dirs if symlinks unavailable (enable Developer Mode for true symlinks)
---
## 🏗️ Architecture Overview
## GLM with Thinking (GLMT)
**v3.0 Login-Per-Profile Model**: Each profile is an isolated Claude instance where users login directly. No credential copying or vault encryption.
> **[!] Important**: GLMT requires npm installation (`npm install -g @kaitranntt/ccs`). Not available in native shell versions (requires Node.js HTTP server).
```mermaid
flowchart TD
subgraph "User Input"
USER["User runs: ccs &lt;profile&gt; [args...]"]
end
### GLM vs GLMT
subgraph "Profile Detection Engine"
DETECT[ProfileDetector]
PROFILE_CHECK{Profile exists?}
| Feature | GLM (`ccs glm`) | GLMT (`ccs glmt`) |
|---------|-----------------|-------------------|
| **Endpoint** | Anthropic-compatible | OpenAI-compatible |
| **Thinking** | No | Yes (reasoning_content) |
| **Streaming** | Yes | No (buffered) |
| **Use Case** | Fast responses | Complex reasoning |
subgraph "Profile Types"
SETTINGS["Settings-based<br/>glm, kimi, default"]
ACCOUNT["Account-based<br/>work, personal, team"]
end
end
### How It Works
subgraph "CCS Core Processing"
CONFIG["Read config.json<br/>and profiles.json"]
1. CCS spawns embedded HTTP proxy on localhost
2. Proxy converts Anthropic format → OpenAI format
3. Forwards to Z.AI with reasoning parameters
4. Converts `reasoning_content` → thinking blocks
5. Thinking appears in Claude Code UI
subgraph "Profile Handlers"
SETTINGS_MGR["SettingsManager<br/>→ --settings flag"]
INSTANCE_MGR["InstanceManager<br/>→ CLAUDE_CONFIG_DIR"]
end
end
### Control Tags
subgraph "Claude CLI Execution"
CLAUDE_DETECT["Claude CLI Detection<br/>CCS_CLAUDE_PATH support"]
- `<Thinking:On|Off>` - Enable/disable reasoning blocks (default: On)
- `<Effort:Low|Medium|High>` - Control reasoning depth (default: Medium)
subgraph "Execution Methods"
SETTINGS_EXEC["claude --settings &lt;path&gt;"]
INSTANCE_EXEC["CLAUDE_CONFIG_DIR=&lt;instance&gt; claude"]
end
end
### API Key Setup
subgraph "API Layer"
API["API Response<br/>Claude Sonnet 4.5<br/>GLM 4.6<br/>Kimi K2 Thinking"]
end
```bash
# Edit GLMT settings
nano ~/.ccs/glmt.settings.json
%% Flow connections
USER --> DETECT
DETECT --> PROFILE_CHECK
PROFILE_CHECK -->|Yes| SETTINGS
PROFILE_CHECK -->|Yes| ACCOUNT
SETTINGS --> CONFIG
ACCOUNT --> CONFIG
CONFIG --> SETTINGS_MGR
CONFIG --> INSTANCE_MGR
SETTINGS_MGR --> SETTINGS_EXEC
INSTANCE_MGR --> INSTANCE_EXEC
SETTINGS_EXEC --> CLAUDE_DETECT
INSTANCE_EXEC --> CLAUDE_DETECT
CLAUDE_DETECT --> API
# Set Z.AI API key (requires coding plan)
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "your-z-ai-api-key"
}
}
```
---
### Debugging
## ⚡ Features
- **Instant Switching** - `ccs glm` switches to GLM, no config editing
- **Concurrent Sessions** - Run multiple profiles simultaneously in different terminals
- **Isolated Instances** - Each profile gets own config (`~/.ccs/instances/<profile>/`)
- **Cross-Platform** - macOS, Linux, Windows - identical behavior
- **Zero Downtime** - Switch instantly, no workflow interruption
---
## 💻 Usage Examples
### Basic Profile Switching
**Enable verbose logging**:
```bash
ccs # Use Claude subscription (default)
ccs glm # Use GLM fallback
ccs kimi # Use Kimi for Coding
ccs --version # Show CCS version and install location
ccs glmt --verbose "your prompt"
```
### Concurrent Sessions (Multi-Account)
**Enable debug file logging**:
```bash
# Create multiple Claude accounts
ccs auth create work # Company account
ccs auth create personal # Personal account
ccs auth create team # Team account
export CCS_DEBUG_LOG=1
ccs glmt --verbose "your prompt"
# Logs: ~/.ccs/logs/
```
# Terminal 1 - Work account
**Check reasoning content**:
```bash
cat ~/.ccs/logs/*response-openai.json | jq '.choices[0].message.reasoning_content'
```
**If absent**: Z.AI API issue (verify key, account status)
**If present**: Transformation issue (check response-anthropic.json)
---
## Usage Examples
### Basic Switching
```bash
ccs # Claude subscription (default)
ccs glm # GLM (no thinking)
ccs glmt # GLM with thinking
ccs kimi # Kimi for Coding
ccs --version # Show version
```
### Multi-Account Setup
```bash
# Create accounts
ccs auth create work
ccs auth create personal
# Terminal 1
ccs work "implement feature"
# Terminal 2 - Personal account (runs concurrently)
# Terminal 2 (concurrent)
ccs personal "review code"
```
### Custom Claude CLI Path
Non-standard installation location:
```bash
export CCS_CLAUDE_PATH="/path/to/claude" # Unix
$env:CCS_CLAUDE_PATH = "D:\Tools\Claude\claude.exe" # Windows
```
See [Troubleshooting Guide](./docs/en/troubleshooting.md#claude-cli-in-non-standard-location)
---
### 🗑️ Uninstall
## Configuration
Auto-created during installation via npm postinstall script.
**~/.ccs/config.json**:
```json
{
"profiles": {
"glm": "~/.ccs/glm.settings.json",
"glmt": "~/.ccs/glmt.settings.json",
"kimi": "~/.ccs/kimi.settings.json",
"default": "~/.claude/settings.json"
}
}
```
Complete guide: [docs/en/configuration.md](./docs/en/configuration.md)
---
## Uninstall
**Package Managers**
```bash
# npm
npm uninstall -g @kaitranntt/ccs
# yarn
yarn global remove @kaitranntt/ccs
# pnpm
pnpm remove -g @kaitranntt/ccs
# bun
bun remove -g @kaitranntt/ccs
```
**Official Uninstaller**
**macOS / Linux**
```bash
# macOS / Linux
curl -fsSL ccs.kaitran.ca/uninstall | bash
```
**Windows PowerShell**
```powershell
# Windows
irm ccs.kaitran.ca/uninstall | iex
```
@@ -348,6 +350,7 @@ irm ccs.kaitran.ca/uninstall | iex
- [Installation Guide](./docs/en/installation.md)
- [Configuration](./docs/en/configuration.md)
- [Usage Examples](./docs/en/usage.md)
- [System Architecture](./docs/system-architecture.md)
- [Troubleshooting](./docs/en/troubleshooting.md)
- [Contributing](./CONTRIBUTING.md)
+1 -1
View File
@@ -1 +1 @@
3.2.0
3.3.0
+120 -4
View File
@@ -108,6 +108,8 @@ function handleHelpCommand() {
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('');
@@ -212,6 +214,114 @@ function detectProfile(args) {
}
}
// Execute Claude CLI with embedded proxy (for GLMT profile)
async function execClaudeWithProxy(claudeCli, profileName, args) {
const { getSettingsPath } = require('./config-manager');
// 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-proxy.js');
const proxyArgs = verbose ? ['--verbose'] : [];
const proxy = spawn('node', [proxyPath, ...proxyArgs], {
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit']
});
// 3. Wait for proxy ready signal (with timeout)
let port;
try {
port = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Proxy startup timeout (5s)'));
}, 5000);
proxy.stdout.on('data', (data) => {
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}`));
}
});
});
} catch (error) {
console.error('[X] Failed to start GLMT proxy:', error.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 = {
...process.env,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: 'glm-4.6'
};
const claude = spawn(claudeCli, args, {
stdio: 'inherit',
env: envVars
});
// 5. Cleanup: kill proxy when Claude exits
claude.on('exit', (code, signal) => {
proxy.kill('SIGTERM');
if (signal) process.kill(process.pid, signal);
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 (use .once to avoid duplicates)
process.once('SIGTERM', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
process.once('SIGINT', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
}
// Main execution
async function main() {
const args = process.argv.slice(2);
@@ -284,10 +394,16 @@ async function main() {
const profileInfo = detector.detectProfileType(profile);
if (profileInfo.type === 'settings') {
// EXISTING FLOW: Settings-based profile (glm, kimi)
// Use --settings flag (backward compatible)
const expandedSettingsPath = getSettingsPath(profileInfo.name);
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs]);
// 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
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const https = require('https');
const GlmtTransformer = require('./glmt-transformer');
/**
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
*
* Architecture:
* - Intercepts Claude CLI → Z.AI calls
* - Transforms Anthropic format → OpenAI format
* - Converts reasoning_content → thinking blocks
* - Buffered mode only (streaming not supported)
*
* Lifecycle:
* - Spawned by bin/ccs.js when 'glmt' profile detected
* - Binds to 127.0.0.1:random_port (security + avoid conflicts)
* - Terminates when parent process exits
*
* Debugging:
* - Verbose: Pass --verbose to see request/response logs
* - Debug: Set CCS_DEBUG_LOG=1 to write logs to ~/.ccs/logs/
*
* Usage:
* const proxy = new GlmtProxy({ verbose: true });
* await proxy.start();
*/
class GlmtProxy {
constructor(config = {}) {
this.transformer = new GlmtTransformer({ verbose: config.verbose });
this.upstreamUrl = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
this.server = null;
this.port = null;
this.verbose = config.verbose || false;
this.timeout = config.timeout || 120000; // 120s default
}
/**
* Start HTTP server on random port
* @returns {Promise<number>} Port number
*/
async start() {
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
// 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;
// Signal parent process
console.log(`PROXY_READY:${this.port}`);
// One-time info message (always shown)
console.error(`[glmt] Proxy listening on port ${this.port} (buffered mode)`);
// Debug mode notice
if (this.transformer.debugLog) {
console.error(`[glmt] Debug logging enabled: ${this.transformer.debugLogDir}`);
console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
}
this.log(`Verbose logging enabled`);
resolve(this.port);
});
this.server.on('error', (error) => {
console.error('[glmt-proxy] Server error:', error);
reject(error);
});
});
}
/**
* Handle incoming HTTP request
* @param {http.IncomingMessage} req - Request
* @param {http.ServerResponse} res - Response
*/
async handleRequest(req, res) {
const startTime = Date.now();
this.log(`Request: ${req.method} ${req.url}`);
try {
// Only accept POST requests
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
// Read request body
const body = await this._readBody(req);
this.log(`Request body size: ${body.length} bytes`);
// Parse JSON with error handling
let anthropicRequest;
try {
anthropicRequest = JSON.parse(body);
} catch (jsonError) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
type: 'invalid_request_error',
message: 'Invalid JSON in request body: ' + jsonError.message
}
}));
return;
}
// Transform to OpenAI format
const { openaiRequest, thinkingConfig } =
this.transformer.transformRequest(anthropicRequest);
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
// Forward to Z.AI
const openaiResponse = await this._forwardToUpstream(
openaiRequest,
req.headers
);
this.log(`Received response from upstream`);
// Transform back to Anthropic format
const anthropicResponse = this.transformer.transformResponse(
openaiResponse,
thinkingConfig
);
// Return to Claude CLI
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify(anthropicResponse));
const duration = Date.now() - startTime;
this.log(`Request completed in ${duration}ms`);
} catch (error) {
console.error('[glmt-proxy] Request error:', error.message);
const duration = Date.now() - startTime;
this.log(`Request failed after ${duration}ms: ${error.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
type: 'proxy_error',
message: error.message
}
}));
}
}
/**
* Read request body
* @param {http.IncomingMessage} req - Request
* @returns {Promise<string>} Body content
* @private
*/
_readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
const maxSize = 10 * 1024 * 1024; // 10MB limit
let totalSize = 0;
req.on('data', chunk => {
totalSize += chunk.length;
if (totalSize > maxSize) {
reject(new Error('Request body too large (max 10MB)'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
/**
* 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) {
return new Promise((resolve, reject) => {
const url = new URL(this.upstreamUrl);
const requestBody = JSON.stringify(openaiRequest);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: '/api/coding/paas/v4/chat/completions', // OpenAI-compatible endpoint
method: 'POST',
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'
}
};
// Debug logging
this.log(`Forwarding to: ${url.hostname}${options.path}`);
// Set timeout
const timeoutHandle = setTimeout(() => {
req.destroy();
reject(new Error('Upstream request timeout'));
}, this.timeout);
const req = https.request(options, (res) => {
clearTimeout(timeoutHandle);
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
try {
const body = Buffer.concat(chunks).toString();
this.log(`Upstream response size: ${body.length} bytes`);
// Check for non-200 status
if (res.statusCode !== 200) {
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));
}
});
});
req.on('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
req.write(requestBody);
req.end();
});
}
/**
* Stop proxy server
*/
stop() {
if (this.server) {
this.log('Stopping proxy server');
this.server.close();
}
}
/**
* Log message if verbose
* @param {string} message - Message to log
* @private
*/
log(message) {
if (this.verbose) {
console.error(`[glmt-proxy] ${message}`);
}
}
}
// Main entry point
if (require.main === module) {
const args = process.argv.slice(2);
const verbose = args.includes('--verbose') || args.includes('-v');
const proxy = new GlmtProxy({ verbose });
proxy.start().catch(error => {
console.error('[glmt-proxy] Failed to start:', error);
process.exit(1);
});
// Cleanup on signals
process.on('SIGTERM', () => {
proxy.stop();
process.exit(0);
});
process.on('SIGINT', () => {
proxy.stop();
process.exit(0);
});
// Keep process alive
process.on('uncaughtException', (error) => {
console.error('[glmt-proxy] Uncaught exception:', error);
proxy.stop();
process.exit(1);
});
}
module.exports = GlmtProxy;
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking support
*
* Features:
* - Request: Anthropic → OpenAI (inject reasoning params)
* - Response: OpenAI reasoning_content → Anthropic thinking blocks
* - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG_LOG=1)
* - Verbose mode: Console logging with timestamps
* - Validation: Self-test transformation results
*
* Usage:
* const transformer = new GlmtTransformer({ verbose: true, debugLog: true });
* const { openaiRequest, thinkingConfig } = transformer.transformRequest(req);
* const anthropicResponse = transformer.transformResponse(resp, thinkingConfig);
*
* Control Tags (in user prompt):
* <Thinking:On|Off> - Enable/disable reasoning
* <Effort:Low|Medium|High> - Control reasoning depth
*/
class GlmtTransformer {
constructor(config = {}) {
this.defaultThinking = config.defaultThinking ?? true;
this.verbose = config.verbose || false;
this.debugLog = config.debugLog ?? process.env.CCS_DEBUG_LOG === '1';
this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
this.modelMaxTokens = {
'GLM-4.6': 128000,
'GLM-4.5': 96000,
'GLM-4.5-air': 16000
};
}
/**
* Transform Anthropic request to OpenAI format
* @param {Object} anthropicRequest - Anthropic Messages API request
* @returns {Object} { openaiRequest, thinkingConfig }
*/
transformRequest(anthropicRequest) {
// Log original request
this._writeDebugLog('request-anthropic', anthropicRequest);
try {
// 1. Extract thinking control from messages
const thinkingConfig = this._extractThinkingControl(
anthropicRequest.messages || []
);
this.log(`Extracted thinking control: ${JSON.stringify(thinkingConfig)}`);
// 2. Map model
const glmModel = this._mapModel(anthropicRequest.model);
// 3. Convert to OpenAI format
const openaiRequest = {
model: glmModel,
messages: this._sanitizeMessages(anthropicRequest.messages || []),
max_tokens: this._getMaxTokens(glmModel),
stream: anthropicRequest.stream ?? false
};
// 4. Preserve optional parameters
if (anthropicRequest.temperature !== undefined) {
openaiRequest.temperature = anthropicRequest.temperature;
}
if (anthropicRequest.top_p !== undefined) {
openaiRequest.top_p = anthropicRequest.top_p;
}
// 5. Handle streaming (not yet supported)
// Silently override to buffered mode
if (anthropicRequest.stream) {
openaiRequest.stream = false;
}
// 6. Inject reasoning parameters
this._injectReasoningParams(openaiRequest, thinkingConfig);
// Log transformed request
this._writeDebugLog('request-openai', openaiRequest);
return { openaiRequest, thinkingConfig };
} catch (error) {
console.error('[glmt-transformer] Request transformation error:', error);
// Return original request with warning
return {
openaiRequest: anthropicRequest,
thinkingConfig: { thinking: false },
error: error.message
};
}
}
/**
* Transform OpenAI response to Anthropic format
* @param {Object} openaiResponse - OpenAI Chat Completions response
* @param {Object} thinkingConfig - Config from request transformation
* @returns {Object} Anthropic Messages API response
*/
transformResponse(openaiResponse, thinkingConfig = {}) {
// Log original response
this._writeDebugLog('response-openai', openaiResponse);
try {
const choice = openaiResponse.choices?.[0];
if (!choice) {
throw new Error('No choices in OpenAI response');
}
const message = choice.message;
const content = [];
// Add thinking block if reasoning_content exists
if (message.reasoning_content) {
const length = message.reasoning_content.length;
const lineCount = message.reasoning_content.split('\n').length;
const preview = message.reasoning_content
.substring(0, 100)
.replace(/\n/g, ' ')
.trim();
this.log(`Detected reasoning_content:`);
this.log(` Length: ${length} characters`);
this.log(` Lines: ${lineCount}`);
this.log(` Preview: ${preview}...`);
content.push({
type: 'thinking',
thinking: message.reasoning_content,
signature: this._generateThinkingSignature(message.reasoning_content)
});
} else {
this.log('No reasoning_content in OpenAI response');
this.log('Note: This is expected if thinking not requested or model cannot reason');
}
// Add text content
if (message.content) {
content.push({
type: 'text',
text: message.content
});
}
// Handle tool_calls if present
if (message.tool_calls && message.tool_calls.length > 0) {
message.tool_calls.forEach(toolCall => {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.function.name,
input: JSON.parse(toolCall.function.arguments || '{}')
});
});
}
const anthropicResponse = {
id: openaiResponse.id || 'msg_' + Date.now(),
type: 'message',
role: 'assistant',
content: content,
model: openaiResponse.model || 'glm-4.6',
stop_reason: this._mapStopReason(choice.finish_reason),
usage: openaiResponse.usage || {
input_tokens: 0,
output_tokens: 0
}
};
// Validate transformation in verbose mode
if (this.verbose) {
const validation = this._validateTransformation(anthropicResponse);
this.log(`Transformation validation: ${validation.passed}/${validation.total} checks passed`);
if (!validation.valid) {
this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`);
}
}
// Log transformed response
this._writeDebugLog('response-anthropic', anthropicResponse);
return anthropicResponse;
} catch (error) {
console.error('[glmt-transformer] Response transformation error:', error);
// Return minimal valid response
return {
id: 'msg_error_' + Date.now(),
type: 'message',
role: 'assistant',
content: [{
type: 'text',
text: '[Transformation Error] ' + error.message
}],
stop_reason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0 }
};
}
}
/**
* Sanitize messages for OpenAI API compatibility
* Remove thinking blocks and unsupported content types
* @param {Array} messages - Messages array
* @returns {Array} Sanitized messages
* @private
*/
_sanitizeMessages(messages) {
return messages.map(msg => {
// If content is a string, return as-is
if (typeof msg.content === 'string') {
return msg;
}
// If content is an array, filter out unsupported types
if (Array.isArray(msg.content)) {
const sanitizedContent = msg.content
.filter(block => {
// Keep only text content for OpenAI
// Filter out: thinking, tool_use, tool_result, etc.
return block.type === 'text';
})
.map(block => {
// Return just the text content
return block;
});
// If we filtered everything out, return empty string
if (sanitizedContent.length === 0) {
return {
role: msg.role,
content: ''
};
}
// If only one text block, convert to string
if (sanitizedContent.length === 1 && sanitizedContent[0].type === 'text') {
return {
role: msg.role,
content: sanitizedContent[0].text
};
}
// Return array of text blocks
return {
role: msg.role,
content: sanitizedContent
};
}
// Fallback: return message as-is
return msg;
});
}
/**
* Extract thinking control tags from user messages
* @param {Array} messages - Messages array
* @returns {Object} { thinking: boolean, effort: string }
* @private
*/
_extractThinkingControl(messages) {
const config = {
thinking: this.defaultThinking,
effort: 'medium'
};
// Scan user messages for control tags
for (const msg of messages) {
if (msg.role !== 'user') continue;
const content = msg.content;
if (typeof content !== 'string') continue;
// Check for <Thinking:On|Off>
const thinkingMatch = content.match(/<Thinking:(On|Off)>/i);
if (thinkingMatch) {
config.thinking = thinkingMatch[1].toLowerCase() === 'on';
}
// Check for <Effort:Low|Medium|High>
const effortMatch = content.match(/<Effort:(Low|Medium|High)>/i);
if (effortMatch) {
config.effort = effortMatch[1].toLowerCase();
}
}
return config;
}
/**
* Generate thinking signature for Claude Code UI
* @param {string} thinking - Thinking content
* @returns {Object} Signature object
* @private
*/
_generateThinkingSignature(thinking) {
// Generate signature hash
const hash = crypto.createHash('sha256')
.update(thinking)
.digest('hex')
.substring(0, 16);
return {
type: 'thinking_signature',
hash: hash,
length: thinking.length,
timestamp: Date.now()
};
}
/**
* Inject reasoning parameters into OpenAI request
* @param {Object} openaiRequest - OpenAI request to modify
* @param {Object} thinkingConfig - Thinking configuration
* @returns {Object} Modified request
* @private
*/
_injectReasoningParams(openaiRequest, thinkingConfig) {
// Always enable sampling for temperature/top_p to work
openaiRequest.do_sample = true;
// Add thinking-specific parameters if enabled
if (thinkingConfig.thinking) {
// Z.AI may support these parameters (based on research)
openaiRequest.reasoning = true;
openaiRequest.reasoning_effort = thinkingConfig.effort;
}
return openaiRequest;
}
/**
* Map Anthropic model to GLM model
* @param {string} anthropicModel - Anthropic model name
* @returns {string} GLM model name
* @private
*/
_mapModel(anthropicModel) {
// Default to GLM-4.6 (latest and most capable)
return 'GLM-4.6';
}
/**
* Get max tokens for model
* @param {string} model - Model name
* @returns {number} Max tokens
* @private
*/
_getMaxTokens(model) {
return this.modelMaxTokens[model] || 128000;
}
/**
* Map OpenAI stop reason to Anthropic stop reason
* @param {string} openaiReason - OpenAI finish_reason
* @returns {string} Anthropic stop_reason
* @private
*/
_mapStopReason(openaiReason) {
const mapping = {
'stop': 'end_turn',
'length': 'max_tokens',
'tool_calls': 'tool_use',
'content_filter': 'stop_sequence'
};
return mapping[openaiReason] || 'end_turn';
}
/**
* Write debug log to file
* @param {string} type - 'request-anthropic', 'request-openai', 'response-openai', 'response-anthropic'
* @param {object} data - Data to log
* @private
*/
_writeDebugLog(type, data) {
if (!this.debugLog) return;
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
const filename = `${timestamp}-${type}.json`;
const filepath = path.join(this.debugLogDir, filename);
// Ensure directory exists
fs.mkdirSync(this.debugLogDir, { recursive: true });
// Write file (pretty-printed)
fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8');
if (this.verbose) {
this.log(`Debug log written: ${filepath}`);
}
} catch (error) {
console.error(`[glmt-transformer] Failed to write debug log: ${error.message}`);
}
}
/**
* Validate transformed Anthropic response
* @param {object} anthropicResponse - Response to validate
* @returns {object} Validation results
* @private
*/
_validateTransformation(anthropicResponse) {
const checks = {
hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0),
hasThinking: anthropicResponse.content?.some(block => block.type === 'thinking') || false,
hasText: anthropicResponse.content?.some(block => block.type === 'text') || false,
validStructure: anthropicResponse.type === 'message' && anthropicResponse.role === 'assistant',
hasUsage: Boolean(anthropicResponse.usage)
};
const passed = Object.values(checks).filter(Boolean).length;
const total = Object.keys(checks).length;
return { checks, passed, total, valid: passed === total };
}
/**
* Log message if verbose
* @param {string} message - Message to log
* @private
*/
log(message) {
if (this.verbose) {
const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
console.error(`[glmt-transformer] [${timestamp}] ${message}`);
}
}
}
module.exports = GlmtTransformer;
+17
View File
@@ -0,0 +1,17 @@
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/coding/paas/v4/chat/completions",
"ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE",
"ANTHROPIC_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.6",
"ANTHROPIC_TEMPERATURE": "0.2",
"ANTHROPIC_MAX_TOKENS": "65536",
"MAX_THINKING_TOKENS": "32768",
"ENABLE_STREAMING": "true",
"ANTHROPIC_SAFE_MODE": "false",
"API_TIMEOUT_MS": "3000000"
},
"alwaysThinkingEnabled": true
}
+52 -1
View File
@@ -522,4 +522,55 @@ ccs glm
- CI/CD environments
- Log file generation
- Terminals without color support
- Accessibility preferences
- Accessibility preferences
---
## GLMT Troubleshooting
### Thinking Blocks Not Visible
1. **Enable debug logging**:
```bash
export CCS_DEBUG_LOG=1
ccs glmt --verbose "test"
```
2. **Check raw response**:
```bash
cat ~/.ccs/logs/*response-openai.json | jq '.choices[0].message.reasoning_content'
```
3. **Scenarios**:
- **reasoning_content present**: Transformation issue → check response-anthropic.json
- **reasoning_content absent**: Z.AI API issue → verify API key/account status
### Duplicate "Enchanting" Lines
**Symptom**: Two spinner lines during thinking
**Cause**: Terminal rendering (not CCS issue)
**Solutions**:
- Use native terminal (iTerm2, GNOME Terminal, Windows Terminal)
- Exit tmux/screen before running
- Accept visual glitch (functionality unaffected)
### Proxy Startup Timeout
**Error**: "Proxy startup timeout (5s)"
**Solutions**:
```bash
# Try non-thinking mode
ccs glm "your prompt"
# Check Node.js
node --version # Requires ≥14
# Check port availability
netstat -an | grep 127.0.0.1
# Verbose details
ccs glmt --verbose "test"
```
+267 -17
View File
@@ -2,7 +2,7 @@
## Overview
CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5 and GLM 4.6 models. The architecture has been recently simplified to achieve a 35% reduction in codebase size while maintaining all functionality.
CCS (Claude Code Switch) is a lightweight CLI wrapper that provides instant profile switching between Claude Sonnet 4.5 and GLM 4.6 models. Current version **v3.3.0** features GLMT thinking mode with embedded proxy architecture, debug logging, and refined configuration management.
## Core Architecture Principles
@@ -62,32 +62,57 @@ graph TB
**Key Responsibilities**:
- Argument parsing and profile detection
- Special command handling (--version, --help, auth) [--install/--uninstall WIP]
- Special command handling (--version, --help, auth, doctor) [--install/--uninstall WIP]
- Profile type routing (settings-based vs account-based)
- GLMT proxy lifecycle management
- Unified process execution through `execClaude()`
- Error propagation and exit code management
- Auto-recovery for missing configuration
**Architecture with Concurrent Sessions**:
**Architecture with GLMT Support (v3.3.0)**:
```mermaid
graph LR
graph TD
subgraph "Entry Point"
ARGS[Parse Arguments]
SPECIAL[Handle Special Commands]
RECOVER[Auto-recovery]
DETECT[ProfileDetector]
SETTINGS[Settings-based Profile]
GLMT{GLMT Profile?}
ACCOUNT[Account-based Profile]
PROXY[Spawn Proxy]
EXEC[Execute Claude]
end
ARGS --> SPECIAL
SPECIAL --> DETECT
SPECIAL --> RECOVER
RECOVER --> DETECT
DETECT --> SETTINGS
DETECT --> ACCOUNT
SETTINGS --> EXEC
SETTINGS --> GLMT
GLMT -->|Yes| PROXY
GLMT -->|No| EXEC
PROXY --> EXEC
ACCOUNT --> EXEC
```
**Key Enhancement ()**: Dual-path execution supporting both `--settings` flag (backward compatible) and `CLAUDE_CONFIG_DIR` env var (concurrent sessions).
**Key Enhancements**:
- **v3.3.0**: GLMT proxy spawning with verbose flag detection, API key validation, 5s timeout
- **v3.2.0**: Dual-path execution supporting both `--settings` flag (backward compatible) and `CLAUDE_CONFIG_DIR` env var (concurrent sessions)
- **v3.1.0**: Auto-recovery manager for missing configs
**GLMT-Specific Logic**:
```javascript
// Check if GLMT profile
if (profileInfo.name === 'glmt') {
// 1. Read API key from settings
// 2. Spawn proxy with --verbose flag (if detected in args)
// 3. Wait for PROXY_READY:port signal (5s timeout)
// 4. Spawn Claude CLI with proxy URL
// 5. Kill proxy when Claude exits
await execClaudeWithProxy(claudeCli, 'glmt', remainingArgs);
}
```
### 2. Configuration Manager (`bin/config-manager.js`)
@@ -236,6 +261,132 @@ graph TD
"default": "work"
}
```
## GLMT Architecture (v3.2.0+)
### Overview
GLMT (GLM with Thinking) uses an embedded HTTP proxy to enable thinking mode support for GLM 4.6. The proxy converts between Anthropic and OpenAI formats, injecting reasoning parameters and transforming `reasoning_content` into thinking blocks.
### Components
**1. GLMT Transformer (`bin/glmt-transformer.js`)**
- Converts Anthropic Messages API → OpenAI Chat Completions format
- Extracts thinking control tags: `<Thinking:On|Off>`, `<Effort:Low|Medium|High>`
- Injects reasoning parameters: `reasoning: true`, `reasoning_effort`
- Transforms OpenAI `reasoning_content` → Anthropic thinking blocks
- Generates thinking signatures for Claude Code UI
- Debug logging to `~/.ccs/logs/` when `CCS_DEBUG_LOG=1`
**2. GLMT Proxy (`bin/glmt-proxy.js`)**
- Embedded HTTP server on `127.0.0.1:random_port`
- Intercepts Claude CLI → Z.AI requests
- Lifecycle tied to parent process
- Buffered mode only (streaming not supported)
- Request timeout: 120s default
### GLMT Execution Flow
```mermaid
sequenceDiagram
participant User
participant CCS as ccs.js
participant Proxy as glmt-proxy.js
participant Transformer as glmt-transformer.js
participant ZAI as Z.AI API
participant Claude as Claude CLI
User->>CCS: ccs glmt "solve problem"
CCS->>CCS: Read GLMT settings (API key, config)
CCS->>Proxy: Spawn proxy with --verbose flag
Proxy->>Proxy: Bind to 127.0.0.1:random_port
Proxy-->>CCS: PROXY_READY:port
CCS->>Claude: Spawn with ANTHROPIC_BASE_URL=http://127.0.0.1:port
Claude->>Proxy: POST /v1/messages (Anthropic format)
Proxy->>Transformer: transformRequest(anthropicRequest)
Transformer->>Transformer: Extract thinking control tags
Transformer->>Transformer: Inject reasoning params
Transformer-->>Proxy: OpenAI format request
Proxy->>ZAI: POST /api/coding/paas/v4/chat/completions
ZAI-->>Proxy: OpenAI response (with reasoning_content)
Proxy->>Transformer: transformResponse(openaiResponse)
Transformer->>Transformer: Convert reasoning_content → thinking blocks
Transformer-->>Proxy: Anthropic format response
Proxy-->>Claude: Return Anthropic response
Claude-->>User: Display with thinking blocks
Claude->>CCS: Exit
CCS->>Proxy: Kill (SIGTERM)
```
### Debug Mode Architecture
**Verbose Logging** (`--verbose` flag):
- Console logging with timestamps
- Request/response size tracking
- Transformation validation results
- Proxy lifecycle events
**Debug File Logging** (`CCS_DEBUG_LOG=1`):
- Writes to `~/.ccs/logs/`
- Files: `{timestamp}-request-anthropic.json`, `request-openai.json`, `response-openai.json`, `response-anthropic.json`
- Pretty-printed JSON with full request/response data
- **[!] Warning**: Contains sensitive data (API keys, prompts)
**Debug Workflow**:
```bash
# Enable both verbose and debug logging
export CCS_DEBUG_LOG=1
ccs glmt --verbose "test prompt"
# Check reasoning content
cat ~/.ccs/logs/*response-openai.json | jq '.choices[0].message.reasoning_content'
# Verify transformation
cat ~/.ccs/logs/*response-anthropic.json | jq '.content[] | select(.type=="thinking")'
```
### Configuration Migration (v3.2.0 → v3.3.0)
**Automatic Migration** (postinstall script):
```javascript
// Added fields in v3.3.0
{
"env": {
"ANTHROPIC_TEMPERATURE": "0.2", // New
"ANTHROPIC_MAX_TOKENS": "65536", // New
"MAX_THINKING_TOKENS": "32768", // New
"ENABLE_STREAMING": "true", // New
"ANTHROPIC_SAFE_MODE": "false", // New
"API_TIMEOUT_MS": "3000000" // New (50 minutes)
},
"alwaysThinkingEnabled": true // New
}
```
**Removed/Obsolete Fields** (from v3.2.0):
- `BASH_DEFAULT_TIMEOUT_MS` - Moved to Claude CLI config
- `BASH_MAX_TIMEOUT_MS` - Moved to Claude CLI config
- `DISABLE_TELEMETRY` - No longer needed
- `ENABLE_THINKING` - Replaced by `alwaysThinkingEnabled`
### Proxy Lifecycle Management
**Startup**:
1. CCS spawns `node bin/glmt-proxy.js`
2. Proxy binds to `127.0.0.1:0` (random port)
3. Proxy emits `PROXY_READY:port` to stdout
4. CCS reads port, spawns Claude CLI with proxy URL
5. Timeout: 5s (configurable)
**Cleanup**:
- Claude CLI exits → CCS kills proxy (`SIGTERM`)
- Parent process dies → Proxy auto-terminates
- Uncaught exception → Proxy logs and exits
**Error Handling**:
- Proxy startup timeout → Show workaround (use `ccs glm`)
- Port conflict → Uses random port (unlikely)
- Upstream timeout → 120s default, configurable
## Data Flow Architecture
### Settings-Based Profile Execution Flow (Backward Compatible)
@@ -318,27 +469,62 @@ sequenceDiagram
```
~/.ccs/
├── config.json # Settings-based profile mappings (glm, kimi)
├── config.json # Settings-based profile mappings (glm, glmt, kimi)
├── profiles.json # Account-based profile metadata (work, personal)
├── glm.settings.json # GLM configuration
├── glm.settings.json # GLM configuration (Anthropic endpoint)
├── glmt.settings.json # GLMT configuration (v3.3.0 with thinking mode)
├── kimi.settings.json # Kimi configuration
├── config.json.backup # Single backup file
├── VERSION # Version information
├── logs/ # Debug logs (CCS_DEBUG_LOG=1)
│ ├── {timestamp}-request-anthropic.json
│ ├── {timestamp}-request-openai.json
│ ├── {timestamp}-response-openai.json
│ └── {timestamp}-response-anthropic.json
├── shared/ # Shared across all profiles (v3.1+)
│ ├── commands/ # Slash commands
│ ├── skills/ # Agent skills
│ └── agents/ # Agent configs
├── accounts/ # Encrypted credential vaults
│ ├── .salt # Key derivation salt
│ ├── work.json.enc # Work account credentials (encrypted)
│ └── personal.json.enc # Personal account credentials (encrypted)
└── instances/ # Isolated Claude instances (+)
└── instances/ # Isolated Claude instances
├── work/ # Work account instance
│ ├── session-env/
│ ├── todos/
│ ├── logs/
│ ├── commands@ → shared/commands/
│ ├── skills@ → shared/skills/
│ ├── agents@ → shared/agents/
│ ├── .credentials.json
│ └── ...
└── personal/ # Personal account instance
├── session-env/
├── todos/
└── ...
bin/ # CCS source files
├── ccs.js # Main entry point (v3.3.0)
├── glmt-proxy.js # Embedded HTTP proxy (v3.2.0+)
├── glmt-transformer.js # Format conversion (v3.2.0+)
├── config-manager.js # Configuration handling
├── claude-detector.js # Claude CLI detection
├── instance-manager.js # Instance orchestration
├── shared-manager.js # Shared data symlinks (v3.1+)
├── profile-detector.js # Profile type detection
├── profile-registry.js # Account profile metadata
├── helpers.js # Utility functions
├── error-manager.js # Error handling
├── recovery-manager.js # Auto-recovery
├── auth-commands.js # Multi-account management
└── doctor.js # Health check diagnostics
config/
└── base-glmt.settings.json # GLMT template (v3.3.0)
scripts/
└── postinstall.js # Auto-configuration + migration
```
### Configuration Schema
@@ -354,6 +540,7 @@ sequenceDiagram
### Settings File Format
**GLM Settings (Anthropic endpoint)**:
```json
{
"env": {
@@ -367,6 +554,29 @@ sequenceDiagram
}
```
**GLMT Settings (v3.3.0 with thinking mode)**:
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/coding/paas/v4/chat/completions",
"ANTHROPIC_AUTH_TOKEN": "your_api_key",
"ANTHROPIC_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.6",
"ANTHROPIC_TEMPERATURE": "0.2",
"ANTHROPIC_MAX_TOKENS": "65536",
"MAX_THINKING_TOKENS": "32768",
"ENABLE_STREAMING": "true",
"ANTHROPIC_SAFE_MODE": "false",
"API_TIMEOUT_MS": "3000000"
},
"alwaysThinkingEnabled": true
}
```
**[i] Note**: All env values are strings (not booleans/numbers) for PowerShell compatibility.
## Security Architecture
### Inherent Security Model
@@ -503,11 +713,36 @@ graph LR
### Installation Process
1. **Package Download**: User installs via npm
2. **Post-install Script**: Automatically creates configuration
3. **Path Configuration**: Sets up executable in system PATH
4. **Validation**: Ensures Claude CLI is available
5. **Ready State**: System ready for profile switching
1. **Package Download**: User installs via npm/yarn/pnpm/bun
2. **Post-install Script** (`scripts/postinstall.js`):
- Creates `~/.ccs/` directory structure
- Creates `~/.ccs/shared/` (commands, skills, agents)
- Migrates from v3.1.1 → v3.2.0 (if needed)
- Migrates GLMT configs from v3.2.0 → v3.3.0 (adds new fields)
- Creates `config.json` (glm, glmt, kimi, default)
- Creates `glm.settings.json` (Anthropic endpoint)
- Creates `glmt.settings.json` (OpenAI endpoint + thinking mode)
- Creates `kimi.settings.json` (Kimi endpoint)
- Creates `~/.claude/settings.json` (if missing)
- Validates configuration (checks JSON syntax, file existence)
- Shows API key setup instructions
3. **Path Configuration**: npm automatically adds to PATH
4. **Ready State**: System ready for profile switching
**Idempotency**: Safe to run multiple times, preserves existing configs
**Migration Logic** (v3.3.0):
```javascript
// Auto-adds missing fields to existing GLMT configs
const envDefaults = {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000'
};
```
## Concurrent Sessions Architecture ()
@@ -614,10 +849,25 @@ The CCS system architecture successfully balances simplicity with functionality:
- **Performance optimization** achieves 35% code reduction with identical functionality
- **Clean separation of concerns** makes the codebase maintainable and extensible
** Enhancements**:
**v3.3.0 Features**:
- **GLMT thinking mode**: Embedded proxy for GLM reasoning support
- **Debug logging**: File-based logging to `~/.ccs/logs/` when `CCS_DEBUG_LOG=1`
- **Verbose mode**: Console logging with `--verbose` flag
- **Configuration migration**: Auto-upgrade v3.2.0 configs with new fields
- **Enhanced settings**: Temperature, max tokens, thinking controls, API timeout
- **Transformation validation**: Self-test request/response conversions
**v3.2.0 Enhancements**:
- Concurrent sessions for account-based profiles
- Profile type detection and routing (settings vs account)
- Instance isolation with credential synchronization
- Backward compatibility maintained for all existing profiles
The architecture demonstrates how thoughtful design can add sophisticated features (concurrent sessions, multi-account management) while maintaining simplicity, security, and backward compatibility.
**v3.3.0 Architecture Highlights**:
1. **Proxy Architecture**: Buffered mode with 120s timeout, random port binding
2. **Debug Infrastructure**: Dual logging (console + file) with timestamp tracking
3. **Config Management**: Automatic migration, string-only env vars, PowerShell compatibility
4. **Thinking Mode**: Control tags, reasoning parameters, signature generation
5. **Error Recovery**: Timeout handling, fallback options, clear error messages
The architecture demonstrates how thoughtful design can add sophisticated features (thinking mode, debug infrastructure, multi-account management) while maintaining simplicity, security, and backward compatibility.
+1 -1
View File
@@ -31,7 +31,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 = "3.2.0"
$CcsVersion = "3.3.0"
# Try to read VERSION file for git installations
if ($ScriptDir) {
+1 -1
View File
@@ -32,7 +32,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="3.2.0"
CCS_VERSION="3.3.0"
# Try to read VERSION file for git installations
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
+2 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
# Version (updated by scripts/bump-version.sh)
CCS_VERSION="3.2.0"
CCS_VERSION="3.3.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
readonly PROFILES_JSON="$HOME/.ccs/profiles.json"
@@ -51,6 +51,7 @@ show_help() {
echo -e "${CYAN}Model Switching:${RESET}"
echo -e " ${YELLOW}ccs${RESET} Use default Claude account"
echo -e " ${YELLOW}ccs glm${RESET} Switch to GLM 4.6 model"
echo -e " ${YELLOW}ccs glmt${RESET} Switch to GLM with thinking mode"
echo -e " ${YELLOW}ccs kimi${RESET} Switch to Kimi for Coding"
echo -e " ${YELLOW}ccs glm${RESET} \"debug this code\" Use GLM and run command"
echo ""
+2 -1
View File
@@ -12,7 +12,7 @@ param(
$ErrorActionPreference = "Stop"
# Version (updated by scripts/bump-version.sh)
$CcsVersion = "3.2.0"
$CcsVersion = "3.3.0"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
@@ -107,6 +107,7 @@ function Show-Help {
Write-ColorLine "Model Switching:" "Cyan"
Write-ColorLine " ccs Use default Claude account" "Yellow"
Write-ColorLine " ccs glm Switch to GLM 4.6 model" "Yellow"
Write-ColorLine " ccs glmt Switch to GLM with thinking mode" "Yellow"
Write-ColorLine " ccs kimi Switch to Kimi for Coding" "Yellow"
Write-ColorLine " ccs glm 'debug this code' Use GLM and run command" "Yellow"
Write-Host ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "3.2.0",
"version": "3.3.0",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+105 -1
View File
@@ -33,6 +33,7 @@ function validateConfiguration() {
const requiredFiles = [
{ path: path.join(ccsDir, 'config.json'), name: 'config.json' },
{ path: path.join(ccsDir, 'glm.settings.json'), name: 'glm.settings.json' },
{ path: path.join(ccsDir, 'glmt.settings.json'), name: 'glmt.settings.json' },
{ path: path.join(ccsDir, 'kimi.settings.json'), name: 'kimi.settings.json' }
];
@@ -108,6 +109,7 @@ function createConfigFiles() {
const config = {
profiles: {
glm: '~/.ccs/glm.settings.json',
glmt: '~/.ccs/glmt.settings.json',
kimi: '~/.ccs/kimi.settings.json',
default: '~/.claude/settings.json'
}
@@ -120,7 +122,21 @@ function createConfigFiles() {
console.log('[OK] Created config: ~/.ccs/config.json');
} else {
console.log('[OK] Config exists: ~/.ccs/config.json (preserved)');
// Update existing config with glmt if missing (migration for v3.x users)
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Ensure profiles object exists
if (!config.profiles) {
config.profiles = {};
}
if (!config.profiles.glmt) {
config.profiles.glmt = '~/.ccs/glmt.settings.json';
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');
} else {
console.log('[OK] Config exists: ~/.ccs/config.json (preserved)');
}
}
// Create glm.settings.json if missing
@@ -152,6 +168,94 @@ function createConfigFiles() {
console.log('[OK] GLM profile exists: ~/.ccs/glm.settings.json (preserved)');
}
// Create glmt.settings.json if missing
const glmtSettingsPath = path.join(ccsDir, 'glmt.settings.json');
if (!fs.existsSync(glmtSettingsPath)) {
const glmtSettings = {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
ANTHROPIC_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000'
},
alwaysThinkingEnabled: true
};
// Atomic write
const tmpPath = `${glmtSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(glmtSettings, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, glmtSettingsPath);
console.log('[OK] Created GLMT profile: ~/.ccs/glmt.settings.json');
console.log('');
console.log(' [!] Configure GLMT API key:');
console.log(' 1. Get key from: https://api.z.ai');
console.log(' 2. Edit: ~/.ccs/glmt.settings.json');
console.log(' 3. Replace: YOUR_GLM_API_KEY_HERE');
console.log(' Note: GLMT enables GLM thinking mode (reasoning)');
console.log(' Defaults: Temperature 0.2, thinking enabled, 50min timeout');
} else {
console.log('[OK] GLMT profile exists: ~/.ccs/glmt.settings.json (preserved)');
}
// Migrate existing GLMT configs to include new defaults (v3.3.0)
if (fs.existsSync(glmtSettingsPath)) {
try {
const existing = JSON.parse(fs.readFileSync(glmtSettingsPath, 'utf8'));
let updated = false;
// Ensure env object exists
if (!existing.env) {
existing.env = {};
updated = true;
}
// Add missing env vars (preserve existing values)
const envDefaults = {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000'
};
for (const [key, value] of Object.entries(envDefaults)) {
if (existing.env[key] === undefined) {
existing.env[key] = value;
updated = true;
}
}
// Add alwaysThinkingEnabled if missing
if (existing.alwaysThinkingEnabled === undefined) {
existing.alwaysThinkingEnabled = true;
updated = true;
}
// Write back if updated
if (updated) {
const tmpPath = `${glmtSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, glmtSettingsPath);
console.log('[OK] Migrated GLMT config with new defaults (v3.3.0)');
console.log(' Added: temperature, max_tokens, thinking settings, alwaysThinkingEnabled');
}
} catch (err) {
console.warn('[!] GLMT config migration failed:', err.message);
console.warn(' Existing config preserved, may be missing new defaults');
console.warn(' You can manually add fields or delete file to regenerate');
}
}
// Create kimi.settings.json if missing
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
if (!fs.existsSync(kimiSettingsPath)) {
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const GlmtTransformer = require('../bin/glmt-transformer');
/**
* Manual test for debug mode file logging
*/
const logDir = path.join(os.homedir(), '.ccs', 'logs');
// Clean up any existing logs
console.log('Cleaning up existing logs...');
if (fs.existsSync(logDir)) {
fs.rmSync(logDir, { recursive: true, force: true });
}
console.log('\n=== Test 1: Debug Mode OFF (default) ===');
{
const transformer = new GlmtTransformer({ verbose: false });
console.log(`Debug logging: ${transformer.debugLog}`);
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test without debug' }]
};
const { openaiRequest } = transformer.transformRequest(input);
const openaiResponse = {
id: 'test-1',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Response without debug',
reasoning_content: 'Some reasoning...'
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
};
transformer.transformResponse(openaiResponse, {});
// Check that no logs were created
const logExists = fs.existsSync(logDir);
console.log(`Log directory exists: ${logExists}`);
if (logExists) {
console.log('ERROR: Logs created when debug mode is OFF!');
process.exit(1);
} else {
console.log('✓ No logs created (correct)');
}
}
console.log('\n=== Test 2: Debug Mode ON (via config) ===');
{
const transformer = new GlmtTransformer({ verbose: true, debugLog: true });
console.log(`Debug logging: ${transformer.debugLog}`);
console.log(`Log directory: ${transformer.debugLogDir}`);
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test with debug' }]
};
const { openaiRequest } = transformer.transformRequest(input);
const openaiResponse = {
id: 'test-2',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Response with debug',
reasoning_content: 'Detailed reasoning process for debugging...'
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
};
transformer.transformResponse(openaiResponse, {});
// Check that logs were created
const logExists = fs.existsSync(logDir);
console.log(`\nLog directory exists: ${logExists}`);
if (!logExists) {
console.log('ERROR: No logs created when debug mode is ON!');
process.exit(1);
}
const files = fs.readdirSync(logDir);
console.log(`Files created: ${files.length}`);
console.log(`Files:`, files);
if (files.length !== 4) {
console.log(`ERROR: Expected 4 files, got ${files.length}`);
process.exit(1);
}
// Check file names
const hasRequestAnthropic = files.some(f => f.includes('request-anthropic'));
const hasRequestOpenai = files.some(f => f.includes('request-openai'));
const hasResponseOpenai = files.some(f => f.includes('response-openai'));
const hasResponseAnthropic = files.some(f => f.includes('response-anthropic'));
console.log(`\nFile types found:`);
console.log(` request-anthropic: ${hasRequestAnthropic ? '✓' : '✗'}`);
console.log(` request-openai: ${hasRequestOpenai ? '✓' : '✗'}`);
console.log(` response-openai: ${hasResponseOpenai ? '✓' : '✗'}`);
console.log(` response-anthropic: ${hasResponseAnthropic ? '✓' : '✗'}`);
if (!hasRequestAnthropic || !hasRequestOpenai || !hasResponseOpenai || !hasResponseAnthropic) {
console.log('ERROR: Missing expected file types!');
process.exit(1);
}
// Check file contents
const responseOpenaiFile = files.find(f => f.includes('response-openai'));
const responseOpenaiPath = path.join(logDir, responseOpenaiFile);
const responseOpenaiData = JSON.parse(fs.readFileSync(responseOpenaiPath, 'utf8'));
console.log(`\nChecking response-openai.json for reasoning_content...`);
if (responseOpenaiData.choices[0].message.reasoning_content) {
console.log('✓ reasoning_content found in response-openai.json');
console.log(` Length: ${responseOpenaiData.choices[0].message.reasoning_content.length} chars`);
} else {
console.log('ERROR: reasoning_content NOT found in response-openai.json!');
process.exit(1);
}
const responseAnthropicFile = files.find(f => f.includes('response-anthropic'));
const responseAnthropicPath = path.join(logDir, responseAnthropicFile);
const responseAnthropicData = JSON.parse(fs.readFileSync(responseAnthropicPath, 'utf8'));
console.log(`\nChecking response-anthropic.json for thinking block...`);
const thinkingBlock = responseAnthropicData.content.find(b => b.type === 'thinking');
if (thinkingBlock) {
console.log('✓ Thinking block found in response-anthropic.json');
console.log(` Length: ${thinkingBlock.thinking.length} chars`);
} else {
console.log('ERROR: Thinking block NOT found in response-anthropic.json!');
process.exit(1);
}
console.log('\n✓ All checks passed for debug mode ON');
}
console.log('\n=== Test 3: Debug Mode via CCS_DEBUG_LOG=1 ===');
{
// Clean up
fs.rmSync(logDir, { recursive: true, force: true });
process.env.CCS_DEBUG_LOG = '1';
const transformer = new GlmtTransformer({ verbose: false });
console.log(`Debug logging: ${transformer.debugLog}`);
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test with env var' }]
};
transformer.transformRequest(input);
const openaiResponse = {
id: 'test-3',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Response',
reasoning_content: 'Reasoning...'
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
};
transformer.transformResponse(openaiResponse, {});
const files = fs.readdirSync(logDir);
console.log(`Files created: ${files.length}`);
if (files.length === 4) {
console.log('✓ Debug mode enabled via CCS_DEBUG_LOG=1');
} else {
console.log(`ERROR: Expected 4 files, got ${files.length}`);
process.exit(1);
}
delete process.env.CCS_DEBUG_LOG;
}
console.log('\n=== Test 4: Error Handling (No Write Permission) ===');
{
// Create a read-only directory to test error handling
const readOnlyDir = path.join(os.tmpdir(), 'ccs-readonly-test');
if (fs.existsSync(readOnlyDir)) {
fs.chmodSync(readOnlyDir, 0o755);
fs.rmSync(readOnlyDir, { recursive: true, force: true });
}
fs.mkdirSync(readOnlyDir, { recursive: true });
fs.chmodSync(readOnlyDir, 0o444); // Read-only
const transformer = new GlmtTransformer({
debugLog: true,
debugLogDir: readOnlyDir,
verbose: false
});
console.log('Testing graceful error handling with read-only directory...');
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test error handling' }]
};
try {
transformer.transformRequest(input);
console.log('✓ No crash on write error (graceful handling)');
} catch (error) {
console.log('ERROR: Transformer crashed on write error!');
console.log(error);
process.exit(1);
} finally {
// Clean up
fs.chmodSync(readOnlyDir, 0o755);
fs.rmSync(readOnlyDir, { recursive: true, force: true });
}
}
// Clean up test logs
console.log('\n=== Cleaning up test logs ===');
if (fs.existsSync(logDir)) {
fs.rmSync(logDir, { recursive: true, force: true });
console.log(`Removed ${logDir}`);
}
console.log('\n=== ALL TESTS PASSED ===');
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
#
# GLMT Integration Test Suite
# Tests proxy startup, configuration, and basic functionality
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CCS_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== GLMT Integration Test Suite ==="
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
PASSED=0
FAILED=0
test_pass() {
echo -e "${GREEN}${NC} $1"
((PASSED++))
}
test_fail() {
echo -e "${RED}${NC} $1"
((FAILED++))
}
test_info() {
echo -e "${YELLOW}${NC} $1"
}
# Test 1: Check GLMT profile exists in config
echo "Test 1: GLMT profile configuration"
if grep -q '"glmt"' ~/.ccs/config.json; then
test_pass "GLMT profile found in config.json"
else
test_fail "GLMT profile NOT found in config.json"
fi
# Test 2: Check GLMT settings file exists
echo "Test 2: GLMT settings file"
if [ -f ~/.ccs/glmt.settings.json ]; then
test_pass "GLMT settings file exists"
# Check if API key is configured
if grep -q "YOUR_GLM_API_KEY_HERE" ~/.ccs/glmt.settings.json; then
test_info "API key not configured (still has placeholder)"
else
test_pass "API key configured"
fi
else
test_fail "GLMT settings file NOT found"
fi
# Test 3: Check transformer module
echo "Test 3: Transformer module"
if [ -f "$CCS_DIR/bin/glmt-transformer.js" ]; then
test_pass "Transformer module exists"
# Test syntax
if node --check "$CCS_DIR/bin/glmt-transformer.js" 2>/dev/null; then
test_pass "Transformer syntax valid"
else
test_fail "Transformer syntax invalid"
fi
else
test_fail "Transformer module NOT found"
fi
# Test 4: Check proxy module
echo "Test 4: Proxy module"
if [ -f "$CCS_DIR/bin/glmt-proxy.js" ]; then
test_pass "Proxy module exists"
# Test syntax
if node --check "$CCS_DIR/bin/glmt-proxy.js" 2>/dev/null; then
test_pass "Proxy syntax valid"
else
test_fail "Proxy syntax invalid"
fi
else
test_fail "Proxy module NOT found"
fi
# Test 5: Test proxy startup
echo "Test 5: Proxy startup test"
test_info "Starting proxy in background..."
# Start proxy
node "$CCS_DIR/bin/glmt-proxy.js" &
PROXY_PID=$!
# Wait for PROXY_READY signal (with timeout)
TIMEOUT=5
PORT=""
for i in $(seq 1 $TIMEOUT); do
if ps -p $PROXY_PID > /dev/null 2>&1; then
sleep 0.2
# Check if proxy outputted anything (we can't easily capture it in background)
if [ $i -eq $TIMEOUT ]; then
test_fail "Proxy started but PROXY_READY signal not captured (this is OK - proxy works)"
PORT="unknown"
fi
else
test_fail "Proxy failed to start or exited immediately"
break
fi
done
if ps -p $PROXY_PID > /dev/null 2>&1; then
test_pass "Proxy process is running (PID: $PROXY_PID)"
# Kill proxy
kill $PROXY_PID 2>/dev/null || true
sleep 0.5
if ! ps -p $PROXY_PID > /dev/null 2>&1; then
test_pass "Proxy terminated gracefully"
else
test_fail "Proxy did not terminate (killing forcefully)"
kill -9 $PROXY_PID 2>/dev/null || true
fi
fi
# Test 6: Unit tests
echo "Test 6: Unit tests"
if [ -f "$CCS_DIR/tests/glmt-transformer.test.js" ]; then
test_info "Running transformer unit tests..."
if node "$CCS_DIR/tests/glmt-transformer.test.js" | grep -q "Passed: 12/12"; then
test_pass "All 12 unit tests passed"
else
test_fail "Some unit tests failed"
fi
else
test_fail "Unit test file NOT found"
fi
# Test 7: Help text
echo "Test 7: Help text verification"
if ccs --help | grep -q "ccs glmt"; then
test_pass "GLMT appears in help text"
else
test_fail "GLMT NOT in help text"
fi
# Summary
echo ""
echo "=== Test Summary ==="
echo -e "Passed: ${GREEN}$PASSED${NC}"
echo -e "Failed: ${RED}$FAILED${NC}"
echo ""
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
echo ""
echo "Next steps:"
echo " 1. Test with Claude Code: ccs glmt \"What is 2+2?\""
echo " 2. Test thinking tags: ccs glmt \"<Thinking:On> Explain recursion\""
echo " 3. Check thinking blocks appear in Claude Code UI"
exit 0
else
echo -e "${RED}Some tests failed. Please review above.${NC}"
exit 1
fi
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../bin/glmt-transformer');
/**
* Simple test runner (no external dependencies)
*/
class TestRunner {
constructor() {
this.tests = [];
this.passed = 0;
this.failed = 0;
}
test(name, fn) {
this.tests.push({ name, fn });
}
async run() {
console.log('\n=== GLM Thinking Transformer Tests ===\n');
for (const { name, fn } of this.tests) {
try {
await fn();
console.log(`${name}`);
this.passed++;
} catch (error) {
console.error(`${name}`);
console.error(` Error: ${error.message}`);
this.failed++;
}
}
console.log(`\n=== Results ===`);
console.log(`Passed: ${this.passed}/${this.tests.length}`);
console.log(`Failed: ${this.failed}/${this.tests.length}`);
return this.failed === 0;
}
}
/**
* Simple assertion helpers
*/
function assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(
`${message || 'Assertion failed'}\n` +
` Expected: ${JSON.stringify(expected)}\n` +
` Actual: ${JSON.stringify(actual)}`
);
}
}
function assertExists(value, message) {
if (value === undefined || value === null) {
throw new Error(message || 'Value should exist');
}
}
function assertDeepEqual(actual, expected, message) {
const actualStr = JSON.stringify(actual);
const expectedStr = JSON.stringify(expected);
if (actualStr !== expectedStr) {
throw new Error(
`${message || 'Deep equality failed'}\n` +
` Expected: ${expectedStr}\n` +
` Actual: ${actualStr}`
);
}
}
// Test suite
const runner = new TestRunner();
// Test 1: Transform Anthropic request to OpenAI format
runner.test('transforms Anthropic request to OpenAI format', () => {
const transformer = new GlmtTransformer();
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 4096
};
const { openaiRequest } = transformer.transformRequest(input);
assertEqual(openaiRequest.model, 'GLM-4.6', 'Model should be GLM-4.6');
assertEqual(openaiRequest.do_sample, true, 'do_sample should be true');
assertEqual(openaiRequest.max_tokens, 128000, 'max_tokens should be 128000');
assertExists(openaiRequest.messages, 'messages should exist');
});
// Test 2: Extract thinking control tags
runner.test('extracts thinking control tags', () => {
const transformer = new GlmtTransformer();
const input = {
model: 'claude-sonnet-4.5',
messages: [{
role: 'user',
content: '<Thinking:On> <Effort:High> Solve this problem'
}]
};
const { thinkingConfig } = transformer.transformRequest(input);
assertEqual(thinkingConfig.thinking, true, 'thinking should be On');
assertEqual(thinkingConfig.effort, 'high', 'effort should be high');
});
// Test 3: Extract thinking off tag
runner.test('respects Thinking:Off tag', () => {
const transformer = new GlmtTransformer();
const input = {
model: 'claude-sonnet-4.5',
messages: [{
role: 'user',
content: '<Thinking:Off> Quick question'
}]
};
const { thinkingConfig } = transformer.transformRequest(input);
assertEqual(thinkingConfig.thinking, false, 'thinking should be Off');
});
// Test 4: Convert reasoning_content to thinking block
runner.test('converts reasoning_content to thinking block', () => {
const transformer = new GlmtTransformer();
const openaiResponse = {
id: 'chatcmpl-123',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Here is the answer',
reasoning_content: 'Let me think through this problem...'
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
};
const result = transformer.transformResponse(openaiResponse, {});
assertEqual(result.content.length, 2, 'Should have 2 content blocks');
assertEqual(result.content[0].type, 'thinking', 'First block should be thinking');
assertEqual(
result.content[0].thinking,
'Let me think through this problem...',
'Thinking content should match'
);
assertEqual(result.content[1].type, 'text', 'Second block should be text');
assertEqual(
result.content[1].text,
'Here is the answer',
'Text content should match'
);
});
// Test 5: Handle response without reasoning_content
runner.test('handles response without reasoning_content', () => {
const transformer = new GlmtTransformer();
const openaiResponse = {
id: 'chatcmpl-123',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Simple answer'
},
finish_reason: 'stop'
}]
};
const result = transformer.transformResponse(openaiResponse, {});
assertEqual(result.content.length, 1, 'Should have 1 content block');
assertEqual(result.content[0].type, 'text', 'Block should be text');
assertEqual(result.content[0].text, 'Simple answer', 'Text should match');
});
// Test 6: Thinking signature generation
runner.test('generates thinking signature', () => {
const transformer = new GlmtTransformer();
const thinking = 'This is my reasoning process';
const signature = transformer._generateThinkingSignature(thinking);
assertExists(signature.type, 'signature.type should exist');
assertEqual(signature.type, 'thinking_signature', 'type should be thinking_signature');
assertExists(signature.hash, 'signature.hash should exist');
assertEqual(signature.hash.length, 16, 'hash should be 16 chars');
assertEqual(signature.length, thinking.length, 'length should match thinking length');
assertExists(signature.timestamp, 'timestamp should exist');
});
// Test 7: Stop reason mapping
runner.test('maps OpenAI stop reasons to Anthropic', () => {
const transformer = new GlmtTransformer();
assertEqual(transformer._mapStopReason('stop'), 'end_turn', 'stop → end_turn');
assertEqual(transformer._mapStopReason('length'), 'max_tokens', 'length → max_tokens');
assertEqual(transformer._mapStopReason('tool_calls'), 'tool_use', 'tool_calls → tool_use');
assertEqual(
transformer._mapStopReason('unknown'),
'end_turn',
'unknown → end_turn (default)'
);
});
// Test 8: Preserve temperature and top_p
runner.test('preserves temperature and top_p parameters', () => {
const transformer = new GlmtTransformer();
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test' }],
temperature: 0.7,
top_p: 0.9
};
const { openaiRequest } = transformer.transformRequest(input);
assertEqual(openaiRequest.temperature, 0.7, 'temperature should be preserved');
assertEqual(openaiRequest.top_p, 0.9, 'top_p should be preserved');
});
// Test 9: Default thinking enabled
runner.test('enables thinking by default', () => {
const transformer = new GlmtTransformer({ defaultThinking: true });
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test' }]
};
const { openaiRequest, thinkingConfig } = transformer.transformRequest(input);
assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled by default');
assertEqual(openaiRequest.reasoning, true, 'reasoning should be in request');
});
// Test 10: Error handling in transformRequest
runner.test('handles errors in transformRequest gracefully', () => {
const transformer = new GlmtTransformer();
const invalidInput = null; // Invalid input
const { openaiRequest, thinkingConfig, error } = transformer.transformRequest(invalidInput);
assertExists(error, 'error should be present');
assertEqual(thinkingConfig.thinking, false, 'thinking should be disabled on error');
});
// Test 11: Error handling in transformResponse
runner.test('handles errors in transformResponse gracefully', () => {
const transformer = new GlmtTransformer();
const invalidResponse = {}; // Missing choices
const result = transformer.transformResponse(invalidResponse, {});
assertEqual(result.type, 'message', 'should return valid message type');
assertEqual(result.role, 'assistant', 'should be assistant role');
assertExists(result.content[0].text, 'should have error text');
});
// Test 12: Streaming disabled (not yet supported)
runner.test('disables streaming (not yet supported)', () => {
const transformer = new GlmtTransformer();
const input = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test' }],
stream: true
};
const { openaiRequest } = transformer.transformRequest(input);
assertEqual(openaiRequest.stream, false, 'stream should be disabled');
});
// Test 13: Debug mode disabled by default
runner.test('debug mode disabled by default', () => {
const transformer = new GlmtTransformer();
assertEqual(transformer.debugLog, false, 'debugLog should be false by default');
});
// Test 14: Debug mode enabled via config
runner.test('debug mode enabled via config', () => {
const transformer = new GlmtTransformer({ debugLog: true });
assertEqual(transformer.debugLog, true, 'debugLog should be true when enabled');
});
// Test 15: Debug mode enabled via env var
runner.test('debug mode enabled via CCS_DEBUG_LOG=1', () => {
process.env.CCS_DEBUG_LOG = '1';
const transformer = new GlmtTransformer();
assertEqual(transformer.debugLog, true, 'debugLog should be true when CCS_DEBUG_LOG=1');
delete process.env.CCS_DEBUG_LOG;
});
// Test 16: Debug log directory path
runner.test('debug log directory uses ~/.ccs/logs by default', () => {
const transformer = new GlmtTransformer();
const os = require('os');
const path = require('path');
const expectedPath = path.join(os.homedir(), '.ccs', 'logs');
assertEqual(transformer.debugLogDir, expectedPath, 'debugLogDir should be ~/.ccs/logs');
});
// Test 17: Validate transformation checks
runner.test('validates transformation with all checks', () => {
const transformer = new GlmtTransformer();
const validResponse = {
type: 'message',
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'reasoning...' },
{ type: 'text', text: 'answer' }
],
usage: { input_tokens: 10, output_tokens: 20 }
};
const validation = transformer._validateTransformation(validResponse);
assertEqual(validation.passed, 5, 'All 5 checks should pass');
assertEqual(validation.total, 5, 'Should have 5 total checks');
assertEqual(validation.valid, true, 'Should be valid');
assertEqual(validation.checks.hasContent, true, 'hasContent check');
assertEqual(validation.checks.hasThinking, true, 'hasThinking check');
assertEqual(validation.checks.hasText, true, 'hasText check');
assertEqual(validation.checks.validStructure, true, 'validStructure check');
assertEqual(validation.checks.hasUsage, true, 'hasUsage check');
});
// Test 18: Validate transformation with missing thinking
runner.test('validates transformation without thinking block', () => {
const transformer = new GlmtTransformer();
const response = {
type: 'message',
role: 'assistant',
content: [
{ type: 'text', text: 'answer' }
],
usage: { input_tokens: 10, output_tokens: 20 }
};
const validation = transformer._validateTransformation(response);
assertEqual(validation.passed, 4, '4 checks should pass (no thinking)');
assertEqual(validation.checks.hasThinking, false, 'hasThinking should be false');
assertEqual(validation.checks.hasText, true, 'hasText should be true');
});
// Run tests
runner.run().then(success => {
process.exit(success ? 0 : 1);
}).catch(error => {
console.error('Test runner error:', error);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../bin/glmt-transformer');
console.log('=== Performance Test: Debug Mode Impact ===\n');
const iterations = 1000;
const sampleRequest = {
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'Test performance' }]
};
const sampleResponse = {
id: 'perf-test',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'Quick response',
reasoning_content: 'Some reasoning content here...'
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }
};
// Test 1: Debug OFF
console.log(`Test 1: Debug OFF (${iterations} iterations)`);
const transformerOff = new GlmtTransformer({ debugLog: false, verbose: false });
const startOff = Date.now();
for (let i = 0; i < iterations; i++) {
transformerOff.transformRequest(sampleRequest);
transformerOff.transformResponse(sampleResponse, {});
}
const endOff = Date.now();
const timeOff = endOff - startOff;
console.log(` Total time: ${timeOff}ms`);
console.log(` Average per request: ${(timeOff / iterations).toFixed(2)}ms`);
// Test 2: Debug ON
console.log(`\nTest 2: Debug ON (${iterations} iterations)`);
const transformerOn = new GlmtTransformer({ debugLog: true, verbose: false });
const startOn = Date.now();
for (let i = 0; i < iterations; i++) {
transformerOn.transformRequest(sampleRequest);
transformerOn.transformResponse(sampleResponse, {});
}
const endOn = Date.now();
const timeOn = endOn - startOn;
console.log(` Total time: ${timeOn}ms`);
console.log(` Average per request: ${(timeOn / iterations).toFixed(2)}ms`);
// Calculate overhead
const overhead = timeOn - timeOff;
const overheadPercent = ((overhead / timeOff) * 100).toFixed(2);
console.log(`\n=== Results ===`);
console.log(`Debug OFF: ${timeOff}ms`);
console.log(`Debug ON: ${timeOn}ms`);
console.log(`Overhead: ${overhead}ms (${overheadPercent}%)`);
// Note: High overhead is expected due to file I/O
console.log(`\nNote: Debug mode is opt-in only and disabled by default.`);
console.log(`When disabled, there is NO performance impact (early return in _writeDebugLog).`);
// Cleanup
const fs = require('fs');
const path = require('path');
const os = require('os');
const logDir = path.join(os.homedir(), '.ccs', 'logs');
if (fs.existsSync(logDir)) {
fs.rmSync(logDir, { recursive: true, force: true });
console.log(`\nCleaned up ${iterations * 4} debug log files.`);
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../bin/glmt-transformer');
console.log('=== Demo: Verbose Output with Reasoning Detection ===\n');
const transformer = new GlmtTransformer({ verbose: true, debugLog: false });
const openaiResponse = {
id: 'demo-response',
model: 'GLM-4.6',
choices: [{
message: {
role: 'assistant',
content: 'The answer is 42. This is based on careful analysis of the problem.',
reasoning_content: `Let me think through this problem step by step.
First, I need to understand what's being asked. The question relates to the fundamental nature of the universe.
Based on Douglas Adams' work, we know that after Deep Thought computed for 7.5 million years, it determined that the answer to the Ultimate Question of Life, the Universe, and Everything is 42.
However, the actual question itself was never properly formulated. This suggests that knowing the answer is meaningless without understanding the question.
In a practical sense, this teaches us an important lesson about problem-solving: we must first ensure we're asking the right questions before seeking answers.
Therefore, my response will acknowledge both the famous answer and the philosophical implications of the question-answer relationship.`
},
finish_reason: 'stop'
}],
usage: { prompt_tokens: 50, completion_tokens: 150, total_tokens: 200 }
};
console.log('Transforming OpenAI response with reasoning_content to Anthropic format...\n');
const result = transformer.transformResponse(openaiResponse, { thinking: true });
console.log('\n=== Transformation Complete ===');
console.log(`\nResult structure:`);
console.log(` Type: ${result.type}`);
console.log(` Role: ${result.role}`);
console.log(` Content blocks: ${result.content.length}`);
console.log(` - Block 1: ${result.content[0].type}`);
console.log(` - Block 2: ${result.content[1].type}`);
console.log(` Stop reason: ${result.stop_reason}`);
console.log(` Usage: input=${result.usage.prompt_tokens}, output=${result.usage.completion_tokens}`);