chore: complete TypeScript migration (Phase 05-06)

Phase 05: Migrate test imports from bin/ to dist/
- Update 19 test files to import from dist/ instead of bin/
- Update CLI path in cli.test.js and special-commands.test.js
- Update cross-platform.test.js to check dist/ directory

Phase 06: Cleanup & validation
- Remove bin/ directory (32 JS files)
- Fix ClaudeSymlinkManager import in doctor.ts
- Update postinstall.js to use dist/ modules
- Regenerate package-lock.json (now points to dist/ccs.js)

All 39 tests passing. TypeScript migration complete.
This commit is contained in:
kaitranntt
2025-11-26 20:20:54 -05:00
parent 26e279decf
commit 1c3c4aade0
54 changed files with 29 additions and 9340 deletions
-499
View File
@@ -1,499 +0,0 @@
'use strict';
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const ProfileRegistry = require('./profile-registry');
const InstanceManager = require('../management/instance-manager');
const { colored } = require('../utils/helpers');
const { detectClaudeCli } = require('../utils/claude-detector');
const { InteractivePrompt } = require('../utils/prompt');
const CCS_VERSION = require('../../package.json').version;
/**
* Auth Commands (Simplified)
*
* CLI interface for CCS multi-account management.
* Commands: create, list, show, remove, default
*
* Login-per-profile model: Each profile is an isolated Claude instance.
* Users login directly in each instance (no credential copying).
*/
class AuthCommands {
constructor() {
this.registry = new ProfileRegistry();
this.instanceMgr = new InstanceManager();
}
/**
* Show help for auth commands
*/
showHelp() {
console.log(colored('CCS Concurrent Account Management', 'bold'));
console.log('');
console.log(colored('Usage:', 'cyan'));
console.log(` ${colored('ccs auth', 'yellow')} <command> [options]`);
console.log('');
console.log(colored('Commands:', 'cyan'));
console.log(` ${colored('create <profile>', 'yellow')} Create new profile and login`);
console.log(` ${colored('list', 'yellow')} List all saved profiles`);
console.log(` ${colored('show <profile>', 'yellow')} Show profile details`);
console.log(` ${colored('remove <profile>', 'yellow')} Remove saved profile`);
console.log(` ${colored('default <profile>', 'yellow')} Set default profile`);
console.log('');
console.log(colored('Examples:', 'cyan'));
console.log(` ${colored('ccs auth create work', 'yellow')} # Create & login to work profile`);
console.log(` ${colored('ccs auth default work', 'yellow')} # Set work as default`);
console.log(` ${colored('ccs auth list', 'yellow')} # List all profiles`);
console.log(` ${colored('ccs work "review code"', 'yellow')} # Use work profile`);
console.log(` ${colored('ccs "review code"', 'yellow')} # Use default profile`);
console.log('');
console.log(colored('Options:', 'cyan'));
console.log(` ${colored('--force', 'yellow')} Allow overwriting existing profile (create)`);
console.log(` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts (remove)`);
console.log(` ${colored('--json', 'yellow')} Output in JSON format (list, show)`);
console.log(` ${colored('--verbose', 'yellow')} Show additional details (list)`);
console.log('');
console.log(colored('Note:', 'cyan'));
console.log(` By default, ${colored('ccs', 'yellow')} uses Claude CLI defaults from ~/.claude/`);
console.log(` Use ${colored('ccs auth default <profile>', 'yellow')} to change the default profile.`);
console.log('');
}
/**
* Create new profile and prompt for login
* @param {Array} args - Command arguments
*/
async handleCreate(args) {
const profileName = args.find(arg => !arg.startsWith('--'));
const force = args.includes('--force');
if (!profileName) {
console.error('[X] Profile name is required');
console.log('');
console.log(`Usage: ${colored('ccs auth create <profile> [--force]', 'yellow')}`);
console.log('');
console.log('Example:');
console.log(` ${colored('ccs auth create work', 'yellow')}`);
process.exit(1);
}
// Check if profile already exists
if (!force && this.registry.hasProfile(profileName)) {
console.error(`[X] Profile already exists: ${profileName}`);
console.log(` Use ${colored('--force', 'yellow')} to overwrite`);
process.exit(1);
}
try {
// Create instance directory
console.log(`[i] Creating profile: ${profileName}`);
const instancePath = this.instanceMgr.ensureInstance(profileName);
// Create/update profile entry
if (this.registry.hasProfile(profileName)) {
this.registry.updateProfile(profileName, {
type: 'account'
});
} else {
this.registry.createProfile(profileName, {
type: 'account'
});
}
console.log(`[i] Instance directory: ${instancePath}`);
console.log('');
console.log(colored('[i] Starting Claude in isolated instance...', 'yellow'));
console.log(colored('[i] You will be prompted to login with your account.', 'yellow'));
console.log('');
// Detect Claude CLI
const claudeCli = detectClaudeCli();
if (!claudeCli) {
console.error('[X] Claude CLI not found');
console.log('');
console.log('Please install Claude CLI first:');
console.log(' https://claude.ai/download');
process.exit(1);
}
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
const child = spawn(claudeCli, [], {
stdio: 'inherit',
env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath }
});
child.on('exit', (code) => {
if (code === 0) {
console.log('');
console.log(colored('[OK] Profile created successfully', 'green'));
console.log('');
console.log(` Profile: ${profileName}`);
console.log(` Instance: ${instancePath}`);
console.log('');
console.log('Usage:');
console.log(` ${colored(`ccs ${profileName} "your prompt here"`, 'yellow')} # Use this specific profile`);
console.log('');
console.log('To set as default (so you can use just "ccs"):');
console.log(` ${colored(`ccs auth default ${profileName}`, 'yellow')}`);
console.log('');
process.exit(0);
} else {
console.log('');
console.error('[X] Login failed or cancelled');
console.log('');
console.log('To retry:');
console.log(` ${colored(`ccs auth create ${profileName} --force`, 'yellow')}`);
console.log('');
process.exit(1);
}
});
child.on('error', (err) => {
console.error(`[X] Failed to execute Claude CLI: ${err.message}`);
process.exit(1);
});
} catch (error) {
console.error(`[X] Failed to create profile: ${error.message}`);
process.exit(1);
}
}
/**
* List all saved profiles
* @param {Array} args - Command arguments
*/
async handleList(args) {
const verbose = args.includes('--verbose');
const json = args.includes('--json');
try {
const profiles = this.registry.getAllProfiles();
const defaultProfile = this.registry.getDefaultProfile();
const profileNames = Object.keys(profiles);
// JSON output mode
if (json) {
const output = {
version: CCS_VERSION,
profiles: profileNames.map(name => {
const profile = profiles[name];
const isDefault = name === defaultProfile;
const instancePath = this.instanceMgr.getInstancePath(name);
return {
name: name,
type: profile.type || 'account',
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
instance_path: instancePath
};
})
};
console.log(JSON.stringify(output, null, 2));
return;
}
// Human-readable output
if (profileNames.length === 0) {
console.log(colored('No account profiles found', 'yellow'));
console.log('');
console.log('To create your first profile:');
console.log(` ${colored('ccs auth create <profile>', 'yellow')} # Create and login to profile`);
console.log('');
console.log('Example:');
console.log(` ${colored('ccs auth create work', 'yellow')}`);
console.log('');
return;
}
console.log(colored('Saved Account Profiles:', 'bold'));
console.log('');
// Sort by last_used (descending), then alphabetically
const sorted = profileNames.sort((a, b) => {
const aProfile = profiles[a];
const bProfile = profiles[b];
// Default first
if (a === defaultProfile) return -1;
if (b === defaultProfile) return 1;
// Then by last_used
if (aProfile.last_used && bProfile.last_used) {
return new Date(bProfile.last_used) - new Date(aProfile.last_used);
}
if (aProfile.last_used) return -1;
if (bProfile.last_used) return 1;
// Then alphabetically
return a.localeCompare(b);
});
sorted.forEach(name => {
const profile = profiles[name];
const isDefault = name === defaultProfile;
const indicator = isDefault ? colored('[*]', 'green') : '[ ]';
console.log(`${indicator} ${colored(name, 'cyan')}${isDefault ? colored(' (default)', 'green') : ''}`);
console.log(` Type: ${profile.type || 'account'}`);
if (verbose) {
console.log(` Created: ${new Date(profile.created).toLocaleString()}`);
if (profile.last_used) {
console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`);
}
}
console.log('');
});
console.log(`Total profiles: ${profileNames.length}`);
console.log('');
} catch (error) {
console.error(`[X] Failed to list profiles: ${error.message}`);
process.exit(1);
}
}
/**
* Show details for a specific profile
* @param {Array} args - Command arguments
*/
async handleShow(args) {
const profileName = args.find(arg => !arg.startsWith('--'));
const json = args.includes('--json');
if (!profileName) {
console.error('[X] Profile name is required');
console.log('');
console.log(`Usage: ${colored('ccs auth show <profile> [--json]', 'yellow')}`);
process.exit(1);
}
try {
const profile = this.registry.getProfile(profileName);
const defaultProfile = this.registry.getDefaultProfile();
const isDefault = profileName === defaultProfile;
const instancePath = this.instanceMgr.getInstancePath(profileName);
// Count sessions
let sessionCount = 0;
try {
const sessionsDir = path.join(instancePath, 'session-env');
if (fs.existsSync(sessionsDir)) {
const files = fs.readdirSync(sessionsDir);
sessionCount = files.filter(f => f.endsWith('.json')).length;
}
} catch (e) {
// Ignore errors counting sessions
}
// JSON output mode
if (json) {
const output = {
name: profileName,
type: profile.type || 'account',
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
instance_path: instancePath,
session_count: sessionCount
};
console.log(JSON.stringify(output, null, 2));
return;
}
// Human-readable output
console.log(colored(`Profile: ${profileName}`, 'bold'));
console.log('');
console.log(` Type: ${profile.type || 'account'}`);
console.log(` Default: ${isDefault ? 'Yes' : 'No'}`);
console.log(` Instance: ${instancePath}`);
console.log(` Created: ${new Date(profile.created).toLocaleString()}`);
if (profile.last_used) {
console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`);
} else {
console.log(` Last used: Never`);
}
console.log('');
} catch (error) {
console.error(`[X] ${error.message}`);
process.exit(1);
}
}
/**
* Remove a saved profile
* @param {Array} args - Command arguments
*/
async handleRemove(args) {
const profileName = args.find(arg => !arg.startsWith('--') && !arg.startsWith('-'));
if (!profileName) {
console.error('[X] Profile name is required');
console.log('');
console.log(`Usage: ${colored('ccs auth remove <profile> [--yes]', 'yellow')}`);
process.exit(1);
}
if (!this.registry.hasProfile(profileName)) {
console.error(`[X] Profile not found: ${profileName}`);
process.exit(1);
}
try {
// Get instance path and session count for impact display
const instancePath = this.instanceMgr.getInstancePath(profileName);
let sessionCount = 0;
try {
const sessionsDir = path.join(instancePath, 'session-env');
if (fs.existsSync(sessionsDir)) {
const files = fs.readdirSync(sessionsDir);
sessionCount = files.filter(f => f.endsWith('.json')).length;
}
} catch (e) {
// Ignore errors counting sessions
}
// Display impact
console.log('');
console.log(`Profile '${colored(profileName, 'cyan')}' will be permanently deleted.`);
console.log(` Instance path: ${instancePath}`);
console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`);
console.log('');
// Interactive confirmation (or --yes flag)
const confirmed = await InteractivePrompt.confirm(
'Delete this profile?',
{ default: false } // Default to NO (safe)
);
if (!confirmed) {
console.log('[i] Cancelled');
process.exit(0);
}
// Delete instance
this.instanceMgr.deleteInstance(profileName);
// Delete profile
this.registry.deleteProfile(profileName);
console.log(colored('[OK] Profile removed successfully', 'green'));
console.log(` Profile: ${profileName}`);
console.log('');
} catch (error) {
console.error(`[X] Failed to remove profile: ${error.message}`);
process.exit(1);
}
}
/**
* Set default profile
* @param {Array} args - Command arguments
*/
async handleDefault(args) {
const profileName = args.find(arg => !arg.startsWith('--'));
if (!profileName) {
console.error('[X] Profile name is required');
console.log('');
console.log(`Usage: ${colored('ccs auth default <profile>', 'yellow')}`);
process.exit(1);
}
try {
this.registry.setDefaultProfile(profileName);
console.log(colored('[OK] Default profile set', 'green'));
console.log(` Profile: ${profileName}`);
console.log('');
console.log('Now you can use:');
console.log(` ${colored('ccs "your prompt"', 'yellow')} # Uses ${profileName} profile`);
console.log('');
} catch (error) {
console.error(`[X] ${error.message}`);
process.exit(1);
}
}
/**
* Route auth command to appropriate handler
* @param {Array} args - Command arguments
*/
async route(args) {
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
this.showHelp();
return;
}
const command = args[0];
const commandArgs = args.slice(1);
switch (command) {
case 'create':
await this.handleCreate(commandArgs);
break;
case 'save':
// Deprecated - redirect to create
console.log(colored('[!] Command "save" is deprecated', 'yellow'));
console.log(` Use: ${colored('ccs auth create <profile>', 'yellow')} instead`);
console.log('');
await this.handleCreate(commandArgs);
break;
case 'list':
await this.handleList(commandArgs);
break;
case 'show':
await this.handleShow(commandArgs);
break;
case 'remove':
await this.handleRemove(commandArgs);
break;
case 'default':
await this.handleDefault(commandArgs);
break;
case 'current':
console.log(colored('[!] Command "current" has been removed', 'yellow'));
console.log('');
console.log('Each profile has its own login in an isolated instance.');
console.log('Use "ccs auth list" to see all profiles.');
console.log('');
break;
case 'cleanup':
console.log(colored('[!] Command "cleanup" has been removed', 'yellow'));
console.log('');
console.log('No cleanup needed - no separate vault files.');
console.log('Use "ccs auth list" to see all profiles.');
console.log('');
break;
default:
console.error(`[X] Unknown command: ${command}`);
console.log('');
console.log('Run for help:');
console.log(` ${colored('ccs auth --help', 'yellow')}`);
process.exit(1);
}
}
}
module.exports = AuthCommands;
-204
View File
@@ -1,204 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { findSimilarStrings } = require('../utils/helpers');
/**
* Profile Detector
*
* Determines profile type (settings-based vs account-based) for routing.
* Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility.
*/
class ProfileDetector {
constructor() {
this.configPath = path.join(os.homedir(), '.ccs', 'config.json');
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
}
/**
* Read settings-based config (config.json)
* @returns {Object} Config data
*/
_readConfig() {
if (!fs.existsSync(this.configPath)) {
return { profiles: {} };
}
try {
const data = fs.readFileSync(this.configPath, 'utf8');
return JSON.parse(data);
} catch (error) {
console.warn(`[!] Warning: Could not read config.json: ${error.message}`);
return { profiles: {} };
}
}
/**
* Read account-based profiles (profiles.json)
* @returns {Object} Profiles data
*/
_readProfiles() {
if (!fs.existsSync(this.profilesPath)) {
return { profiles: {}, default: null };
}
try {
const data = fs.readFileSync(this.profilesPath, 'utf8');
return JSON.parse(data);
} catch (error) {
console.warn(`[!] Warning: Could not read profiles.json: ${error.message}`);
return { profiles: {}, default: null };
}
}
/**
* Detect profile type and return routing information
* @param {string} profileName - Profile name to detect
* @returns {Object} {type: 'settings'|'account'|'default', ...info}
*/
detectProfileType(profileName) {
// Special case: 'default' means use default profile
if (profileName === 'default' || profileName === null || profileName === undefined) {
return this._resolveDefaultProfile();
}
// Priority 1: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY
const config = this._readConfig();
if (config.profiles && config.profiles[profileName]) {
return {
type: 'settings',
name: profileName,
settingsPath: config.profiles[profileName]
};
}
// Priority 2: Check account-based profiles (work, personal)
const profiles = this._readProfiles();
if (profiles.profiles && profiles.profiles[profileName]) {
return {
type: 'account',
name: profileName,
profile: profiles.profiles[profileName]
};
}
// Not found - generate suggestions
const allProfiles = this.getAllProfiles();
const allProfileNames = [...allProfiles.settings, ...allProfiles.accounts];
const suggestions = findSimilarStrings(profileName, allProfileNames);
const error = new Error(`Profile not found: ${profileName}`);
error.profileName = profileName;
error.suggestions = suggestions;
error.availableProfiles = this._listAvailableProfiles();
throw error;
}
/**
* Resolve default profile
* @returns {Object} Default profile info
*/
_resolveDefaultProfile() {
// Check if account-based default exists
const profiles = this._readProfiles();
if (profiles.default && profiles.profiles[profiles.default]) {
return {
type: 'account',
name: profiles.default,
profile: profiles.profiles[profiles.default]
};
}
// Check if settings-based default exists
const config = this._readConfig();
if (config.profiles && config.profiles['default']) {
return {
type: 'settings',
name: 'default',
settingsPath: config.profiles['default']
};
}
// No default profile configured, use Claude's own defaults
return {
type: 'default',
name: 'default',
message: 'No profile configured. Using Claude CLI defaults from ~/.claude/'
};
}
/**
* List available profiles (for error messages)
* @returns {string} Formatted list
*/
_listAvailableProfiles() {
const lines = [];
// Settings-based profiles
const config = this._readConfig();
const settingsProfiles = Object.keys(config.profiles || {});
if (settingsProfiles.length > 0) {
lines.push('Settings-based profiles (GLM, Kimi, etc.):');
settingsProfiles.forEach(name => {
lines.push(` - ${name}`);
});
}
// Account-based profiles
const profiles = this._readProfiles();
const accountProfiles = Object.keys(profiles.profiles || {});
if (accountProfiles.length > 0) {
lines.push('Account-based profiles:');
accountProfiles.forEach(name => {
const isDefault = name === profiles.default;
lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`);
});
}
if (lines.length === 0) {
return ' (no profiles configured)\n' +
' Run "ccs auth save <profile>" to create your first account profile.';
}
return lines.join('\n');
}
/**
* Check if profile exists (any type)
* @param {string} profileName - Profile name
* @returns {boolean} True if exists
*/
hasProfile(profileName) {
try {
this.detectProfileType(profileName);
return true;
} catch {
return false;
}
}
/**
* Get all available profile names
* @returns {Object} {settings: [...], accounts: [...]}
*/
getAllProfiles() {
const config = this._readConfig();
const profiles = this._readProfiles();
return {
settings: Object.keys(config.profiles || {}),
accounts: Object.keys(profiles.profiles || {}),
default: profiles.default
};
}
}
module.exports = ProfileDetector;
-225
View File
@@ -1,225 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Profile Registry (Simplified)
*
* Manages account profile metadata in ~/.ccs/profiles.json
* Each profile represents an isolated Claude instance with login credentials.
*
* Profile Schema (v3.0 - Minimal):
* {
* type: 'account', // Profile type
* created: <ISO timestamp>, // Creation time
* last_used: <ISO timestamp or null> // Last usage time
* }
*
* Removed fields from v2.x:
* - vault: No encrypted vault (credentials in instance)
* - subscription: No credential reading
* - email: No credential reading
*/
class ProfileRegistry {
constructor() {
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
}
/**
* Read profiles from disk
* @returns {Object} Profiles data
*/
_read() {
if (!fs.existsSync(this.profilesPath)) {
return {
version: '2.0.0',
profiles: {},
default: null
};
}
try {
const data = fs.readFileSync(this.profilesPath, 'utf8');
return JSON.parse(data);
} catch (error) {
throw new Error(`Failed to read profiles: ${error.message}`);
}
}
/**
* Write profiles to disk atomically
* @param {Object} data - Profiles data
*/
_write(data) {
const dir = path.dirname(this.profilesPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Atomic write: temp file + rename
const tempPath = `${this.profilesPath}.tmp`;
try {
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, this.profilesPath);
} catch (error) {
// Cleanup temp file on error
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
throw new Error(`Failed to write profiles: ${error.message}`);
}
}
/**
* Create a new profile
* @param {string} name - Profile name
* @param {Object} metadata - Profile metadata (type, created, last_used)
*/
createProfile(name, metadata = {}) {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
// v3.0 minimal schema: only essential fields
data.profiles[name] = {
type: metadata.type || 'account',
created: new Date().toISOString(),
last_used: null
};
// Note: No longer auto-set as default
// Users must explicitly run: ccs auth default <profile>
// Default always stays on implicit 'default' profile (uses ~/.claude/)
this._write(data);
}
/**
* Get profile metadata
* @param {string} name - Profile name
* @returns {Object} Profile metadata
*/
getProfile(name) {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
return data.profiles[name];
}
/**
* Update profile metadata
* @param {string} name - Profile name
* @param {Object} updates - Fields to update
*/
updateProfile(name, updates) {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = {
...data.profiles[name],
...updates
};
this._write(data);
}
/**
* Delete a profile
* @param {string} name - Profile name
*/
deleteProfile(name) {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
delete data.profiles[name];
// Clear default if it was the deleted profile
if (data.default === name) {
// Set to first remaining profile or null
const remaining = Object.keys(data.profiles);
data.default = remaining.length > 0 ? remaining[0] : null;
}
this._write(data);
}
/**
* List all profiles
* @returns {Array} Array of profile names
*/
listProfiles() {
const data = this._read();
return Object.keys(data.profiles);
}
/**
* Get all profiles with metadata
* @returns {Object} All profiles
*/
getAllProfiles() {
const data = this._read();
return data.profiles;
}
/**
* Get default profile name
* @returns {string|null} Default profile name
*/
getDefaultProfile() {
const data = this._read();
return data.default;
}
/**
* Set default profile
* @param {string} name - Profile name
*/
setDefaultProfile(name) {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
}
/**
* Check if profile exists
* @param {string} name - Profile name
* @returns {boolean}
*/
hasProfile(name) {
const data = this._read();
return !!data.profiles[name];
}
/**
* Update last used timestamp
* @param {string} name - Profile name
*/
touchProfile(name) {
this.updateProfile(name, {
last_used: new Date().toISOString()
});
}
}
module.exports = ProfileRegistry;
-1034
View File
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
# CCS Delegation Module
Enhanced Claude Code delegation system for multi-model task delegation.
## Files
### Core Components
- **headless-executor.js** (405 lines) - Main executor, spawns `claude -p` with enhanced features
- **session-manager.js** (156 lines) - Session persistence and cost tracking
- **settings-parser.js** (88 lines) - Parse tool restrictions from settings
- **result-formatter.js** (326 lines) - Terminal output formatting
**Total**: 975 lines (down from 1,755 lines - 44% reduction)
## Features
### Enhanced Headless Execution
- Stream-JSON output parsing (`--output-format stream-json`)
- Real-time tool use visibility in TTY
- Permission mode acceptEdits (`--permission-mode acceptEdits`)
- Tool restrictions from `.claude/settings.local.json`
- Multi-turn session management (`--resume <session-id>`)
- Time-based limits (10 min default timeout with graceful termination)
- Cost tracking and aggregation
### Session Management
- Persistence: `~/.ccs/delegation-sessions.json`
- Resume via `/ccs:continue` (auto-detects profile)
- Auto-cleanup expired sessions (>30 days)
- Cost aggregation across turns
### Settings
- Profile location: `~/.ccs/{profile}.settings.json`
- Examples: `glm.settings.json`, `kimi.settings.json`, `glmt.settings.json`
- Tool restrictions from `.claude/settings.local.json`
## Usage
### Basic Delegation
```javascript
const { HeadlessExecutor } = require('./headless-executor');
const result = await HeadlessExecutor.execute('glm', 'Refactor auth.js', {
cwd: '/path/to/project',
outputFormat: 'stream-json',
permissionMode: 'acceptEdits',
timeout: 600000 // 10 minutes
});
console.log(result.sessionId); // For multi-turn
console.log(result.totalCost); // Cost in USD
console.log(result.content); // Result text
```
### Multi-Turn Sessions
```javascript
// Start session
const result1 = await HeadlessExecutor.execute('glm', 'Implement feature');
const sessionId = result1.sessionId;
// Continue session
const result2 = await HeadlessExecutor.execute('glm', 'Add tests', {
resumeSession: true
});
// Or with specific session ID
const result3 = await HeadlessExecutor.execute('glm', 'Run tests', {
sessionId: sessionId
});
```
### Tool Restrictions
Create `.claude/settings.local.json`:
```json
{
"permissions": {
"allow": ["Bash(git:*)", "Read", "Edit"],
"deny": ["Bash(rm:*)", "Bash(sudo:*)"]
}
}
```
Automatically applied as CLI flags:
```bash
--allowedTools "Bash(git:*)" "Read" "Edit" \
--disallowedTools "Bash(rm:*)" "Bash(sudo:*)"
```
## Slash Commands
The delegation system is invoked via simple slash commands in `.claude/commands/ccs/`:
### Basic Commands
- `/ccs "task"` - Delegate task (auto-selects best profile)
- `/ccs --glm "task"` - Force GLM-4.6 delegation
- `/ccs --kimi "task"` - Force Kimi delegation (long-context)
### Session Continuation
- `/ccs:continue "follow-up"` - Resume last delegation session (auto-detect profile)
- `/ccs:continue --glm "follow-up"` - Resume with specific profile switch
Each command directly invokes:
```bash
claude -p "$ARGUMENTS" \
--settings ~/.ccs/{profile}.settings.json \
--output-format stream-json \
--permission-mode acceptEdits
```
## Debug Mode
```bash
export CCS_DEBUG=1
```
Enables verbose logging:
- Permission mode selection
- Session resumption details
- Tool restrictions parsing
- CLI args construction
- Session persistence events
## Testing
```bash
# Run all delegation tests
node tests/unit/delegation/json-output.test.js
node tests/unit/delegation/permission-mode.test.js
node tests/unit/delegation/session-manager.test.js
node tests/unit/delegation/settings-parser.test.js
node tests/unit/delegation/max-turns.test.js
node tests/unit/delegation/result-formatter.test.js
```
**Test Coverage:**
- JSON output parsing (6 tests)
- Permission modes (11 tests)
- Session management (7 tests)
- Settings parser (6 tests)
- Auto max-turns (14 tests)
- Result formatting (14 tests)
- **Total: 58 tests**
## Architecture
```
User → SlashCommand (/ccs)
→ ccs-delegation skill (auto-selects profile)
→ Directly invokes: claude -p
→ HeadlessExecutor (monitors execution)
→ SessionManager (load last session)
→ SettingsParser (tool restrictions)
→ Parse JSON response
→ SessionManager (store/update)
→ ResultFormatter.format()
→ Display to user
```
**Key Simplification**: Slash commands invoke `claude -p` directly. No intermediate delegation engine or rule system - just direct headless execution with enhanced features.
## File Permissions
All files should be `644` (rw-r--r--):
```bash
chmod 644 bin/delegation/*.js
```
## Dependencies
- Node.js 14+
- Claude CLI installed and in PATH
- Profile settings configured in `~/.ccs/{profile}.settings.json`
## Migration from Legacy System
**Removed components** (as of 2025-11-15):
- ~~delegation-engine.js~~ - Rule-based decision engine (unused)
- ~~cwd-resolver.js~~ - Working directory resolution (unused)
- ~~rules-schema.js~~ - Schema validation (unused)
- ~~delegation-rules.json~~ - Configuration file (not created)
**Why removed**: Current slash commands directly invoke `claude -p` without intermediate orchestration. The delegation engine, CWD resolver, and rules schema were designed for a more complex system that was never fully integrated.
**Result**: 44% code reduction (1,755 → 975 lines) with same functionality.
## References
- Official docs: https://code.claude.com/docs/en/headless.md
- Skill: `.claude/skills/ccs-delegation/`
- Commands: `.claude/commands/ccs/`
- Tests: `tests/unit/delegation/`
-212
View File
@@ -1,212 +0,0 @@
#!/usr/bin/env node
'use strict';
const { HeadlessExecutor } = require('./headless-executor');
const { SessionManager } = require('./session-manager');
const { ResultFormatter } = require('./result-formatter');
const { DelegationValidator } = require('../utils/delegation-validator');
const { SettingsParser } = require('./settings-parser');
/**
* Delegation command handler
* Routes -p flag commands to HeadlessExecutor with enhanced features
*/
class DelegationHandler {
/**
* Route delegation command
* @param {Array<string>} args - Full args array from ccs.js
*/
async route(args) {
try {
// 1. Parse args into { profile, prompt, options }
const parsed = this._parseArgs(args);
// 2. Detect special profiles (glm:continue, kimi:continue)
if (parsed.profile.includes(':continue')) {
return await this._handleContinue(parsed);
}
// 3. Validate profile
this._validateProfile(parsed.profile);
// 4. Execute via HeadlessExecutor
const result = await HeadlessExecutor.execute(
parsed.profile,
parsed.prompt,
parsed.options
);
// 5. Format and display results
const formatted = ResultFormatter.format(result);
console.log(formatted);
// 6. Exit with proper code
process.exit(result.exitCode || 0);
} catch (error) {
console.error(`[X] Delegation error: ${error.message}`);
if (process.env.CCS_DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
/**
* Handle continue command (resume last session)
* @param {Object} parsed - Parsed args
*/
async _handleContinue(parsed) {
const baseProfile = parsed.profile.replace(':continue', '');
// Get last session from SessionManager
const sessionMgr = new SessionManager();
const lastSession = sessionMgr.getLastSession(baseProfile);
if (!lastSession) {
console.error(`[X] No previous session found for ${baseProfile}`);
console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`);
process.exit(1);
}
// Execute with resume flag
const result = await HeadlessExecutor.execute(
baseProfile,
parsed.prompt,
{
...parsed.options,
resumeSession: true,
sessionId: lastSession.sessionId
}
);
const formatted = ResultFormatter.format(result);
console.log(formatted);
process.exit(result.exitCode || 0);
}
/**
* Parse args into structured format
* @param {Array<string>} args - Raw args
* @returns {Object} { profile, prompt, options }
*/
_parseArgs(args) {
// Extract profile (first non-flag arg or 'default')
const profile = this._extractProfile(args);
// Extract prompt from -p or --prompt
const prompt = this._extractPrompt(args);
// Extract options (--timeout, --permission-mode, etc.)
const options = this._extractOptions(args);
return { profile, prompt, options };
}
/**
* Extract profile from args (first non-flag arg)
* @param {Array<string>} args - Args array
* @returns {string} Profile name
*/
_extractProfile(args) {
// Find first arg that doesn't start with '-' and isn't -p value
let skipNext = false;
for (let i = 0; i < args.length; i++) {
if (skipNext) {
skipNext = false;
continue;
}
if (args[i] === '-p' || args[i] === '--prompt') {
skipNext = true;
continue;
}
if (!args[i].startsWith('-')) {
return args[i];
}
}
// No profile specified, return null (will error in validation)
return null;
}
/**
* Extract prompt from -p flag
* @param {Array<string>} args - Args array
* @returns {string} Prompt text
*/
_extractPrompt(args) {
const pIndex = args.indexOf('-p');
const promptIndex = args.indexOf('--prompt');
const index = pIndex !== -1 ? pIndex : promptIndex;
if (index === -1 || index === args.length - 1) {
console.error('[X] Missing prompt after -p flag');
console.error(' Usage: ccs glm -p "task description"');
process.exit(1);
}
return args[index + 1];
}
/**
* Extract options from remaining args
* @param {Array<string>} args - Args array
* @returns {Object} Options for HeadlessExecutor
*/
_extractOptions(args) {
const cwd = process.cwd();
// Read default permission mode from .claude/settings.local.json
// Falls back to 'acceptEdits' if file doesn't exist
const defaultPermissionMode = SettingsParser.parseDefaultPermissionMode(cwd);
const options = {
cwd,
outputFormat: 'stream-json',
permissionMode: defaultPermissionMode
};
// Parse permission-mode (CLI flag overrides settings file)
const permModeIndex = args.indexOf('--permission-mode');
if (permModeIndex !== -1 && permModeIndex < args.length - 1) {
options.permissionMode = args[permModeIndex + 1];
}
// Parse timeout
const timeoutIndex = args.indexOf('--timeout');
if (timeoutIndex !== -1 && timeoutIndex < args.length - 1) {
options.timeout = parseInt(args[timeoutIndex + 1], 10);
}
return options;
}
/**
* Validate profile exists and is configured
* @param {string} profile - Profile name
*/
_validateProfile(profile) {
if (!profile) {
console.error('[X] No profile specified');
console.error(' Usage: ccs <profile> -p "task"');
console.error(' Examples: ccs glm -p "task", ccs kimi -p "task"');
process.exit(1);
}
// Use DelegationValidator to check profile
const validation = DelegationValidator.validate(profile);
if (!validation.valid) {
console.error(`[X] Profile '${profile}' is not configured for delegation`);
console.error(` ${validation.error}`);
console.error('');
console.error(' Run: ccs doctor');
console.error(' Or configure: ~/.ccs/${profile}.settings.json');
process.exit(1);
}
}
}
module.exports = DelegationHandler;
-618
View File
@@ -1,618 +0,0 @@
#!/usr/bin/env node
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');
const { SessionManager } = require('./session-manager');
const { SettingsParser } = require('./settings-parser');
/**
* Headless executor for Claude CLI delegation
* Spawns claude with -p flag for single-turn execution
*/
class HeadlessExecutor {
/**
* Execute task via headless Claude CLI
* @param {string} profile - Profile name (glm, kimi, custom)
* @param {string} enhancedPrompt - Enhanced prompt with context
* @param {Object} options - Execution options
* @param {string} options.cwd - Working directory (absolute path)
* @param {number} options.timeout - Timeout in milliseconds (default: 600000 = 10 minutes)
* @param {string} options.outputFormat - Output format: 'stream-json' or 'text' (default: 'stream-json')
* @param {string} options.permissionMode - Permission mode: 'default', 'plan', 'acceptEdits', 'bypassPermissions' (default: 'acceptEdits')
* @param {boolean} options.resumeSession - Resume last session for profile (default: false)
* @param {string} options.sessionId - Specific session ID to resume
* @returns {Promise<Object>} Execution result
*/
static async execute(profile, enhancedPrompt, options = {}) {
const {
cwd = process.cwd(),
timeout = 600000, // 10 minutes default
outputFormat = 'stream-json', // Use stream-json for real-time progress
permissionMode = 'acceptEdits',
resumeSession = false,
sessionId = null
} = options;
// Validate permission mode
this._validatePermissionMode(permissionMode);
// Initialize session manager
const sessionMgr = new SessionManager();
// Detect Claude CLI path
const claudeCli = this._detectClaudeCli();
if (!claudeCli) {
throw new Error('Claude CLI not found in PATH. Install from: https://docs.claude.com/en/docs/claude-code/installation');
}
// Get settings path for profile
const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`);
// Validate settings file exists
if (!fs.existsSync(settingsPath)) {
throw new Error(`Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.`);
}
// Smart slash command detection and preservation
// Detects if prompt contains slash command and restructures for proper execution
const processedPrompt = this._processSlashCommand(enhancedPrompt);
// Prepare arguments
const args = ['-p', processedPrompt, '--settings', settingsPath];
// Always use stream-json for real-time progress visibility
// Note: --verbose is required when using --print with stream-json
args.push('--output-format', 'stream-json', '--verbose');
// Add permission mode
if (permissionMode && permissionMode !== 'default') {
if (permissionMode === 'bypassPermissions') {
args.push('--dangerously-skip-permissions');
// Warn about dangerous mode
if (process.env.CCS_DEBUG) {
console.warn('[!] WARNING: Using --dangerously-skip-permissions mode');
console.warn('[!] This bypasses ALL permission checks. Use only in trusted environments.');
}
} else {
args.push('--permission-mode', permissionMode);
}
}
// Add resume flag for multi-turn sessions
if (resumeSession) {
const lastSession = sessionMgr.getLastSession(profile);
if (lastSession) {
args.push('--resume', lastSession.sessionId);
if (process.env.CCS_DEBUG) {
const cost = lastSession.totalCost !== undefined && lastSession.totalCost !== null ? lastSession.totalCost.toFixed(4) : '0.0000';
console.error(`[i] Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`);
}
} else if (sessionId) {
args.push('--resume', sessionId);
if (process.env.CCS_DEBUG) {
console.error(`[i] Resuming specific session: ${sessionId}`);
}
} else {
console.warn('[!] No previous session found, starting new session');
}
} else if (sessionId) {
args.push('--resume', sessionId);
if (process.env.CCS_DEBUG) {
console.error(`[i] Resuming specific session: ${sessionId}`);
}
}
// Add tool restrictions from settings
const toolRestrictions = SettingsParser.parseToolRestrictions(cwd);
if (toolRestrictions.allowedTools.length > 0) {
args.push('--allowedTools');
toolRestrictions.allowedTools.forEach(tool => args.push(tool));
}
if (toolRestrictions.disallowedTools.length > 0) {
args.push('--disallowedTools');
toolRestrictions.disallowedTools.forEach(tool => args.push(tool));
}
// Note: No max-turns limit - using time-based limits instead (default 10min timeout)
// Debug log args
if (process.env.CCS_DEBUG) {
console.error(`[i] Claude CLI args: ${args.join(' ')}`);
}
// Execute with spawn
return new Promise((resolve, reject) => {
const startTime = Date.now();
// Show progress unless explicitly disabled with CCS_QUIET
const showProgress = !process.env.CCS_QUIET;
// Show initial progress message
if (showProgress) {
const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase();
console.error(`[i] Delegating to ${modelName}...`);
}
const proc = spawn(claudeCli, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
timeout
});
let stdout = '';
let stderr = '';
let progressInterval;
const messages = []; // Accumulate stream-json messages
let partialLine = ''; // Buffer for incomplete JSON lines
// Handle parent process termination (Ctrl+C or Esc in Claude)
// When main Claude session is killed, cleanup spawned child process
const cleanupHandler = () => {
if (!proc.killed) {
if (process.env.CCS_DEBUG) {
console.error('[!] Parent process terminating, killing delegated session...');
}
proc.kill('SIGTERM');
// Force kill if not dead after 2s
setTimeout(() => {
if (!proc.killed) {
proc.kill('SIGKILL');
}
}, 2000);
}
};
// Register signal handlers for parent process termination
process.once('SIGINT', cleanupHandler);
process.once('SIGTERM', cleanupHandler);
// Cleanup signal handlers when child process exits
const removeSignalHandlers = () => {
process.removeListener('SIGINT', cleanupHandler);
process.removeListener('SIGTERM', cleanupHandler);
};
proc.on('close', removeSignalHandlers);
proc.on('error', removeSignalHandlers);
// Progress indicator (show elapsed time every 5 seconds)
if (showProgress) {
progressInterval = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
process.stderr.write(`[i] Still running... ${elapsed}s elapsed\r`);
}, 5000);
}
// Capture stdout (stream-json format - jsonl)
proc.stdout.on('data', (data) => {
stdout += data.toString();
// Parse stream-json messages (jsonl format - one JSON per line)
const chunk = partialLine + data.toString();
const lines = chunk.split('\n');
partialLine = lines.pop() || ''; // Save incomplete line for next chunk
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
messages.push(msg);
// Show real-time tool use with verbose details
if (showProgress && msg.type === 'assistant') {
const toolUses = msg.message?.content?.filter(c => c.type === 'tool_use') || [];
for (const tool of toolUses) {
process.stderr.write('\r\x1b[K'); // Clear line
// Show verbose tool use with description/input if available
const toolInput = tool.input || {};
let verboseMsg = `[Tool] ${tool.name}`;
// Add context based on tool type (all Claude Code tools)
switch (tool.name) {
case 'Bash':
if (toolInput.command) {
// Truncate long commands
const cmd = toolInput.command.length > 80
? toolInput.command.substring(0, 77) + '...'
: toolInput.command;
verboseMsg += `: ${cmd}`;
}
break;
case 'Edit':
case 'Write':
case 'Read':
if (toolInput.file_path) {
verboseMsg += `: ${toolInput.file_path}`;
}
break;
case 'NotebookEdit':
case 'NotebookRead':
if (toolInput.notebook_path) {
verboseMsg += `: ${toolInput.notebook_path}`;
}
break;
case 'Grep':
if (toolInput.pattern) {
verboseMsg += `: searching for "${toolInput.pattern}"`;
if (toolInput.path) {
verboseMsg += ` in ${toolInput.path}`;
}
}
break;
case 'Glob':
if (toolInput.pattern) {
verboseMsg += `: ${toolInput.pattern}`;
}
break;
case 'SlashCommand':
if (toolInput.command) {
verboseMsg += `: ${toolInput.command}`;
}
break;
case 'Task':
if (toolInput.description) {
verboseMsg += `: ${toolInput.description}`;
} else if (toolInput.prompt) {
const prompt = toolInput.prompt.length > 60
? toolInput.prompt.substring(0, 57) + '...'
: toolInput.prompt;
verboseMsg += `: ${prompt}`;
}
break;
case 'TodoWrite':
if (toolInput.todos && Array.isArray(toolInput.todos)) {
// Show in_progress task instead of just count
const inProgressTask = toolInput.todos.find(t => t.status === 'in_progress');
if (inProgressTask && inProgressTask.activeForm) {
verboseMsg += `: ${inProgressTask.activeForm}`;
} else {
// Fallback to count if no in_progress task
verboseMsg += `: ${toolInput.todos.length} task(s)`;
}
}
break;
case 'WebFetch':
if (toolInput.url) {
verboseMsg += `: ${toolInput.url}`;
}
break;
case 'WebSearch':
if (toolInput.query) {
verboseMsg += `: "${toolInput.query}"`;
}
break;
default:
// For unknown tools, show first meaningful parameter
if (Object.keys(toolInput).length > 0) {
const firstKey = Object.keys(toolInput)[0];
const firstValue = toolInput[firstKey];
if (typeof firstValue === 'string' && firstValue.length < 60) {
verboseMsg += `: ${firstValue}`;
}
}
}
process.stderr.write(`${verboseMsg}\n`);
}
}
} catch (parseError) {
// Skip malformed JSON lines (shouldn't happen with stream-json)
if (process.env.CCS_DEBUG) {
console.error(`[!] Failed to parse stream-json line: ${parseError.message}`);
}
}
}
});
// Stream stderr in real-time (progress messages from Claude CLI)
proc.stderr.on('data', (data) => {
const stderrText = data.toString();
stderr += stderrText;
// Show stderr in real-time if in TTY
if (showProgress) {
// Clear progress line before showing stderr
if (progressInterval) {
process.stderr.write('\r\x1b[K'); // Clear line
}
process.stderr.write(stderrText);
}
});
// Handle completion
proc.on('close', (exitCode) => {
const duration = Date.now() - startTime;
// Clear progress indicator
if (progressInterval) {
clearInterval(progressInterval);
process.stderr.write('\r\x1b[K'); // Clear line
}
// Show completion message
if (showProgress) {
const durationSec = (duration / 1000).toFixed(1);
if (timedOut) {
console.error(`[i] Execution timed out after ${durationSec}s`);
} else {
console.error(`[i] Execution completed in ${durationSec}s`);
}
console.error(''); // Blank line before formatted output
}
const result = {
exitCode,
stdout,
stderr,
cwd,
profile,
duration,
timedOut,
success: exitCode === 0 && !timedOut,
messages // Include all stream-json messages
};
// Extract metadata from final 'result' message in stream-json
const resultMessage = messages.find(m => m.type === 'result');
if (resultMessage) {
// Add parsed fields from result message
result.sessionId = resultMessage.session_id || null;
result.totalCost = resultMessage.total_cost_usd || 0;
result.numTurns = resultMessage.num_turns || 0;
result.isError = resultMessage.is_error || false;
result.type = resultMessage.type || null;
result.subtype = resultMessage.subtype || null;
result.durationApi = resultMessage.duration_api_ms || 0;
result.permissionDenials = resultMessage.permission_denials || [];
result.errors = resultMessage.errors || [];
// Extract content from result message
result.content = resultMessage.result || '';
} else {
// Fallback: no result message found (shouldn't happen)
result.content = stdout;
if (process.env.CCS_DEBUG) {
console.error(`[!] No result message found in stream-json output`);
}
}
// Store or update session if we have session ID (even on timeout, for :continue support)
if (result.sessionId) {
if (resumeSession || sessionId) {
// Update existing session
sessionMgr.updateSession(profile, result.sessionId, {
totalCost: result.totalCost
});
} else {
// Store new session
sessionMgr.storeSession(profile, {
sessionId: result.sessionId,
totalCost: result.totalCost,
cwd: result.cwd
});
}
// Cleanup expired sessions periodically
if (Math.random() < 0.1) { // 10% chance
sessionMgr.cleanupExpired();
}
}
resolve(result);
});
// Handle errors
proc.on('error', (error) => {
if (progressInterval) {
clearInterval(progressInterval);
}
reject(new Error(`Failed to execute Claude CLI: ${error.message}`));
});
// Handle timeout with graceful SIGTERM then forceful SIGKILL
let timedOut = false;
if (timeout > 0) {
const timeoutHandle = setTimeout(() => {
if (!proc.killed) {
timedOut = true;
if (progressInterval) {
clearInterval(progressInterval);
process.stderr.write('\r\x1b[K'); // Clear line
}
if (process.env.CCS_DEBUG) {
console.error(`[!] Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`);
}
// Send SIGTERM for graceful shutdown
proc.kill('SIGTERM');
// If process doesn't terminate within 10s, force kill
setTimeout(() => {
if (!proc.killed) {
if (process.env.CCS_DEBUG) {
console.error(`[!] Process did not terminate gracefully, sending SIGKILL...`);
}
proc.kill('SIGKILL');
}
}, 10000); // Give 10s for graceful shutdown instead of 5s
}
}, timeout);
// Clear timeout on successful completion
proc.on('close', () => clearTimeout(timeoutHandle));
}
});
}
/**
* Validate permission mode
* @param {string} mode - Permission mode
* @throws {Error} If mode is invalid
* @private
*/
static _validatePermissionMode(mode) {
const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
if (!VALID_MODES.includes(mode)) {
throw new Error(
`Invalid permission mode: "${mode}". Valid modes: ${VALID_MODES.join(', ')}`
);
}
}
/**
* Detect Claude CLI executable
* @returns {string|null} Path to claude CLI or null if not found
* @private
*/
static _detectClaudeCli() {
// Check environment variable override
if (process.env.CCS_CLAUDE_PATH) {
return process.env.CCS_CLAUDE_PATH;
}
// Try to find in PATH
const { execSync } = require('child_process');
try {
const result = execSync('command -v claude', { encoding: 'utf8' });
return result.trim();
} catch (error) {
return null;
}
}
/**
* Execute with retry logic
* @param {string} profile - Profile name
* @param {string} enhancedPrompt - Enhanced prompt
* @param {Object} options - Execution options
* @param {number} options.maxRetries - Maximum retry attempts (default: 2)
* @returns {Promise<Object>} Execution result
*/
static async executeWithRetry(profile, enhancedPrompt, options = {}) {
const { maxRetries = 2, ...execOptions } = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await this.execute(profile, enhancedPrompt, execOptions);
// If successful, return immediately
if (result.success) {
return result;
}
// If not last attempt, retry
if (attempt < maxRetries) {
console.error(`[!] Attempt ${attempt + 1} failed, retrying...`);
await this._sleep(1000 * (attempt + 1)); // Exponential backoff
continue;
}
// Last attempt failed, return result anyway
return result;
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
console.error(`[!] Attempt ${attempt + 1} errored: ${error.message}, retrying...`);
await this._sleep(1000 * (attempt + 1));
}
}
}
// All retries exhausted
throw lastError || new Error('Execution failed after all retry attempts');
}
/**
* Sleep utility for retry backoff
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
* @private
*/
static _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Process prompt to detect and preserve slash commands
* Implements smart enhancement: preserves slash command at start, allows context in rest
* @param {string} prompt - Original prompt (may contain slash command)
* @returns {string} Processed prompt with slash command preserved
* @private
*/
static _processSlashCommand(prompt) {
const trimmed = prompt.trim();
// Case 1: Already starts with slash command - keep as-is
if (trimmed.match(/^\/[\w:-]+(\s|$)/)) {
return prompt;
}
// Case 2: Find slash command embedded in text
// Look for /command that's NOT part of a file path
// File paths: /home/user, /path/to/file (have / before or after)
// Commands: /cook, /plan (standalone, preceded by space/colon/start)
// Strategy: Find LAST occurrence that looks like a command, not a path
const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/);
if (embeddedSlash) {
const command = embeddedSlash[1]; // e.g., "/cook"
const args = (embeddedSlash[2] || '').trim(); // Everything after command
// Calculate where the command starts (excluding preceding char if any)
const matchStart = embeddedSlash.index + (embeddedSlash[0][0] === '/' ? 0 : 1);
const beforeCommand = trimmed.substring(0, matchStart).trim();
// Restructure: command first, context after
if (beforeCommand && args) {
return `${command} ${args}\n\nContext: ${beforeCommand}`;
} else if (beforeCommand) {
return `${command}\n\nContext: ${beforeCommand}`;
}
return args ? `${command} ${args}` : command;
}
// No slash command detected, return as-is
return prompt;
}
/**
* Test if profile is executable (quick health check)
* @param {string} profile - Profile name
* @returns {Promise<boolean>} True if profile can execute
*/
static async testProfile(profile) {
try {
const result = await this.execute(profile, 'Say "test successful"', {
timeout: 10000
});
return result.success;
} catch (error) {
return false;
}
}
}
module.exports = { HeadlessExecutor };
-485
View File
@@ -1,485 +0,0 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
/**
* Formats delegation execution results for display
* Creates ASCII box output with file change tracking
*/
class ResultFormatter {
/**
* Format execution result with complete source-of-truth
* @param {Object} result - Execution result from HeadlessExecutor
* @param {string} result.profile - Profile used (glm, kimi, etc.)
* @param {string} result.cwd - Working directory
* @param {number} result.exitCode - Exit code
* @param {string} result.stdout - Standard output
* @param {string} result.stderr - Standard error
* @param {number} result.duration - Duration in milliseconds
* @param {boolean} result.success - Success flag
* @param {string} result.content - Parsed content (from JSON or stdout)
* @param {string} result.sessionId - Session ID (from JSON)
* @param {number} result.totalCost - Total cost USD (from JSON)
* @param {number} result.numTurns - Number of turns (from JSON)
* @returns {string} Formatted result
*/
static format(result) {
const { profile, cwd, exitCode, stdout, stderr, duration, success, content, sessionId, totalCost, numTurns, subtype, permissionDenials, errors, json, timedOut } = result;
// Handle timeout (graceful termination)
if (timedOut) {
return this._formatTimeoutError(result);
}
// Handle legacy max_turns error (Claude CLI might still return this)
if (subtype === 'error_max_turns') {
return this._formatTimeoutError(result);
}
// Use content field for output (JSON result or fallback stdout)
const displayOutput = content || stdout;
// Build formatted output
let output = '';
// Header
output += this._formatHeader(profile, success);
// Info box (file detection handled by delegated session itself)
output += this._formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns);
// Task output
output += '\n';
output += this._formatOutput(displayOutput);
// Permission denials if present
if (permissionDenials && permissionDenials.length > 0) {
output += '\n';
output += this._formatPermissionDenials(permissionDenials);
}
// Errors if present
if (errors && errors.length > 0) {
output += '\n';
output += this._formatErrors(errors);
}
// Stderr if present
if (stderr && stderr.trim()) {
output += '\n';
output += this._formatStderr(stderr);
}
// Footer
output += '\n';
output += this._formatFooter(success, duration);
return output;
}
/**
* Extract file changes from output
* @param {string} output - Command output
* @param {string} cwd - Working directory for filesystem scanning fallback
* @returns {Object} { created: Array<string>, modified: Array<string> }
*/
static extractFileChanges(output, cwd) {
const created = [];
const modified = [];
// Patterns to match file operations (case-insensitive)
const createdPatterns = [
/created:\s*([^\n\r]+)/gi,
/create:\s*([^\n\r]+)/gi,
/wrote:\s*([^\n\r]+)/gi,
/write:\s*([^\n\r]+)/gi,
/new file:\s*([^\n\r]+)/gi,
/generated:\s*([^\n\r]+)/gi,
/added:\s*([^\n\r]+)/gi
];
const modifiedPatterns = [
/modified:\s*([^\n\r]+)/gi,
/update:\s*([^\n\r]+)/gi,
/updated:\s*([^\n\r]+)/gi,
/edit:\s*([^\n\r]+)/gi,
/edited:\s*([^\n\r]+)/gi,
/changed:\s*([^\n\r]+)/gi
];
// Helper to check if file is infrastructure (should be ignored)
const isInfrastructure = (filePath) => {
return filePath.includes('/.claude/') || filePath.startsWith('.claude/');
};
// Extract created files
for (const pattern of createdPatterns) {
let match;
while ((match = pattern.exec(output)) !== null) {
const filePath = match[1].trim();
if (filePath && !created.includes(filePath) && !isInfrastructure(filePath)) {
created.push(filePath);
}
}
}
// Extract modified files
for (const pattern of modifiedPatterns) {
let match;
while ((match = pattern.exec(output)) !== null) {
const filePath = match[1].trim();
// Don't include if already in created list or is infrastructure
if (filePath && !modified.includes(filePath) && !created.includes(filePath) && !isInfrastructure(filePath)) {
modified.push(filePath);
}
}
}
// Fallback: Scan filesystem for recently modified files (last 5 minutes)
if (created.length === 0 && modified.length === 0 && cwd) {
try {
const fs = require('fs');
const childProcess = require('child_process');
// Use find command to get recently modified files (excluding infrastructure)
const findCmd = `find . -type f -mmin -5 -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.claude/*" 2>/dev/null | head -20`;
const result = childProcess.execSync(findCmd, { cwd, encoding: 'utf8', timeout: 5000 });
const files = result.split('\n').filter(f => f.trim());
files.forEach(file => {
const fullPath = path.join(cwd, file);
// Double-check not infrastructure
if (isInfrastructure(fullPath)) {
return;
}
try {
const stats = fs.statSync(fullPath);
const now = Date.now();
const mtime = stats.mtimeMs;
const ctime = stats.ctimeMs;
// If both mtime and ctime are very recent (within 10 minutes), likely created
// ctime = inode change time, for new files this is close to creation time
const isVeryRecent = (now - mtime) < 600000 && (now - ctime) < 600000;
const timeDiff = Math.abs(mtime - ctime);
// If mtime and ctime are very close (< 1 second apart) and both recent, it's created
if (isVeryRecent && timeDiff < 1000) {
if (!created.includes(fullPath)) {
created.push(fullPath);
}
} else {
// Otherwise, it's modified
if (!modified.includes(fullPath)) {
modified.push(fullPath);
}
}
} catch (statError) {
// If stat fails, default to created (since we're in fallback mode)
if (!created.includes(fullPath) && !modified.includes(fullPath)) {
created.push(fullPath);
}
}
});
} catch (scanError) {
// Silently fail if filesystem scan doesn't work
if (process.env.CCS_DEBUG) {
console.error(`[!] Filesystem scan failed: ${scanError.message}`);
}
}
}
return { created, modified };
}
/**
* Format header with delegation indicator
* @param {string} profile - Profile name
* @param {boolean} success - Success flag
* @returns {string} Formatted header
* @private
*/
static _formatHeader(profile, success) {
const modelName = this._getModelDisplayName(profile);
const icon = success ? '[i]' : '[X]';
return `${icon} Delegated to ${modelName} (ccs:${profile})\n`;
}
/**
* Format info box with delegation details
* @param {string} cwd - Working directory
* @param {string} profile - Profile name
* @param {number} duration - Duration in ms
* @param {number} exitCode - Exit code
* @param {string} sessionId - Session ID (from JSON)
* @param {number} totalCost - Total cost USD (from JSON)
* @param {number} numTurns - Number of turns (from JSON)
* @returns {string} Formatted info box
* @private
*/
static _formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns) {
const modelName = this._getModelDisplayName(profile);
const durationSec = (duration / 1000).toFixed(1);
// Calculate box width (fit longest line + padding)
const maxWidth = 70;
const cwdLine = `Working Directory: ${cwd}`;
const boxWidth = Math.min(Math.max(cwdLine.length + 4, 50), maxWidth);
const lines = [
`Working Directory: ${this._truncate(cwd, boxWidth - 22)}`,
`Model: ${modelName}`,
`Duration: ${durationSec}s`,
`Exit Code: ${exitCode}`
];
// Add JSON-specific fields if available
if (sessionId) {
// Abbreviate session ID (Git-style first 8 chars) to prevent wrapping
const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId;
lines.push(`Session ID: ${shortId}`);
}
if (totalCost !== undefined && totalCost !== null) {
lines.push(`Cost: $${totalCost.toFixed(4)}`);
}
if (numTurns) {
lines.push(`Turns: ${numTurns}`);
}
let box = '';
box += '╔' + '═'.repeat(boxWidth - 2) + '╗\n';
for (const line of lines) {
const padding = boxWidth - line.length - 4;
box += '║ ' + line + ' '.repeat(Math.max(0, padding)) + ' ║\n';
}
box += '╚' + '═'.repeat(boxWidth - 2) + '╝';
return box;
}
/**
* Format task output
* @param {string} output - Standard output
* @returns {string} Formatted output
* @private
*/
static _formatOutput(output) {
if (!output || !output.trim()) {
return '[i] No output from delegated task\n';
}
return output.trim() + '\n';
}
/**
* Format stderr output
* @param {string} stderr - Standard error
* @returns {string} Formatted stderr
* @private
*/
static _formatStderr(stderr) {
return `[!] Stderr:\n${stderr.trim()}\n\n`;
}
/**
* Format file list (created or modified)
* @param {string} label - Label (Created/Modified)
* @param {Array<string>} files - File paths
* @returns {string} Formatted file list
* @private
*/
static _formatFileList(label, files) {
let output = `[i] ${label} Files:\n`;
for (const file of files) {
output += ` - ${file}\n`;
}
return output;
}
/**
* Format footer with completion status
* @param {boolean} success - Success flag
* @param {number} duration - Duration in ms
* @returns {string} Formatted footer
* @private
*/
static _formatFooter(success, duration) {
const icon = success ? '[OK]' : '[X]';
const status = success ? 'Delegation completed' : 'Delegation failed';
return `${icon} ${status}\n`;
}
/**
* Get display name for model profile
* @param {string} profile - Profile name
* @returns {string} Display name
* @private
*/
static _getModelDisplayName(profile) {
const displayNames = {
'glm': 'GLM-4.6',
'glmt': 'GLM-4.6 (Thinking)',
'kimi': 'Kimi',
'default': 'Claude'
};
return displayNames[profile] || profile.toUpperCase();
}
/**
* Truncate string to max length
* @param {string} str - String to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated string
* @private
*/
static _truncate(str, maxLength) {
if (str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength - 3) + '...';
}
/**
* Format minimal result (for quick tasks)
* @param {Object} result - Execution result
* @returns {string} Minimal formatted result
*/
static formatMinimal(result) {
const { profile, success, duration } = result;
const modelName = this._getModelDisplayName(profile);
const icon = success ? '[OK]' : '[X]';
const durationSec = (duration / 1000).toFixed(1);
return `${icon} ${modelName} delegation ${success ? 'completed' : 'failed'} (${durationSec}s)\n`;
}
/**
* Format verbose result (with full details)
* @param {Object} result - Execution result
* @returns {string} Verbose formatted result
*/
static formatVerbose(result) {
const basic = this.format(result);
// Add additional debug info
let verbose = basic;
verbose += '\n=== Debug Information ===\n';
verbose += `CWD: ${result.cwd}\n`;
verbose += `Profile: ${result.profile}\n`;
verbose += `Exit Code: ${result.exitCode}\n`;
verbose += `Duration: ${result.duration}ms\n`;
verbose += `Success: ${result.success}\n`;
verbose += `Stdout Length: ${result.stdout.length} chars\n`;
verbose += `Stderr Length: ${result.stderr.length} chars\n`;
return verbose;
}
/**
* Check if NO_COLOR environment variable is set
* @returns {boolean} True if colors should be disabled
* @private
*/
static _shouldDisableColors() {
return process.env.NO_COLOR !== undefined;
}
/**
* Format timeout error (session exceeded time limit)
* @param {Object} result - Execution result
* @returns {string} Formatted timeout error
* @private
*/
static _formatTimeoutError(result) {
const { profile, cwd, duration, sessionId, totalCost, numTurns, permissionDenials } = result;
let output = '';
// Header
output += this._formatHeader(profile, false);
// Info box
output += this._formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns);
// Timeout message
output += '\n';
const timeoutMin = (duration / 60000).toFixed(1);
output += `[!] Execution timed out after ${timeoutMin} minutes\n\n`;
output += 'The delegated session exceeded its time limit before completing the task.\n';
output += 'Session was gracefully terminated and saved for continuation.\n';
// Permission denials if present
if (permissionDenials && permissionDenials.length > 0) {
output += '\n';
output += this._formatPermissionDenials(permissionDenials);
output += '\n';
output += 'The task may require permissions that were denied.\n';
output += 'Consider running with --permission-mode bypassPermissions or execute manually.\n';
}
// Suggestions
output += '\n';
output += 'Suggestions:\n';
output += ` - Continue session: ccs ${profile}:continue -p "finish the task"\n`;
output += ` - Increase timeout: ccs ${profile} -p "task" --timeout ${duration * 2}\n`;
output += ' - Break task into smaller steps\n';
output += ' - Run task manually in main Claude session\n';
output += '\n';
// Abbreviate session ID (Git-style first 8 chars)
const shortId = sessionId && sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId;
output += `[i] Session persisted with ID: ${shortId}\n`;
if (totalCost !== undefined && totalCost !== null) {
output += `[i] Cost: $${totalCost.toFixed(4)}\n`;
}
return output;
}
/**
* Format permission denials
* @param {Array<Object>} denials - Permission denial objects
* @returns {string} Formatted permission denials
* @private
*/
static _formatPermissionDenials(denials) {
let output = '[!] Permission Denials:\n';
for (const denial of denials) {
const tool = denial.tool_name || 'Unknown';
const input = denial.tool_input || {};
const command = input.command || input.description || JSON.stringify(input);
output += ` - ${tool}: ${command}\n`;
}
return output;
}
/**
* Format errors array
* @param {Array<Object>} errors - Error objects
* @returns {string} Formatted errors
* @private
*/
static _formatErrors(errors) {
let output = '[X] Errors:\n';
for (const error of errors) {
const message = error.message || error.error || JSON.stringify(error);
output += ` - ${message}\n`;
}
return output;
}
}
module.exports = { ResultFormatter };
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Manages delegation session persistence for multi-turn conversations
*/
class SessionManager {
constructor() {
this.sessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
}
/**
* Store new session metadata
* @param {string} profile - Profile name (glm, kimi, etc.)
* @param {Object} sessionData - Session data
* @param {string} sessionData.sessionId - Claude session ID
* @param {number} sessionData.totalCost - Initial cost
* @param {string} sessionData.cwd - Working directory
*/
storeSession(profile, sessionData) {
const sessions = this._loadSessions();
const key = `${profile}:latest`;
sessions[key] = {
sessionId: sessionData.sessionId,
profile,
startTime: Date.now(),
lastTurnTime: Date.now(),
totalCost: sessionData.totalCost || 0,
turns: 1,
cwd: sessionData.cwd || process.cwd()
};
this._saveSessions(sessions);
if (process.env.CCS_DEBUG) {
console.error(`[i] Stored session: ${sessionData.sessionId} for ${profile}`);
}
}
/**
* Update session after additional turn
* @param {string} profile - Profile name
* @param {string} sessionId - Session ID
* @param {Object} turnData - Turn data
* @param {number} turnData.totalCost - Turn cost
*/
updateSession(profile, sessionId, turnData) {
const sessions = this._loadSessions();
const key = `${profile}:latest`;
if (sessions[key]?.sessionId === sessionId) {
sessions[key].lastTurnTime = Date.now();
sessions[key].totalCost += turnData.totalCost || 0;
sessions[key].turns += 1;
this._saveSessions(sessions);
if (process.env.CCS_DEBUG) {
const cost = sessions[key].totalCost !== undefined && sessions[key].totalCost !== null ? sessions[key].totalCost.toFixed(4) : '0.0000';
console.error(`[i] Updated session: ${sessionId}, total: $${cost}, turns: ${sessions[key].turns}`);
}
}
}
/**
* Get last session for profile
* @param {string} profile - Profile name
* @returns {Object|null} Session metadata or null
*/
getLastSession(profile) {
const sessions = this._loadSessions();
const key = `${profile}:latest`;
return sessions[key] || null;
}
/**
* Clear all sessions for profile
* @param {string} profile - Profile name
*/
clearProfile(profile) {
const sessions = this._loadSessions();
const key = `${profile}:latest`;
delete sessions[key];
this._saveSessions(sessions);
}
/**
* Clean up expired sessions (>30 days)
*/
cleanupExpired() {
const sessions = this._loadSessions();
const now = Date.now();
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
let cleaned = 0;
Object.keys(sessions).forEach(key => {
if (now - sessions[key].lastTurnTime > maxAge) {
delete sessions[key];
cleaned++;
}
});
if (cleaned > 0) {
this._saveSessions(sessions);
if (process.env.CCS_DEBUG) {
console.error(`[i] Cleaned ${cleaned} expired sessions`);
}
}
}
/**
* Load sessions from disk
* @returns {Object} Sessions object
* @private
*/
_loadSessions() {
try {
if (!fs.existsSync(this.sessionsPath)) {
return {};
}
const content = fs.readFileSync(this.sessionsPath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (process.env.CCS_DEBUG) {
console.warn(`[!] Failed to load sessions: ${error.message}`);
}
return {};
}
}
/**
* Save sessions to disk
* @param {Object} sessions - Sessions object
* @private
*/
_saveSessions(sessions) {
try {
const dir = path.dirname(this.sessionsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(
this.sessionsPath,
JSON.stringify(sessions, null, 2),
{ mode: 0o600 }
);
} catch (error) {
console.error(`[!] Failed to save sessions: ${error.message}`);
}
}
}
module.exports = { SessionManager };
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
/**
* Parses Claude Code settings for tool restrictions
*/
class SettingsParser {
/**
* Parse default permission mode from project settings
* @param {string} projectDir - Project directory (usually cwd)
* @returns {string} Default permission mode (e.g., 'acceptEdits', 'bypassPermissions', 'plan', 'default')
*/
static parseDefaultPermissionMode(projectDir) {
const settings = this._loadSettings(projectDir);
const permissions = settings.permissions || {};
// Priority: local > shared > fallback to 'acceptEdits'
const defaultMode = permissions.defaultMode || 'acceptEdits';
if (process.env.CCS_DEBUG) {
console.error(`[i] Permission mode from settings: ${defaultMode}`);
}
return defaultMode;
}
/**
* Parse project settings for tool restrictions
* @param {string} projectDir - Project directory (usually cwd)
* @returns {Object} { allowedTools: string[], disallowedTools: string[] }
*/
static parseToolRestrictions(projectDir) {
const settings = this._loadSettings(projectDir);
const permissions = settings.permissions || {};
const allowed = permissions.allow || [];
const denied = permissions.deny || [];
if (process.env.CCS_DEBUG) {
console.error(`[i] Tool restrictions: ${allowed.length} allowed, ${denied.length} denied`);
}
return {
allowedTools: allowed,
disallowedTools: denied
};
}
/**
* Load and merge settings files (local overrides shared)
* @param {string} projectDir - Project directory
* @returns {Object} Merged settings
* @private
*/
static _loadSettings(projectDir) {
const claudeDir = path.join(projectDir, '.claude');
const sharedPath = path.join(claudeDir, 'settings.json');
const localPath = path.join(claudeDir, 'settings.local.json');
// Load shared settings
const shared = this._readJsonSafe(sharedPath) || {};
// Load local settings (overrides shared)
const local = this._readJsonSafe(localPath) || {};
// Merge permissions (local overrides shared)
return {
permissions: {
allow: [
...(shared.permissions?.allow || []),
...(local.permissions?.allow || [])
],
deny: [
...(shared.permissions?.deny || []),
...(local.permissions?.deny || [])
],
// Local defaultMode takes priority over shared
defaultMode: local.permissions?.defaultMode || shared.permissions?.defaultMode || null
}
};
}
/**
* Read JSON file safely (no throw)
* @param {string} filePath - Path to JSON file
* @returns {Object|null} Parsed JSON or null
* @private
*/
static _readJsonSafe(filePath) {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (process.env.CCS_DEBUG) {
console.warn(`[!] Failed to read settings: ${filePath}: ${error.message}`);
}
return null;
}
}
}
module.exports = { SettingsParser };
-276
View File
@@ -1,276 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* DeltaAccumulator - Maintain state across streaming deltas
*
* Tracks:
* - Message metadata (id, model, role)
* - Content blocks (thinking, text)
* - Current block index
* - Accumulated content
*
* Usage:
* const acc = new DeltaAccumulator(thinkingConfig);
* const events = transformer.transformDelta(openaiEvent, acc);
*/
class DeltaAccumulator {
constructor(thinkingConfig = {}, options = {}) {
this.thinkingConfig = thinkingConfig;
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
this.model = null;
this.role = 'assistant';
// Content blocks
this.contentBlocks = [];
this.currentBlockIndex = -1;
// Tool calls tracking
this.toolCalls = [];
this.toolCallsIndex = {};
// Buffers
this.thinkingBuffer = '';
this.textBuffer = '';
// C-02 Fix: Limits to prevent unbounded accumulation
this.maxBlocks = options.maxBlocks || 100;
this.maxBufferSize = options.maxBufferSize || 10 * 1024 * 1024; // 10MB
// Loop detection configuration
this.loopDetectionThreshold = options.loopDetectionThreshold || 3;
this.loopDetected = false;
// State flags
this.messageStarted = false;
this.finalized = false;
this.usageReceived = false; // Track if usage data has arrived
// Statistics
this.inputTokens = 0;
this.outputTokens = 0;
this.finishReason = null;
}
/**
* Get current content block
* @returns {Object|null} Current block or null
*/
getCurrentBlock() {
if (this.currentBlockIndex >= 0 && this.currentBlockIndex < this.contentBlocks.length) {
return this.contentBlocks[this.currentBlockIndex];
}
return null;
}
/**
* Start new content block
* @param {string} type - Block type ('thinking', 'text', or 'tool_use')
* @returns {Object} New block
*/
startBlock(type) {
// C-02 Fix: Enforce max blocks limit
if (this.contentBlocks.length >= this.maxBlocks) {
throw new Error(`Maximum ${this.maxBlocks} content blocks exceeded (DoS protection)`);
}
this.currentBlockIndex++;
const block = {
index: this.currentBlockIndex,
type: type,
content: '',
started: true,
stopped: false
};
this.contentBlocks.push(block);
// Reset buffer for new block (tool_use doesn't use buffers)
if (type === 'thinking') {
this.thinkingBuffer = '';
} else if (type === 'text') {
this.textBuffer = '';
}
return block;
}
/**
* Add delta to current block
* @param {string} delta - Content delta
*/
addDelta(delta) {
const block = this.getCurrentBlock();
if (!block) {
// FIX: Guard against null block (should never happen, but defensive)
console.error('[DeltaAccumulator] ERROR: addDelta called with no current block');
return;
}
if (block.type === 'thinking') {
// C-02 Fix: Enforce buffer size limit
if (this.thinkingBuffer.length + delta.length > this.maxBufferSize) {
throw new Error(`Thinking buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
}
this.thinkingBuffer += delta;
block.content = this.thinkingBuffer;
// FIX: Verify assignment succeeded (paranoid check for race conditions)
if (block.content.length !== this.thinkingBuffer.length) {
console.error('[DeltaAccumulator] ERROR: Block content assignment failed');
console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`);
}
} else if (block.type === 'text') {
// C-02 Fix: Enforce buffer size limit
if (this.textBuffer.length + delta.length > this.maxBufferSize) {
throw new Error(`Text buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
}
this.textBuffer += delta;
block.content = this.textBuffer;
}
}
/**
* Mark current block as stopped
*/
stopCurrentBlock() {
const block = this.getCurrentBlock();
if (block) {
block.stopped = true;
// FIX: Log block closure for debugging (helps diagnose timing issues)
if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') {
console.error(`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`);
}
}
}
/**
* Update usage statistics
* @param {Object} usage - Usage object from OpenAI
*/
updateUsage(usage) {
if (usage) {
this.inputTokens = usage.prompt_tokens || usage.input_tokens || 0;
this.outputTokens = usage.completion_tokens || usage.output_tokens || 0;
this.usageReceived = true; // Mark that we've received usage data
}
}
/**
* Add or update tool call delta
* @param {Object} toolCallDelta - Tool call delta from OpenAI
*/
addToolCallDelta(toolCallDelta) {
const index = toolCallDelta.index;
// Initialize tool call if not exists
if (!this.toolCallsIndex[index]) {
const toolCall = {
index: index,
id: '',
type: 'function',
function: {
name: '',
arguments: ''
}
};
this.toolCalls.push(toolCall);
this.toolCallsIndex[index] = toolCall;
}
const toolCall = this.toolCallsIndex[index];
// Update id if present
if (toolCallDelta.id) {
toolCall.id = toolCallDelta.id;
}
// Update type if present
if (toolCallDelta.type) {
toolCall.type = toolCallDelta.type;
}
// Update function name if present
if (toolCallDelta.function?.name) {
toolCall.function.name += toolCallDelta.function.name;
}
// Update function arguments if present
if (toolCallDelta.function?.arguments) {
toolCall.function.arguments += toolCallDelta.function.arguments;
}
}
/**
* Get all tool calls
* @returns {Array} Tool calls array
*/
getToolCalls() {
return this.toolCalls;
}
/**
* Check for planning loop pattern
* Loop = N consecutive thinking blocks with no tool calls
* @returns {boolean} True if loop detected
*/
checkForLoop() {
// Already detected loop
if (this.loopDetected) {
return true;
}
// Need minimum blocks to detect pattern
if (this.contentBlocks.length < this.loopDetectionThreshold) {
return false;
}
// Get last N blocks
const recentBlocks = this.contentBlocks.slice(-this.loopDetectionThreshold);
// Check if all recent blocks are thinking blocks
const allThinking = recentBlocks.every(b => b.type === 'thinking');
// Check if no tool calls have been made at all
const noToolCalls = this.toolCalls.length === 0;
// Loop detected if: all recent blocks are thinking AND no tool calls yet
if (allThinking && noToolCalls) {
this.loopDetected = true;
return true;
}
return false;
}
/**
* Reset loop detection state (for testing)
*/
resetLoopDetection() {
this.loopDetected = false;
}
/**
* Get summary of accumulated state
* @returns {Object} Summary
*/
getSummary() {
return {
messageId: this.messageId,
model: this.model,
role: this.role,
blockCount: this.contentBlocks.length,
currentIndex: this.currentBlockIndex,
toolCallCount: this.toolCalls.length,
messageStarted: this.messageStarted,
finalized: this.finalized,
loopDetected: this.loopDetected,
usage: {
input_tokens: this.inputTokens,
output_tokens: this.outputTokens
}
};
}
}
module.exports = DeltaAccumulator;
-495
View File
@@ -1,495 +0,0 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const https = require('https');
const GlmtTransformer = require('./glmt-transformer');
const SSEParser = require('./sse-parser');
const DeltaAccumulator = require('./delta-accumulator');
/**
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
*
* Architecture:
* - Intercepts Claude CLI → Z.AI calls
* - Transforms Anthropic format → OpenAI format
* - Converts reasoning_content → thinking blocks
* - Supports both streaming and buffered modes
*
* 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=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,
debugLog: config.debugLog || process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG_LOG === '1'
});
// Use ANTHROPIC_BASE_URL from environment (set by settings.json) or fallback to Z.AI default
this.upstreamUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions';
this.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}`);
// Info message (only show in verbose mode)
if (this.verbose) {
console.error(`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`);
}
// Debug mode notice
if (this.transformer.debugLog) {
console.error(`[glmt] Debug logging enabled: ${this.transformer.debugLogDir}`);
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;
}
// Log thinking parameter for debugging
if (anthropicRequest.thinking) {
this.log(`Request contains thinking parameter: ${JSON.stringify(anthropicRequest.thinking)}`);
} else {
this.log(`Request does NOT contain thinking parameter (will use message tags or default)`);
}
// Try streaming first (default), fallback to buffered on error
const useStreaming = anthropicRequest.stream !== false;
if (useStreaming) {
try {
await this._handleStreamingRequest(req, res, anthropicRequest, startTime);
} catch (streamError) {
this.log(`Streaming failed: ${streamError.message}, retrying buffered mode`);
try {
await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
} catch (bufferedError) {
// Both modes failed, propagate error
throw bufferedError;
}
}
} else {
await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
}
} 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
}
}));
}
}
/**
* Handle buffered (non-streaming) request
* @private
*/
async _handleBufferedRequest(req, res, anthropicRequest, startTime) {
// 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`);
}
/**
* Handle streaming request
* @private
*/
async _handleStreamingRequest(req, res, anthropicRequest, startTime) {
this.log('Using streaming mode');
// Transform request
const { openaiRequest, thinkingConfig } =
this.transformer.transformRequest(anthropicRequest);
// Force streaming
openaiRequest.stream = true;
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no' // Disable proxy buffering
});
// Disable Nagle's algorithm to prevent buffering at socket level
if (res.socket) {
res.socket.setNoDelay(true);
}
this.log('Starting SSE stream to Claude CLI (socket buffering disabled)');
// Forward and stream
await this._forwardAndStreamUpstream(
openaiRequest,
req.headers,
res,
thinkingConfig,
startTime
);
}
/**
* 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: url.pathname || '/api/coding/paas/v4/chat/completions',
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}${url.pathname}`);
// 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();
});
}
/**
* Forward request to Z.AI and stream response
* @param {Object} openaiRequest - OpenAI format request
* @param {Object} originalHeaders - Original request headers
* @param {http.ServerResponse} clientRes - Response to Claude CLI
* @param {Object} thinkingConfig - Thinking configuration
* @param {number} startTime - Request start time
* @returns {Promise<void>}
* @private
*/
async _forwardAndStreamUpstream(openaiRequest, originalHeaders, clientRes, thinkingConfig, startTime) {
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: url.pathname || '/api/coding/paas/v4/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'Authorization': originalHeaders['authorization'] || '',
'User-Agent': 'CCS-GLMT-Proxy/1.0',
'Accept': 'text/event-stream'
}
};
this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`);
// C-03 Fix: Apply timeout to streaming requests
const timeoutHandle = setTimeout(() => {
req.destroy();
reject(new Error(`Streaming request timeout after ${this.timeout}ms`));
}, this.timeout);
const req = https.request(options, (upstreamRes) => {
clearTimeout(timeoutHandle);
if (upstreamRes.statusCode !== 200) {
let body = '';
upstreamRes.on('data', chunk => body += chunk);
upstreamRes.on('end', () => {
reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`));
});
return;
}
const parser = new SSEParser();
const accumulator = new DeltaAccumulator(thinkingConfig);
upstreamRes.on('data', (chunk) => {
try {
const events = parser.parse(chunk);
events.forEach(event => {
// Transform OpenAI delta → Anthropic events
const anthropicEvents = this.transformer.transformDelta(event, accumulator);
// Forward to Claude CLI with immediate flush
anthropicEvents.forEach(evt => {
const eventLine = `event: ${evt.event}\n`;
const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`;
clientRes.write(eventLine + dataLine);
// Flush immediately if method available (HTTP/2 or custom servers)
if (typeof clientRes.flush === 'function') {
clientRes.flush();
}
});
});
} catch (error) {
this.log(`Error processing chunk: ${error.message}`);
}
});
upstreamRes.on('end', () => {
const duration = Date.now() - startTime;
this.log(`Streaming completed in ${duration}ms`);
clientRes.end();
resolve();
});
upstreamRes.on('error', (error) => {
clearTimeout(timeoutHandle);
this.log(`Upstream stream error: ${error.message}`);
clientRes.write(`event: error\n`);
clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
clientRes.end();
reject(error);
});
});
req.on('error', (error) => {
clearTimeout(timeoutHandle);
this.log(`Request error: ${error.message}`);
clientRes.write(`event: error\n`);
clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
clientRes.end();
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;
-999
View File
@@ -1,999 +0,0 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const os = require('os');
const SSEParser = require('./sse-parser');
const DeltaAccumulator = require('./delta-accumulator');
const LocaleEnforcer = require('./locale-enforcer');
const ReasoningEnforcer = require('./reasoning-enforcer');
/**
* GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking and tool support
*
* Features:
* - Request: Anthropic → OpenAI (inject reasoning params, transform tools)
* - Response: OpenAI reasoning_content → Anthropic thinking blocks
* - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional)
* - Streaming: Real-time tool calls with input_json deltas
* - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG=1)
* - Verbose mode: Console logging with timestamps
* - Validation: Self-test transformation results
*
* 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 {
static _warnedDeprecation = false;
constructor(config = {}) {
this.defaultThinking = config.defaultThinking ?? true;
this.verbose = config.verbose || false;
// CCS_DEBUG controls all debug logging (file + console)
const debugEnabled = process.env.CCS_DEBUG === '1';
this.debugLog = config.debugLog ?? debugEnabled;
this.debugMode = config.debugMode ?? debugEnabled;
this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
this.modelMaxTokens = {
'GLM-4.6': 128000,
'GLM-4.5': 96000,
'GLM-4.5-air': 16000
};
// Effort level thresholds (budget_tokens)
this.EFFORT_LOW_THRESHOLD = 2048;
this.EFFORT_HIGH_THRESHOLD = 8192;
// Initialize locale enforcer (always enforce English)
this.localeEnforcer = new LocaleEnforcer();
// Initialize reasoning enforcer (enabled by default for all GLMT usage)
this.reasoningEnforcer = new ReasoningEnforcer({
enabled: config.explicitReasoning ?? true
});
}
/**
* 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 (tags like <Thinking:On|Off>)
const thinkingConfig = this._extractThinkingControl(
anthropicRequest.messages || []
);
const hasControlTags = this._hasThinkingTags(anthropicRequest.messages || []);
// 2. Detect "think" keywords in user prompts (Anthropic-style)
const keywordConfig = this._detectThinkKeywords(anthropicRequest.messages || []);
if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) {
thinkingConfig.thinking = keywordConfig.thinking;
thinkingConfig.effort = keywordConfig.effort;
this.log(`Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}`);
}
// 3. Check anthropicRequest.thinking parameter (takes precedence)
// Claude CLI sends this when alwaysThinkingEnabled is configured
if (anthropicRequest.thinking) {
if (anthropicRequest.thinking.type === 'enabled') {
thinkingConfig.thinking = true;
this.log('Claude CLI explicitly enabled thinking (overrides budget)');
} else if (anthropicRequest.thinking.type === 'disabled') {
thinkingConfig.thinking = false;
this.log('Claude CLI explicitly disabled thinking (overrides budget)');
} else {
this.log(`Warning: Unknown thinking type: ${anthropicRequest.thinking.type}`);
}
}
this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`);
// 3. Map model
const glmModel = this._mapModel(anthropicRequest.model);
// 4. Inject locale instruction before sanitization
const messagesWithLocale = this.localeEnforcer.injectInstruction(
anthropicRequest.messages || []
);
// 4.5. Inject reasoning instruction (if enabled or thinking requested)
const messagesWithReasoning = this.reasoningEnforcer.injectInstruction(
messagesWithLocale,
thinkingConfig
);
// 5. Convert to OpenAI format
const openaiRequest = {
model: glmModel,
messages: this._sanitizeMessages(messagesWithReasoning),
max_tokens: this._getMaxTokens(glmModel),
stream: anthropicRequest.stream ?? false
};
// 5.5. Transform tools parameter if present
if (anthropicRequest.tools && anthropicRequest.tools.length > 0) {
openaiRequest.tools = this._transformTools(anthropicRequest.tools);
// Always use "auto" as Z.AI doesn't support other modes
openaiRequest.tool_choice = "auto";
this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`);
}
// 6. Preserve optional parameters
if (anthropicRequest.temperature !== undefined) {
openaiRequest.temperature = anthropicRequest.temperature;
}
if (anthropicRequest.top_p !== undefined) {
openaiRequest.top_p = anthropicRequest.top_p;
}
// 7. Handle streaming
// Keep stream parameter from request
if (anthropicRequest.stream !== undefined) {
openaiRequest.stream = anthropicRequest.stream;
}
// 8. Inject reasoning parameters
this._injectReasoningParams(openaiRequest, thinkingConfig);
// Log transformed request
this._writeDebugLog('request-openai', openaiRequest);
return { openaiRequest, thinkingConfig };
} catch (error) {
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 => {
let parsedInput;
try {
parsedInput = JSON.parse(toolCall.function.arguments || '{}');
} catch (parseError) {
this.log(`Warning: Invalid JSON in tool arguments: ${parseError.message}`);
parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments };
}
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.function.name,
input: parsedInput
});
});
}
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: {
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
output_tokens: openaiResponse.usage?.completion_tokens || 0
}
};
// Validate transformation in verbose mode
if (this.verbose) {
const validation = this._validateTransformation(anthropicResponse);
this.log(`Transformation validation: ${validation.passed}/${validation.total} checks passed`);
if (!validation.valid) {
this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`);
}
}
// Log transformed response
this._writeDebugLog('response-anthropic', anthropicResponse);
return anthropicResponse;
} catch (error) {
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
* Convert tool_result blocks to separate tool messages
* Filter out thinking blocks
* @param {Array} messages - Messages array
* @returns {Array} Sanitized messages
* @private
*/
_sanitizeMessages(messages) {
const result = [];
for (const msg of messages) {
// If content is a string, add as-is
if (typeof msg.content === 'string') {
result.push(msg);
continue;
}
// If content is an array, process blocks
if (Array.isArray(msg.content)) {
// Separate tool_result blocks from other content
const toolResults = msg.content.filter(block => block.type === 'tool_result');
const textBlocks = msg.content.filter(block => block.type === 'text');
const toolUseBlocks = msg.content.filter(block => block.type === 'tool_use');
// CRITICAL: Tool messages must come BEFORE user text in OpenAI API
// Convert tool_result blocks to OpenAI tool messages FIRST
for (const toolResult of toolResults) {
result.push({
role: 'tool',
tool_call_id: toolResult.tool_use_id,
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content)
});
}
// Add text content as user/assistant message AFTER tool messages
if (textBlocks.length > 0) {
const textContent = textBlocks.length === 1
? textBlocks[0].text
: textBlocks.map(b => b.text).join('\n');
result.push({
role: msg.role,
content: textContent
});
}
// Add tool_use blocks (assistant's tool calls) - skip for now, they're in assistant messages
// OpenAI handles these differently in response, not request
// If no content at all, add empty message (but not if we added tool messages)
if (textBlocks.length === 0 && toolResults.length === 0 && toolUseBlocks.length === 0) {
result.push({
role: msg.role,
content: ''
});
}
continue;
}
// Fallback: return message as-is
result.push(msg);
}
return result;
}
/**
* Transform Anthropic tools to OpenAI tools format
* @param {Array} anthropicTools - Anthropic tools array
* @returns {Array} OpenAI tools array
* @private
*/
_transformTools(anthropicTools) {
return anthropicTools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.input_schema || {}
}
}));
}
/**
* Check if messages contain thinking control tags
* @param {Array} messages - Messages array
* @returns {boolean} True if tags found
* @private
*/
_hasThinkingTags(messages) {
for (const msg of messages) {
if (msg.role !== 'user') continue;
const content = msg.content;
if (typeof content !== 'string') continue;
// Check for control tags
if (/<Thinking:(On|Off)>/i.test(content) || /<Effort:(Low|Medium|High)>/i.test(content)) {
return true;
}
}
return false;
}
/**
* Extract thinking control tags from user messages
* @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()
};
}
/**
* Detect Anthropic-style "think" keywords in user prompts
* Maps: "ultrathink" > "think harder" > "think hard" > "think"
* @param {Array} messages - Messages array
* @returns {Object|null} { thinking, effort, keyword } or null
* @private
*/
_detectThinkKeywords(messages) {
if (!messages || messages.length === 0) return null;
// Extract text from user messages
const text = messages
.filter(m => m.role === 'user')
.map(m => {
if (typeof m.content === 'string') return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter(block => block.type === 'text')
.map(block => block.text || '')
.join(' ');
}
return '';
})
.join(' ');
// Priority: ultrathink > think harder > think hard > think
// Effort levels: max > high > medium > low (matches Anthropic's 4-tier system)
// Use word boundaries to avoid matching "thinking", "rethink", etc.
if (/\bultrathink\b/i.test(text)) {
return { thinking: true, effort: 'max', keyword: 'ultrathink' };
}
if (/\bthink\s+harder\b/i.test(text)) {
return { thinking: true, effort: 'high', keyword: 'think harder' };
}
if (/\bthink\s+hard\b/i.test(text)) {
return { thinking: true, effort: 'medium', keyword: 'think hard' };
}
if (/\bthink\b/i.test(text)) {
return { thinking: true, effort: 'low', keyword: 'think' };
}
return null; // No keywords detected
}
/**
* Inject reasoning parameters into OpenAI request
* @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 };
}
/**
* Transform OpenAI streaming delta to Anthropic events
* @param {Object} openaiEvent - Parsed SSE event from Z.AI
* @param {DeltaAccumulator} accumulator - State accumulator
* @returns {Array<Object>} Array of Anthropic SSE events
*/
transformDelta(openaiEvent, accumulator) {
const events = [];
// Debug logging for streaming deltas
if (this.debugLog && openaiEvent.data) {
this._writeDebugLog('delta-openai', openaiEvent.data);
}
// Handle [DONE] marker
// Only finalize if we haven't already (deferred finalization may have already triggered)
if (openaiEvent.event === 'done') {
if (!accumulator.finalized) {
return this.finalizeDelta(accumulator);
}
return []; // Already finalized
}
// Usage update (appears in final chunk, may be before choice data)
// Process this BEFORE early returns to ensure we capture usage
if (openaiEvent.data?.usage) {
accumulator.updateUsage(openaiEvent.data.usage);
// If we have both usage AND finish_reason, finalize immediately
if (accumulator.finishReason) {
events.push(...this.finalizeDelta(accumulator));
return events; // Early return after finalization
}
}
const choice = openaiEvent.data?.choices?.[0];
if (!choice) return events;
const delta = choice.delta;
if (!delta) return events;
// Message start
if (!accumulator.messageStarted) {
if (openaiEvent.data.model) {
accumulator.model = openaiEvent.data.model;
}
events.push(this._createMessageStartEvent(accumulator));
accumulator.messageStarted = true;
}
// Role
if (delta.role) {
accumulator.role = delta.role;
}
// Reasoning content delta (Z.AI streams incrementally - confirmed in Phase 02)
if (delta.reasoning_content) {
const currentBlock = accumulator.getCurrentBlock();
// FIX: Enhanced debug logging for thinking block diagnostics
if (this.debugMode) {
console.error(`[GLMT-DEBUG] Reasoning delta: ${delta.reasoning_content.length} chars`);
console.error(`[GLMT-DEBUG] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`);
}
if (!currentBlock || currentBlock.type !== 'thinking') {
// Start thinking block
const block = accumulator.startBlock('thinking');
events.push(this._createContentBlockStartEvent(block));
if (this.debugMode) {
console.error(`[GLMT-DEBUG] Started new thinking block ${block.index}`);
}
}
accumulator.addDelta(delta.reasoning_content);
events.push(this._createThinkingDeltaEvent(
accumulator.getCurrentBlock(),
delta.reasoning_content
));
}
// Text content delta
if (delta.content) {
const currentBlock = accumulator.getCurrentBlock();
// Close thinking block if transitioning from thinking to text
if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
const signatureEvent = this._createSignatureDeltaEvent(currentBlock);
if (signatureEvent) { // FIX: Handle null return from signature race guard
events.push(signatureEvent);
}
events.push(this._createContentBlockStopEvent(currentBlock));
accumulator.stopCurrentBlock();
}
if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock().type !== 'text') {
// Start text block
const block = accumulator.startBlock('text');
events.push(this._createContentBlockStartEvent(block));
}
accumulator.addDelta(delta.content);
events.push(this._createTextDeltaEvent(
accumulator.getCurrentBlock(),
delta.content
));
}
// Check for planning loop after each thinking block completes
if (accumulator.checkForLoop()) {
this.log('WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls');
this.log('Forcing early finalization to prevent unbounded planning');
// Close current block if any
const currentBlock = accumulator.getCurrentBlock();
if (currentBlock && !currentBlock.stopped) {
if (currentBlock.type === 'thinking') {
const signatureEvent = this._createSignatureDeltaEvent(currentBlock);
if (signatureEvent) { // FIX: Handle null return from signature race guard
events.push(signatureEvent);
}
}
events.push(this._createContentBlockStopEvent(currentBlock));
accumulator.stopCurrentBlock();
}
// Force finalization
events.push(...this.finalizeDelta(accumulator));
return events;
}
// Tool calls deltas
if (delta.tool_calls && delta.tool_calls.length > 0) {
// Close current content block ONCE before processing any tool calls
const currentBlock = accumulator.getCurrentBlock();
if (currentBlock && !currentBlock.stopped) {
if (currentBlock.type === 'thinking') {
events.push(this._createSignatureDeltaEvent(currentBlock));
}
events.push(this._createContentBlockStopEvent(currentBlock));
accumulator.stopCurrentBlock();
}
// Process each tool call delta
for (const toolCallDelta of delta.tool_calls) {
// Track tool call state
const isNewToolCall = !accumulator.toolCallsIndex[toolCallDelta.index];
accumulator.addToolCallDelta(toolCallDelta);
// Emit tool use events (start + input_json deltas)
if (isNewToolCall) {
// Start new tool_use block in accumulator
const block = accumulator.startBlock('tool_use');
const toolCall = accumulator.toolCallsIndex[toolCallDelta.index];
events.push({
event: 'content_block_start',
data: {
type: 'content_block_start',
index: block.index,
content_block: {
type: 'tool_use',
id: toolCall.id || `tool_${toolCallDelta.index}`,
name: toolCall.function.name || ''
}
}
});
}
// Emit input_json delta if arguments present
if (toolCallDelta.function?.arguments) {
const currentToolBlock = accumulator.getCurrentBlock();
if (currentToolBlock && currentToolBlock.type === 'tool_use') {
events.push({
event: 'content_block_delta',
data: {
type: 'content_block_delta',
index: currentToolBlock.index,
delta: {
type: 'input_json_delta',
partial_json: toolCallDelta.function.arguments
}
}
});
}
}
}
}
// Finish reason
if (choice.finish_reason) {
accumulator.finishReason = choice.finish_reason;
// If we have both finish_reason AND usage, finalize immediately
if (accumulator.usageReceived) {
events.push(...this.finalizeDelta(accumulator));
}
}
// Debug logging for generated events
if (this.debugLog && events.length > 0) {
this._writeDebugLog('delta-anthropic-events', { events, accumulator: accumulator.getSummary() });
}
return events;
}
/**
* Finalize streaming and generate closing events
* @param {DeltaAccumulator} accumulator - State accumulator
* @returns {Array<Object>} Final Anthropic SSE events
*/
finalizeDelta(accumulator) {
if (accumulator.finalized) {
return []; // Already finalized
}
const events = [];
// Close current content block if any (including tool_use blocks)
const currentBlock = accumulator.getCurrentBlock();
if (currentBlock && !currentBlock.stopped) {
if (currentBlock.type === 'thinking') {
const signatureEvent = this._createSignatureDeltaEvent(currentBlock);
if (signatureEvent) { // FIX: Handle null return from signature race guard
events.push(signatureEvent);
}
}
events.push(this._createContentBlockStopEvent(currentBlock));
accumulator.stopCurrentBlock();
}
// No need to manually stop tool_use blocks - they're now tracked in contentBlocks
// and will be stopped by the logic above if they're the current block
// Message delta (stop reason + usage)
events.push({
event: 'message_delta',
data: {
type: 'message_delta',
delta: {
stop_reason: this._mapStopReason(accumulator.finishReason || 'stop')
},
usage: {
input_tokens: accumulator.inputTokens,
output_tokens: accumulator.outputTokens
}
}
});
// Message stop
events.push({
event: 'message_stop',
data: {
type: 'message_stop'
}
});
accumulator.finalized = true;
return events;
}
/**
* Create message_start event
* @private
*/
_createMessageStartEvent(accumulator) {
return {
event: 'message_start',
data: {
type: 'message_start',
message: {
id: accumulator.messageId,
type: 'message',
role: accumulator.role,
content: [],
model: accumulator.model || 'glm-4.6',
stop_reason: null,
usage: {
input_tokens: accumulator.inputTokens,
output_tokens: 0
}
}
}
};
}
/**
* Create content_block_start event
* @private
*/
_createContentBlockStartEvent(block) {
return {
event: 'content_block_start',
data: {
type: 'content_block_start',
index: block.index,
content_block: {
type: block.type,
[block.type]: ''
}
}
};
}
/**
* Create thinking_delta event
* @private
*/
_createThinkingDeltaEvent(block, delta) {
return {
event: 'content_block_delta',
data: {
type: 'content_block_delta',
index: block.index,
delta: {
type: 'thinking_delta',
thinking: delta
}
}
};
}
/**
* Create text_delta event
* @private
*/
_createTextDeltaEvent(block, delta) {
return {
event: 'content_block_delta',
data: {
type: 'content_block_delta',
index: block.index,
delta: {
type: 'text_delta',
text: delta
}
}
};
}
/**
* Create thinking signature delta event
* @private
*/
_createSignatureDeltaEvent(block) {
// FIX: Guard against empty content (signature timing race)
// In streaming mode, signature may be requested before content fully accumulated
if (!block.content || block.content.length === 0) {
if (this.verbose) {
this.log(`WARNING: Skipping signature for empty thinking block ${block.index}`);
this.log(`This indicates a race condition - signature requested before content accumulated`);
}
return null; // Return null instead of event
}
const signature = this._generateThinkingSignature(block.content);
// Enhanced logging for debugging
if (this.verbose) {
this.log(`Generating signature for block ${block.index}: ${block.content.length} chars`);
}
return {
event: 'content_block_delta',
data: {
type: 'content_block_delta',
index: block.index,
delta: {
type: 'thinking_signature_delta',
signature: signature
}
}
};
}
/**
* Create content_block_stop event
* @private
*/
_createContentBlockStopEvent(block) {
return {
event: 'content_block_stop',
data: {
type: 'content_block_stop',
index: block.index
}
};
}
/**
* 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;
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* LocaleEnforcer - Force English output from GLM models
*
* Purpose: GLM models default to Chinese when prompts are ambiguous or contain Chinese context.
* This module always injects "MUST respond in English" instruction into system prompt or first user message.
*
* Usage:
* const enforcer = new LocaleEnforcer();
* const modifiedMessages = enforcer.injectInstruction(messages);
*
* Strategy:
* 1. If system prompt exists: Prepend instruction
* 2. If no system prompt: Prepend to first user message
* 3. Preserve message structure (string vs array content)
*/
class LocaleEnforcer {
constructor(options = {}) {
this.instruction = "CRITICAL: You MUST respond in English only, regardless of the input language or context. This is a strict requirement.";
}
/**
* Inject English instruction into messages
* @param {Array} messages - Messages array to modify
* @returns {Array} Modified messages array
*/
injectInstruction(messages) {
// Clone messages to avoid mutation
const modifiedMessages = JSON.parse(JSON.stringify(messages));
// Strategy 1: Inject into system prompt (preferred)
const systemIndex = modifiedMessages.findIndex(m => m.role === 'system');
if (systemIndex >= 0) {
const systemMsg = modifiedMessages[systemIndex];
if (typeof systemMsg.content === 'string') {
systemMsg.content = `${this.instruction}\n\n${systemMsg.content}`;
} else if (Array.isArray(systemMsg.content)) {
systemMsg.content.unshift({
type: 'text',
text: this.instruction
});
}
return modifiedMessages;
}
// Strategy 2: Prepend to first user message
const userIndex = modifiedMessages.findIndex(m => m.role === 'user');
if (userIndex >= 0) {
const userMsg = modifiedMessages[userIndex];
if (typeof userMsg.content === 'string') {
userMsg.content = `${this.instruction}\n\n${userMsg.content}`;
} else if (Array.isArray(userMsg.content)) {
userMsg.content.unshift({
type: 'text',
text: this.instruction
});
}
return modifiedMessages;
}
// No system or user messages found (edge case)
return modifiedMessages;
}
}
module.exports = LocaleEnforcer;
-173
View File
@@ -1,173 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* ReasoningEnforcer - Inject explicit reasoning instructions into prompts
*
* Purpose: Force GLM models to use structured reasoning output format (<reasoning_content>)
* This complements API parameters (reasoning: true) with explicit prompt instructions.
*
* Usage:
* const enforcer = new ReasoningEnforcer({ enabled: true });
* const modifiedMessages = enforcer.injectInstruction(messages, thinkingConfig);
*
* Strategy:
* 1. If system prompt exists: Prepend reasoning instruction
* 2. If no system prompt: Prepend to first user message
* 3. Select prompt template based on effort level (low/medium/high/max)
* 4. Preserve message structure (string vs array content)
*/
class ReasoningEnforcer {
constructor(options = {}) {
this.enabled = options.enabled ?? false; // Opt-in by default
this.prompts = options.prompts || this._getDefaultPrompts();
}
/**
* Inject reasoning instruction into messages
* @param {Array} messages - Messages array to modify
* @param {Object} thinkingConfig - { thinking: boolean, effort: string }
* @returns {Array} Modified messages array
*/
injectInstruction(messages, thinkingConfig = {}) {
// Only inject if enabled or thinking explicitly requested
if (!this.enabled && !thinkingConfig.thinking) {
return messages;
}
// Clone messages to avoid mutation
const modifiedMessages = JSON.parse(JSON.stringify(messages));
// Select prompt based on effort level
const prompt = this._selectPrompt(thinkingConfig.effort || 'medium');
// Strategy 1: Inject into system prompt (preferred)
const systemIndex = modifiedMessages.findIndex(m => m.role === 'system');
if (systemIndex >= 0) {
const systemMsg = modifiedMessages[systemIndex];
if (typeof systemMsg.content === 'string') {
systemMsg.content = `${prompt}\n\n${systemMsg.content}`;
} else if (Array.isArray(systemMsg.content)) {
systemMsg.content.unshift({
type: 'text',
text: prompt
});
}
return modifiedMessages;
}
// Strategy 2: Prepend to first user message
const userIndex = modifiedMessages.findIndex(m => m.role === 'user');
if (userIndex >= 0) {
const userMsg = modifiedMessages[userIndex];
if (typeof userMsg.content === 'string') {
userMsg.content = `${prompt}\n\n${userMsg.content}`;
} else if (Array.isArray(userMsg.content)) {
userMsg.content.unshift({
type: 'text',
text: prompt
});
}
return modifiedMessages;
}
// No system or user messages found (edge case)
return modifiedMessages;
}
/**
* Select prompt template based on effort level
* @param {string} effort - 'low', 'medium', 'high', or 'max'
* @returns {string} Prompt template
* @private
*/
_selectPrompt(effort) {
const normalizedEffort = effort.toLowerCase();
return this.prompts[normalizedEffort] || this.prompts.medium;
}
/**
* Get default prompt templates
* @returns {Object} Map of effort levels to prompts
* @private
*/
_getDefaultPrompts() {
return {
low: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL: Before answering, write 2-3 sentences of reasoning in <reasoning_content> tags.
OUTPUT FORMAT:
<reasoning_content>
(Brief analysis: what is the problem? what's the approach?)
</reasoning_content>
(Write your final answer here)`,
medium: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Always think step-by-step before answering
2. Write your reasoning process explicitly in <reasoning_content> tags
3. Never skip your chain of thought, even for simple problems
OUTPUT FORMAT:
<reasoning_content>
(Write your detailed thinking here: analyze the problem, explore approaches,
evaluate trade-offs, and arrive at a conclusion)
</reasoning_content>
(Write your final answer here based on your reasoning above)`,
high: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Think deeply and systematically before answering
2. Write comprehensive reasoning in <reasoning_content> tags
3. Explore multiple approaches and evaluate trade-offs
4. Show all steps in your problem-solving process
OUTPUT FORMAT:
<reasoning_content>
(Write exhaustive analysis here:
- Problem decomposition
- Multiple approach exploration
- Trade-off analysis for each approach
- Edge case consideration
- Final conclusion with justification)
</reasoning_content>
(Write your final answer here based on your systematic reasoning above)`,
max: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Think exhaustively from first principles
2. Write extremely detailed reasoning in <reasoning_content> tags
3. Analyze ALL possible angles, approaches, and edge cases
4. Challenge your own assumptions and explore alternatives
5. Provide rigorous justification for every claim
OUTPUT FORMAT:
<reasoning_content>
(Write comprehensive analysis here:
- First principles breakdown
- Exhaustive approach enumeration
- Comparative analysis of all approaches
- Edge case and failure mode analysis
- Assumption validation
- Counter-argument consideration
- Final conclusion with rigorous justification)
</reasoning_content>
(Write your final answer here based on your exhaustive reasoning above)`
};
}
}
module.exports = ReasoningEnforcer;
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* SSEParser - Parse Server-Sent Events (SSE) stream
*
* Handles:
* - Incomplete events across chunks
* - Multiple events in single chunk
* - Malformed data (skip gracefully)
* - [DONE] marker
*
* Usage:
* const parser = new SSEParser();
* stream.on('data', chunk => {
* const events = parser.parse(chunk);
* events.forEach(event => { ... });
* });
*/
class SSEParser {
constructor(options = {}) {
this.buffer = '';
this.eventCount = 0;
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
}
/**
* Parse chunk and extract SSE events
* @param {Buffer|string} chunk - Data chunk from stream
* @returns {Array<Object>} Array of parsed events
*/
parse(chunk) {
this.buffer += chunk.toString();
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
if (this.buffer.length > this.maxBufferSize) {
throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
}
const lines = this.buffer.split('\n');
// Keep incomplete line in buffer
this.buffer = lines.pop() || '';
const events = [];
let currentEvent = { event: 'message', data: '' };
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent.event = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
this.eventCount++;
events.push({
event: 'done',
data: null,
index: this.eventCount
});
currentEvent = { event: 'message', data: '' };
} else {
try {
currentEvent.data = JSON.parse(data);
this.eventCount++;
currentEvent.index = this.eventCount;
events.push(currentEvent);
currentEvent = { event: 'message', data: '' };
} catch (e) {
// H-01 Fix: Log parse errors for debugging
if (typeof console !== 'undefined' && console.error) {
console.error('[SSEParser] Malformed JSON event:', e.message, 'Data:', data.substring(0, 100));
}
}
}
} else if (line.startsWith('id: ')) {
currentEvent.id = line.substring(4).trim();
} else if (line.startsWith('retry: ')) {
currentEvent.retry = parseInt(line.substring(7), 10);
}
// Empty lines separate events (already handled by JSON parsing)
}
return events;
}
/**
* Reset parser state (for reuse)
*/
reset() {
this.buffer = '';
this.eventCount = 0;
}
}
module.exports = SSEParser;
-721
View File
@@ -1,721 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn } = require('child_process');
const { colored } = require('../utils/helpers');
const { detectClaudeCli } = require('../utils/claude-detector');
// Make ora optional (might not be available during npm install postinstall)
// ora v9+ is an ES module, need to use .default for CommonJS
let ora = null;
try {
const oraModule = require('ora');
ora = oraModule.default || oraModule;
} catch (e) {
// ora not available, create fallback spinner that uses console.log
ora = function(text) {
return {
start: () => ({
succeed: (msg) => console.log(msg || `[OK] ${text}`),
fail: (msg) => console.log(msg || `[X] ${text}`),
warn: (msg) => console.log(msg || `[!] ${text}`),
info: (msg) => console.log(msg || `[i] ${text}`),
text: ''
})
};
};
}
const Table = require('cli-table3');
/**
* Health check results
*/
class HealthCheck {
constructor() {
this.checks = [];
this.warnings = [];
this.errors = [];
this.details = {}; // Store detailed information for summary table
}
addCheck(name, status, message = '', fix = null, details = null) {
this.checks.push({ name, status, message, fix });
if (status === 'error') this.errors.push({ name, message, fix });
if (status === 'warning') this.warnings.push({ name, message, fix });
// Store details for summary table
if (details) {
this.details[name] = details;
}
}
hasErrors() {
return this.errors.length > 0;
}
hasWarnings() {
return this.warnings.length > 0;
}
isHealthy() {
return !this.hasErrors();
}
}
/**
* CCS Health Check and Diagnostics
*/
class Doctor {
constructor() {
this.homedir = os.homedir();
this.ccsDir = path.join(this.homedir, '.ccs');
this.claudeDir = path.join(this.homedir, '.claude');
this.results = new HealthCheck();
// Get CCS version
try {
this.ccsVersion = require('../../package.json').version;
} catch (e) {
this.ccsVersion = 'unknown';
}
}
/**
* Run all health checks
*/
async runAllChecks() {
console.log(colored('Running CCS Health Check...', 'cyan'));
console.log('');
// Store CCS version in details
this.results.details['CCS Version'] = { status: 'OK', info: `v${this.ccsVersion}` };
// Group 1: System
console.log(colored('System:', 'bold'));
await this.checkClaudeCli();
this.checkCcsDirectory();
console.log('');
// Group 2: Configuration
console.log(colored('Configuration:', 'bold'));
this.checkConfigFiles();
this.checkClaudeSettings();
console.log('');
// Group 3: Profiles & Delegation
console.log(colored('Profiles & Delegation:', 'bold'));
this.checkProfiles();
this.checkInstances();
this.checkDelegation();
console.log('');
// Group 4: System Health
console.log(colored('System Health:', 'bold'));
this.checkPermissions();
this.checkCcsSymlinks();
this.checkSettingsSymlinks();
console.log('');
this.showReport();
return this.results;
}
/**
* Check 1: Claude CLI availability
*/
async checkClaudeCli() {
const spinner = ora('Checking Claude CLI').start();
const claudeCli = detectClaudeCli();
// Try to execute claude --version
try {
const result = await new Promise((resolve, reject) => {
const child = spawn(claudeCli, ['--version'], {
stdio: 'pipe',
timeout: 5000
});
let output = '';
child.stdout.on('data', data => output += data);
child.stderr.on('data', data => output += data);
child.on('close', code => {
if (code === 0) resolve(output);
else reject(new Error('Exit code ' + code));
});
child.on('error', reject);
});
// Extract version from output
const versionMatch = result.match(/(\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
spinner.succeed(` ${'Claude CLI'.padEnd(26)}${colored('[OK]', 'green')} ${claudeCli} (v${version})`);
this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, null, {
status: 'OK',
info: `v${version} (${claudeCli})`
});
} catch (err) {
spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working`);
this.results.addCheck(
'Claude CLI',
'error',
'Claude CLI not found or not working',
'Install from: https://docs.claude.com/en/docs/claude-code/installation',
{ status: 'ERROR', info: 'Not installed' }
);
}
}
/**
* Check 2: ~/.ccs/ directory
*/
checkCcsDirectory() {
const spinner = ora('Checking ~/.ccs/ directory').start();
if (fs.existsSync(this.ccsDir)) {
spinner.succeed(` ${'CCS Directory'.padEnd(26)}${colored('[OK]', 'green')} ~/.ccs/`);
this.results.addCheck('CCS Directory', 'success', null, null, {
status: 'OK',
info: '~/.ccs/'
});
} else {
spinner.fail(` ${'CCS Directory'.padEnd(26)}${colored('[X]', 'red')} Not found`);
this.results.addCheck(
'CCS Directory',
'error',
'~/.ccs/ directory not found',
'Run: npm install -g @kaitranntt/ccs --force',
{ status: 'ERROR', info: 'Not found' }
);
}
}
/**
* Check 3: Config files
*/
checkConfigFiles() {
const files = [
{ path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' },
{ path: path.join(this.ccsDir, 'glm.settings.json'), name: 'glm.settings.json', key: 'GLM Settings', profile: 'glm' },
{ path: path.join(this.ccsDir, 'kimi.settings.json'), name: 'kimi.settings.json', key: 'Kimi Settings', profile: 'kimi' }
];
const { DelegationValidator } = require('../utils/delegation-validator');
for (const file of files) {
const spinner = ora(`Checking ${file.name}`).start();
if (!fs.existsSync(file.path)) {
spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Not found`);
this.results.addCheck(
file.name,
'error',
`${file.name} not found`,
'Run: npm install -g @kaitranntt/ccs --force',
{ status: 'ERROR', info: 'Not found' }
);
continue;
}
// Validate JSON
try {
const content = fs.readFileSync(file.path, 'utf8');
const config = JSON.parse(content);
// Extract useful info based on file type
let info = 'Valid';
let status = 'OK';
if (file.profile) {
// For settings files, check if API key is configured
const validation = DelegationValidator.validate(file.profile);
if (validation.valid) {
info = 'Key configured';
status = 'OK';
} else if (validation.error && validation.error.includes('placeholder')) {
info = 'Placeholder key (not configured)';
status = 'WARN';
} else {
info = 'Valid JSON';
status = 'OK';
}
}
const statusIcon = status === 'OK' ? colored('[OK]', 'green') : colored('[!]', 'yellow');
if (status === 'WARN') {
spinner.warn(` ${file.name.padEnd(26)}${statusIcon} ${info}`);
} else {
spinner.succeed(` ${file.name.padEnd(26)}${statusIcon} ${info}`);
}
this.results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', null, null, {
status: status,
info: info
});
} catch (e) {
spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Invalid JSON`);
this.results.addCheck(
file.name,
'error',
`Invalid JSON: ${e.message}`,
`Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`,
{ status: 'ERROR', info: 'Invalid JSON' }
);
}
}
}
/**
* Check 4: Claude settings
*/
checkClaudeSettings() {
const spinner = ora('Checking ~/.claude/settings.json').start();
const settingsPath = path.join(this.claudeDir, 'settings.json');
if (!fs.existsSync(settingsPath)) {
spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found`);
this.results.addCheck(
'Claude Settings',
'warning',
'~/.claude/settings.json not found',
'Run: claude /login'
);
return;
}
// Validate JSON
try {
const content = fs.readFileSync(settingsPath, 'utf8');
JSON.parse(content);
spinner.succeed(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[OK]', 'green')}`);
this.results.addCheck('Claude Settings', 'success');
} catch (e) {
spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON`);
this.results.addCheck(
'Claude Settings',
'warning',
`Invalid JSON: ${e.message}`,
'Run: claude /login'
);
}
}
/**
* Check 5: Profile configurations
*/
checkProfiles() {
const spinner = ora('Checking profiles').start();
const configPath = path.join(this.ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
spinner.info(` ${'Profiles'.padEnd(26)}${colored('[SKIP]', 'cyan')} config.json not found`);
return;
}
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config.profiles || typeof config.profiles !== 'object') {
spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} Missing profiles object`);
this.results.addCheck(
'Profiles',
'error',
'config.json missing profiles object',
'Run: npm install -g @kaitranntt/ccs --force',
{ status: 'ERROR', info: 'Missing profiles object' }
);
return;
}
const profileCount = Object.keys(config.profiles).length;
const profileNames = Object.keys(config.profiles).join(', ');
spinner.succeed(` ${'Profiles'.padEnd(26)}${colored('[OK]', 'green')} ${profileCount} configured (${profileNames})`);
this.results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, null, {
status: 'OK',
info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`
});
} catch (e) {
spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${e.message}`);
this.results.addCheck('Profiles', 'error', e.message, null, {
status: 'ERROR',
info: e.message
});
}
}
/**
* Check 6: Instance directories (account-based profiles)
*/
checkInstances() {
const spinner = ora('Checking instances').start();
const instancesDir = path.join(this.ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`);
this.results.addCheck('Instances', 'success', 'No account profiles configured');
return;
}
const instances = fs.readdirSync(instancesDir).filter(name => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
if (instances.length === 0) {
spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`);
this.results.addCheck('Instances', 'success', 'No account profiles');
return;
}
spinner.succeed(` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles`);
this.results.addCheck('Instances', 'success', `${instances.length} account profiles`);
}
/**
* Check 7: Delegation system
*/
checkDelegation() {
const spinner = ora('Checking delegation').start();
// Check if delegation commands exist in ~/.ccs/.claude/commands/
const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands');
const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md'));
const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md'));
if (!hasCcsCommand || !hasContinueCommand) {
spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} Not installed`);
this.results.addCheck(
'Delegation',
'warning',
'Delegation commands not found',
'Install with: npm install -g @kaitranntt/ccs --force',
{ status: 'WARN', info: 'Not installed' }
);
return;
}
// Check profile validity using DelegationValidator
const { DelegationValidator } = require('../utils/delegation-validator');
const readyProfiles = [];
for (const profile of ['glm', 'kimi']) {
const validation = DelegationValidator.validate(profile);
if (validation.valid) {
readyProfiles.push(profile);
}
}
if (readyProfiles.length === 0) {
spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} No profiles ready`);
this.results.addCheck(
'Delegation',
'warning',
'Delegation installed but no profiles configured',
'Configure profiles with valid API keys (not placeholders)',
{ status: 'WARN', info: 'No profiles ready' }
);
return;
}
spinner.succeed(` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`);
this.results.addCheck(
'Delegation',
'success',
`${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`,
null,
{ status: 'OK', info: `${readyProfiles.length} profiles ready` }
);
}
/**
* Check 8: File permissions
*/
checkPermissions() {
const spinner = ora('Checking permissions').start();
const testFile = path.join(this.ccsDir, '.permission-test');
try {
fs.writeFileSync(testFile, 'test', 'utf8');
fs.unlinkSync(testFile);
spinner.succeed(` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified`);
this.results.addCheck('Permissions', 'success', null, null, {
status: 'OK',
info: 'Write access verified'
});
} catch (e) {
spinner.fail(` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/`);
this.results.addCheck(
'Permissions',
'error',
'Cannot write to ~/.ccs/',
'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
{ status: 'ERROR', info: 'Cannot write to ~/.ccs/' }
);
}
}
/**
* Check 9: CCS symlinks to ~/.claude/
*/
checkCcsSymlinks() {
const spinner = ora('Checking CCS symlinks').start();
try {
const ClaudeSymlinkManager = require('../utils/claude-symlink-manager');
const manager = new ClaudeSymlinkManager();
const health = manager.checkHealth();
if (health.healthy) {
const itemCount = manager.ccsItems.length;
spinner.succeed(` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${itemCount}/${itemCount} items linked`);
this.results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', null, {
status: 'OK',
info: `${itemCount}/${itemCount} items synced`
});
} else {
spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found`);
this.results.addCheck(
'CCS Symlinks',
'warning',
health.issues.join(', '),
'Run: ccs sync',
{ status: 'WARN', info: `${health.issues.length} issues` }
);
}
} catch (e) {
spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} Could not check`);
this.results.addCheck(
'CCS Symlinks',
'warning',
'Could not check CCS symlinks: ' + e.message,
'Run: ccs sync',
{ status: 'WARN', info: 'Could not check' }
);
}
}
/**
* Check 10: settings.json symlinks
*/
checkSettingsSymlinks() {
const spinner = ora('Checking settings.json symlinks').start();
try {
const sharedDir = path.join(this.homedir, '.ccs', 'shared');
const sharedSettings = path.join(sharedDir, 'settings.json');
const claudeSettings = path.join(this.claudeDir, 'settings.json');
// Check shared settings exists and points to ~/.claude/
if (!fs.existsSync(sharedSettings)) {
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`);
this.results.addCheck(
'Settings Symlinks',
'warning',
'Shared settings.json not found',
'Run: ccs sync'
);
return;
}
const sharedStats = fs.lstatSync(sharedSettings);
if (!sharedStats.isSymbolicLink()) {
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`);
this.results.addCheck(
'Settings Symlinks',
'warning',
'Shared settings.json is not a symlink',
'Run: ccs sync'
);
return;
}
const sharedTarget = fs.readlinkSync(sharedSettings);
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
if (resolvedShared !== claudeSettings) {
spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`);
this.results.addCheck(
'Settings Symlinks',
'warning',
`Points to ${resolvedShared} instead of ${claudeSettings}`,
'Run: ccs sync'
);
return;
}
// Check each instance
const instancesDir = path.join(this.ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid`);
this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', null, {
status: 'OK',
info: 'Shared symlink valid'
});
return;
}
const instances = fs.readdirSync(instancesDir).filter(name => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
let broken = 0;
for (const instance of instances) {
const instancePath = path.join(instancesDir, instance);
const instanceSettings = path.join(instancePath, 'settings.json');
if (!fs.existsSync(instanceSettings)) {
broken++;
continue;
}
try {
const stats = fs.lstatSync(instanceSettings);
if (!stats.isSymbolicLink()) {
broken++;
continue;
}
const target = fs.readlinkSync(instanceSettings);
const resolved = path.resolve(path.dirname(instanceSettings), target);
if (resolved !== sharedSettings) {
broken++;
}
} catch (err) {
broken++;
}
}
if (broken > 0) {
spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`);
this.results.addCheck(
'Settings Symlinks',
'warning',
`${broken} instance(s) have broken symlinks`,
'Run: ccs sync',
{ status: 'WARN', info: `${broken} broken instance(s)` }
);
} else {
spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid`);
this.results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', null, {
status: 'OK',
info: `${instances.length} instance(s) valid`
});
}
} catch (err) {
spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`);
this.results.addCheck(
'Settings Symlinks',
'warning',
`Failed to check: ${err.message}`,
'Run: ccs sync',
{ status: 'WARN', info: 'Check failed' }
);
}
}
/**
* Show health check report
*/
showReport() {
console.log('');
// Calculate exact table width to match header bars
// colWidths: [20, 10, 35] = 65 + borders (4) = 69 total
const tableWidth = 69;
const headerBar = '═'.repeat(tableWidth);
console.log(colored(headerBar, 'cyan'));
console.log(colored(' Health Check Summary', 'bold'));
console.log(colored(headerBar, 'cyan'));
// Create summary table with detailed information
const table = new Table({
head: [colored('Component', 'cyan'), colored('Status', 'cyan'), colored('Details', 'cyan')],
colWidths: [20, 10, 35],
wordWrap: true,
chars: {
'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗',
'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝',
'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼',
'right': '║', 'right-mid': '╢', 'middle': '│'
}
});
// Populate table with collected details
for (const [component, detail] of Object.entries(this.results.details)) {
const statusColor = detail.status === 'OK' ? 'green' : detail.status === 'ERROR' ? 'red' : 'yellow';
table.push([
component,
colored(detail.status, statusColor),
detail.info || ''
]);
}
console.log(table.toString());
console.log('');
// Show errors and warnings if present
if (this.results.hasErrors()) {
console.log(colored('Errors:', 'red'));
this.results.errors.forEach(err => {
console.log(` [X] ${err.name}: ${err.message}`);
if (err.fix) {
console.log(` Fix: ${err.fix}`);
}
});
console.log('');
}
if (this.results.hasWarnings()) {
console.log(colored('Warnings:', 'yellow'));
this.results.warnings.forEach(warn => {
console.log(` [!] ${warn.name}: ${warn.message}`);
if (warn.fix) {
console.log(` Fix: ${warn.fix}`);
}
});
console.log('');
}
// Final status
if (this.results.isHealthy() && !this.results.hasWarnings()) {
console.log(colored('[OK] All checks passed! Your CCS installation is healthy.', 'green'));
} else if (this.results.hasErrors()) {
console.log(colored('[X] Status: Installation has errors', 'red'));
console.log('Run suggested fixes above to resolve issues.');
} else {
console.log(colored('[OK] Status: Installation healthy (warnings only)', 'green'));
}
console.log('');
}
/**
* Generate JSON report
*/
generateJsonReport() {
return JSON.stringify({
timestamp: new Date().toISOString(),
platform: process.platform,
nodeVersion: process.version,
ccsVersion: require('../package.json').version,
checks: this.results.checks,
errors: this.results.errors,
warnings: this.results.warnings,
healthy: this.results.isHealthy()
}, null, 2);
}
}
module.exports = Doctor;
-202
View File
@@ -1,202 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const SharedManager = require('./shared-manager');
/**
* Instance Manager (Simplified)
*
* Manages isolated Claude CLI instances per profile for concurrent sessions.
* Each instance is an isolated CLAUDE_CONFIG_DIR where users login directly.
* No credential copying/encryption - Claude manages credentials per instance.
*/
class InstanceManager {
constructor() {
this.instancesDir = path.join(os.homedir(), '.ccs', 'instances');
this.sharedManager = new SharedManager();
}
/**
* Ensure instance exists for profile (lazy init only)
* @param {string} profileName - Profile name
* @returns {string} Instance path
*/
ensureInstance(profileName) {
const instancePath = this.getInstancePath(profileName);
// Lazy initialization
if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath);
}
// Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath);
return instancePath;
}
/**
* Get instance path for profile
* @param {string} profileName - Profile name
* @returns {string} Instance directory path
*/
getInstancePath(profileName) {
const safeName = this._sanitizeName(profileName);
return path.join(this.instancesDir, safeName);
}
/**
* Initialize new instance directory
* @param {string} profileName - Profile name
* @param {string} instancePath - Instance directory path
* @throws {Error} If initialization fails
*/
initializeInstance(profileName, instancePath) {
try {
// Create base directory
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
// Create Claude-expected subdirectories (profile-specific only)
const subdirs = [
'session-env',
'todos',
'logs',
'file-history',
'shell-snapshots',
'debug',
'.anthropic'
];
subdirs.forEach(dir => {
const dirPath = path.join(instancePath, dir);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
}
});
// Symlink shared directories (Phase 1: commands, skills)
this.sharedManager.linkSharedDirectories(instancePath);
// Copy global configs if exist (settings.json only)
this._copyGlobalConfigs(instancePath);
} catch (error) {
throw new Error(`Failed to initialize instance for ${profileName}: ${error.message}`);
}
}
/**
* Validate instance directory structure (auto-fix missing directories)
* @param {string} instancePath - Instance path
*/
validateInstance(instancePath) {
// Check required directories (auto-create if missing for migration)
const requiredDirs = [
'session-env',
'todos',
'logs',
'file-history',
'shell-snapshots',
'debug',
'.anthropic'
];
for (const dir of requiredDirs) {
const dirPath = path.join(instancePath, dir);
if (!fs.existsSync(dirPath)) {
// Auto-create missing directory (migration from older versions)
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
}
}
// Note: Credentials managed by Claude CLI in instance (no validation needed)
}
/**
* Delete instance for profile
* @param {string} profileName - Profile name
*/
deleteInstance(profileName) {
const instancePath = this.getInstancePath(profileName);
if (!fs.existsSync(instancePath)) {
return;
}
// Recursive delete
fs.rmSync(instancePath, { recursive: true, force: true });
}
/**
* List all instance names
* @returns {Array<string>} Instance names
*/
listInstances() {
if (!fs.existsSync(this.instancesDir)) {
return [];
}
return fs.readdirSync(this.instancesDir)
.filter(name => {
const instancePath = path.join(this.instancesDir, name);
return fs.statSync(instancePath).isDirectory();
});
}
/**
* Check if instance exists for profile
* @param {string} profileName - Profile name
* @returns {boolean} True if exists
*/
hasInstance(profileName) {
const instancePath = this.getInstancePath(profileName);
return fs.existsSync(instancePath);
}
/**
* Copy global configs to instance (optional)
* @param {string} instancePath - Instance path
*/
_copyGlobalConfigs(instancePath) {
// No longer needed - settings.json now symlinked via SharedManager
// Keeping method for backward compatibility (empty implementation)
// Can be removed in future major version
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
_copyDirectory(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
this._copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Sanitize profile name for filesystem
* @param {string} name - Profile name
* @returns {string} Safe name
*/
_sanitizeName(name) {
// Replace unsafe characters with dash
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
}
module.exports = InstanceManager;
-135
View File
@@ -1,135 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Auto-recovery for missing or corrupted configuration
*/
class RecoveryManager {
constructor() {
this.homedir = os.homedir();
this.ccsDir = path.join(this.homedir, '.ccs');
this.claudeDir = path.join(this.homedir, '.claude');
this.recovered = [];
}
/**
* Ensure ~/.ccs/ directory exists
*/
ensureCcsDirectory() {
if (!fs.existsSync(this.ccsDir)) {
fs.mkdirSync(this.ccsDir, { recursive: true, mode: 0o755 });
this.recovered.push('Created ~/.ccs/ directory');
return true;
}
return false;
}
/**
* Ensure ~/.ccs/config.json exists with defaults
*/
ensureConfigJson() {
const configPath = path.join(this.ccsDir, 'config.json');
// Check if exists and valid
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf8');
JSON.parse(content); // Validate JSON
return false; // No recovery needed
} catch (e) {
// Corrupted - backup and recreate
const backupPath = `${configPath}.backup.${Date.now()}`;
fs.renameSync(configPath, backupPath);
this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`);
}
}
// Create default config
const defaultConfig = {
profiles: {
glm: '~/.ccs/glm.settings.json',
kimi: '~/.ccs/kimi.settings.json',
default: '~/.claude/settings.json'
}
};
const tmpPath = `${configPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, configPath);
this.recovered.push('Created ~/.ccs/config.json');
return true;
}
/**
* Ensure ~/.claude/settings.json exists
*/
ensureClaudeSettings() {
const claudeSettingsPath = path.join(this.claudeDir, 'settings.json');
// Create ~/.claude/ if missing
if (!fs.existsSync(this.claudeDir)) {
fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o755 });
this.recovered.push('Created ~/.claude/ directory');
}
// Create settings.json if missing
if (!fs.existsSync(claudeSettingsPath)) {
const tmpPath = `${claudeSettingsPath}.tmp`;
fs.writeFileSync(tmpPath, '{}\n', 'utf8');
fs.renameSync(tmpPath, claudeSettingsPath);
this.recovered.push('Created ~/.claude/settings.json');
return true;
}
return false;
}
/**
* Run all recovery operations
* @returns {boolean} True if any recovery performed
*/
recoverAll() {
this.recovered = [];
this.ensureCcsDirectory();
this.ensureConfigJson();
this.ensureClaudeSettings();
return this.recovered.length > 0;
}
/**
* Get recovery summary
* @returns {string[]} List of recovery actions performed
*/
getRecoverySummary() {
return this.recovered;
}
/**
* Show recovery hints
*/
showRecoveryHints() {
if (this.recovered.length === 0) return;
console.log('');
console.log('[i] Auto-recovery completed:');
this.recovered.forEach(msg => console.log(` - ${msg}`));
// Show login hint if created Claude settings
if (this.recovered.some(msg => msg.includes('settings.json'))) {
console.log('');
console.log('[i] Next step: Login to Claude CLI');
console.log(' Run: claude /login');
}
console.log('');
}
}
module.exports = RecoveryManager;
-402
View File
@@ -1,402 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* SharedManager - Manages symlinked shared directories for CCS
* v3.2.0: Symlink-based architecture
*
* Purpose: Eliminates duplication by symlinking:
* ~/.claude/ ~/.ccs/shared/ instance/
*/
class SharedManager {
constructor() {
this.homeDir = os.homedir();
this.sharedDir = path.join(this.homeDir, '.ccs', 'shared');
this.claudeDir = path.join(this.homeDir, '.claude');
this.instancesDir = path.join(this.homeDir, '.ccs', 'instances');
this.sharedItems = [
{ name: 'commands', type: 'directory' },
{ name: 'skills', type: 'directory' },
{ name: 'agents', type: 'directory' },
{ name: 'plugins', type: 'directory' },
{ name: 'settings.json', type: 'file' }
];
}
/**
* Detect circular symlink before creation
* @param {string} target - Target path to link to
* @param {string} linkPath - Path where symlink will be created
* @returns {boolean} True if circular
* @private
*/
_detectCircularSymlink(target, linkPath) {
// Check if target exists and is symlink
if (!fs.existsSync(target)) {
return false;
}
try {
const stats = fs.lstatSync(target);
if (!stats.isSymbolicLink()) {
return false;
}
// Resolve target's link
const targetLink = fs.readlinkSync(target);
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
// Check if target points back to our shared dir or link path
const sharedDir = path.join(this.homeDir, '.ccs', 'shared');
if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) {
console.log(`[!] Circular symlink detected: ${target}${resolvedTarget}`);
return true;
}
} catch (err) {
// If can't read, assume not circular
return false;
}
return false;
}
/**
* Ensure shared directories exist as symlinks to ~/.claude/
* Creates ~/.claude/ structure if missing
*/
ensureSharedDirectories() {
// Create ~/.claude/ if missing
if (!fs.existsSync(this.claudeDir)) {
console.log('[i] Creating ~/.claude/ directory structure');
fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
}
// Create shared directory
if (!fs.existsSync(this.sharedDir)) {
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
}
// Create symlinks ~/.ccs/shared/* → ~/.claude/*
for (const item of this.sharedItems) {
const claudePath = path.join(this.claudeDir, item.name);
const sharedPath = path.join(this.sharedDir, item.name);
// Create in ~/.claude/ if missing
if (!fs.existsSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
// Create empty settings.json if missing
fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8');
}
}
// Check for circular symlink
if (this._detectCircularSymlink(claudePath, sharedPath)) {
console.log(`[!] Skipping ${item.name}: circular symlink detected`);
continue;
}
// If already a symlink pointing to correct target, skip
if (fs.existsSync(sharedPath)) {
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
continue; // Already correct
}
}
} catch (err) {
// Continue to recreate
}
// Remove existing file/directory/link
if (item.type === 'directory') {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else {
fs.unlinkSync(sharedPath);
}
}
// Create symlink
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (err) {
// Windows fallback: copy
if (process.platform === 'win32') {
if (item.type === 'directory') {
this._copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath);
}
console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`);
} else {
throw err;
}
}
}
}
/**
* Link shared directories to instance
* @param {string} instancePath - Path to instance directory
*/
linkSharedDirectories(instancePath) {
this.ensureSharedDirectories();
for (const item of this.sharedItems) {
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(this.sharedDir, item.name);
// Remove existing file/directory/link
if (fs.existsSync(linkPath)) {
if (item.type === 'directory') {
fs.rmSync(linkPath, { recursive: true, force: true });
} else {
fs.unlinkSync(linkPath);
}
}
// Create symlink
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (err) {
// Windows fallback
if (process.platform === 'win32') {
if (item.type === 'directory') {
this._copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath);
}
console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`);
} else {
throw err;
}
}
}
}
/**
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
* Runs once on upgrade
*/
migrateFromV311() {
// Check if migration already done (shared dirs are symlinks)
const commandsPath = path.join(this.sharedDir, 'commands');
if (fs.existsSync(commandsPath)) {
try {
if (fs.lstatSync(commandsPath).isSymbolicLink()) {
return; // Already migrated
}
} catch (err) {
// Continue with migration
}
}
console.log('[i] Migrating from v3.1.1 to v3.2.0...');
// Ensure ~/.claude/ exists
if (!fs.existsSync(this.claudeDir)) {
fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
}
// Copy user modifications from ~/.ccs/shared/ to ~/.claude/
for (const item of this.sharedItems) {
const sharedPath = path.join(this.sharedDir, item.name);
const claudePath = path.join(this.claudeDir, item.name);
if (!fs.existsSync(sharedPath)) continue;
try {
const stats = fs.lstatSync(sharedPath);
// Handle directories
if (item.type === 'directory' && stats.isDirectory()) {
// Create claude dir if missing
if (!fs.existsSync(claudePath)) {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
}
// Copy files from shared to claude (preserve user modifications)
const entries = fs.readdirSync(sharedPath, { withFileTypes: true });
let copied = 0;
for (const entry of entries) {
const src = path.join(sharedPath, entry.name);
const dest = path.join(claudePath, entry.name);
// Skip if already exists in claude
if (fs.existsSync(dest)) continue;
if (entry.isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
} else {
fs.copyFileSync(src, dest);
}
copied++;
}
if (copied > 0) {
console.log(`[OK] Migrated ${copied} ${item.name} to ~/.claude/${item.name}`);
}
}
// Handle files (settings.json)
else if (item.type === 'file' && stats.isFile()) {
// Only copy if ~/.claude/ version doesn't exist
if (!fs.existsSync(claudePath)) {
fs.copyFileSync(sharedPath, claudePath);
console.log(`[OK] Migrated ${item.name} to ~/.claude/${item.name}`);
}
}
} catch (err) {
console.log(`[!] Failed to migrate ${item.name}: ${err.message}`);
}
}
// Now run ensureSharedDirectories to create symlinks
this.ensureSharedDirectories();
// Update all instances to use new symlinks
if (fs.existsSync(this.instancesDir)) {
try {
const instances = fs.readdirSync(this.instancesDir);
for (const instance of instances) {
const instancePath = path.join(this.instancesDir, instance);
try {
if (fs.statSync(instancePath).isDirectory()) {
this.linkSharedDirectories(instancePath);
}
} catch (err) {
console.log(`[!] Failed to update instance ${instance}: ${err.message}`);
}
}
} catch (err) {
// No instances to update
}
}
console.log('[OK] Migration to v3.2.0 complete');
}
/**
* Migrate existing instances from isolated to shared settings.json (v4.4+)
* Runs once on upgrade
*/
migrateToSharedSettings() {
console.log('[i] Migrating instances to shared settings.json...');
// Ensure ~/.claude/settings.json exists (authoritative source)
const claudeSettings = path.join(this.claudeDir, 'settings.json');
if (!fs.existsSync(claudeSettings)) {
// Create empty settings if missing
fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8');
console.log('[i] Created ~/.claude/settings.json');
}
// Ensure shared settings.json symlink exists
this.ensureSharedDirectories();
// Migrate each instance
if (!fs.existsSync(this.instancesDir)) {
console.log('[i] No instances to migrate');
return;
}
const instances = fs.readdirSync(this.instancesDir).filter(name => {
const instancePath = path.join(this.instancesDir, name);
return fs.statSync(instancePath).isDirectory();
});
let migrated = 0;
let skipped = 0;
for (const instance of instances) {
const instancePath = path.join(this.instancesDir, instance);
const instanceSettings = path.join(instancePath, 'settings.json');
try {
// Check if already symlink
if (fs.existsSync(instanceSettings)) {
const stats = fs.lstatSync(instanceSettings);
if (stats.isSymbolicLink()) {
skipped++;
continue; // Already migrated
}
// Backup existing settings
const backup = instanceSettings + '.pre-shared-migration';
if (!fs.existsSync(backup)) {
fs.copyFileSync(instanceSettings, backup);
console.log(`[i] Backed up ${instance}/settings.json`);
}
// Remove old settings.json
fs.unlinkSync(instanceSettings);
}
// Create symlink via SharedManager
const sharedSettings = path.join(this.sharedDir, 'settings.json');
try {
fs.symlinkSync(sharedSettings, instanceSettings, 'file');
migrated++;
} catch (err) {
// Windows fallback
if (process.platform === 'win32') {
fs.copyFileSync(sharedSettings, instanceSettings);
console.log(`[!] Symlink failed for ${instance}, copied instead`);
migrated++;
} else {
throw err;
}
}
} catch (err) {
console.log(`[!] Failed to migrate ${instance}: ${err.message}`);
}
}
console.log(`[OK] Migrated ${migrated} instance(s), skipped ${skipped}`);
}
/**
* Copy directory as fallback (Windows without Developer Mode)
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @private
*/
_copyDirectoryFallback(src, dest) {
if (!fs.existsSync(src)) {
fs.mkdirSync(src, { recursive: true, mode: 0o700 });
return;
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
this._copyDirectoryFallback(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
}
module.exports = SharedManager;
-73
View File
@@ -1,73 +0,0 @@
'use strict';
const fs = require('fs');
const { execSync } = require('child_process');
const { expandPath } = require('./helpers');
// Detect Claude CLI executable
function detectClaudeCli() {
// Priority 1: CCS_CLAUDE_PATH environment variable (if user wants custom path)
if (process.env.CCS_CLAUDE_PATH) {
const ccsPath = expandPath(process.env.CCS_CLAUDE_PATH);
// Basic validation: file exists
if (fs.existsSync(ccsPath)) {
return ccsPath;
}
// Invalid CCS_CLAUDE_PATH - show warning and fall back to PATH
console.warn('[!] Warning: CCS_CLAUDE_PATH is set but file not found:', ccsPath);
console.warn(' Falling back to system PATH lookup...');
}
// Priority 2: Resolve 'claude' from PATH using which/where.exe
// This fixes Windows npm installation where spawn() can't resolve bare command names
// SECURITY: Commands are hardcoded literals with no user input - safe from injection
const isWindows = process.platform === 'win32';
try {
const cmd = isWindows ? 'where.exe claude' : 'which claude';
const result = execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000 // 5 second timeout to prevent hangs
}).trim();
// where.exe may return multiple lines (all matches in PATH order)
const matches = result.split('\n').map(p => p.trim()).filter(p => p);
if (isWindows) {
// On Windows, prefer executables with extensions (.exe, .cmd, .bat)
// where.exe often returns file without extension first, then the actual .cmd wrapper
const withExtension = matches.find(p => /\.(exe|cmd|bat|ps1)$/i.test(p));
const claudePath = withExtension || matches[0];
if (claudePath && fs.existsSync(claudePath)) {
return claudePath;
}
} else {
// On Unix, first match is fine
const claudePath = matches[0];
if (claudePath && fs.existsSync(claudePath)) {
return claudePath;
}
}
} catch (err) {
// Command failed - claude not in PATH
// Fall through to return null
}
// Priority 3: Claude not found
return null;
}
// Show Claude not found error
function showClaudeNotFoundError() {
console.error('ERROR: Claude CLI not found in PATH');
console.error('Install from: https://docs.claude.com/en/docs/claude-code/installation');
process.exit(1);
}
module.exports = {
detectClaudeCli,
showClaudeNotFoundError
};
-283
View File
@@ -1,283 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// Make ora optional (might not be available during npm install postinstall)
// ora v9+ is an ES module, need to use .default for CommonJS
let ora = null;
try {
const oraModule = require('ora');
ora = oraModule.default || oraModule;
} catch (e) {
// ora not available, create fallback spinner that uses console.log
ora = function(text) {
return {
start: () => ({
succeed: (msg) => console.log(msg || `[OK] ${text}`),
fail: (msg) => console.log(msg || `[X] ${text}`),
warn: (msg) => console.log(msg || `[!] ${text}`),
info: (msg) => console.log(msg || `[i] ${text}`),
text: ''
})
};
};
}
const { colored } = require('./helpers');
/**
* ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/
* v4.1.1: Fix for npm install not copying .claude/ directory
*/
class ClaudeDirInstaller {
constructor() {
this.homeDir = os.homedir();
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
}
/**
* Copy .claude/ directory from package to ~/.ccs/.claude/
* @param {string} packageDir - Package installation directory (default: auto-detect)
* @param {boolean} silent - Suppress spinner output
*/
install(packageDir, silent = false) {
const spinner = (silent || !ora) ? null : ora('Copying .claude/ items to ~/.ccs/.claude/').start();
try {
// Auto-detect package directory if not provided
if (!packageDir) {
// Try to find package root by going up from this file
packageDir = path.join(__dirname, '..', '..');
}
const packageClaudeDir = path.join(packageDir, '.claude');
if (!fs.existsSync(packageClaudeDir)) {
const msg = 'Package .claude/ directory not found';
if (spinner) {
spinner.warn(`[!] ${msg}`);
console.log(` Searched in: ${packageClaudeDir}`);
console.log(' This may be a development installation');
} else {
console.log(`[!] ${msg}`);
console.log(` Searched in: ${packageClaudeDir}`);
console.log(' This may be a development installation');
}
return false;
}
// Remove old version before copying new one
if (fs.existsSync(this.ccsClaudeDir)) {
if (spinner) spinner.text = 'Removing old .claude/ items...';
fs.rmSync(this.ccsClaudeDir, { recursive: true, force: true });
}
// Use fs.cpSync for recursive copy (Node.js 16.7.0+)
// Fallback to manual copy for older Node.js versions
if (spinner) spinner.text = 'Copying .claude/ items...';
if (fs.cpSync) {
fs.cpSync(packageClaudeDir, this.ccsClaudeDir, { recursive: true });
} else {
// Fallback for Node.js < 16.7.0
this._copyDirRecursive(packageClaudeDir, this.ccsClaudeDir);
}
// Count files and directories
const itemCount = this._countItems(this.ccsClaudeDir);
const msg = `Copied .claude/ items (${itemCount.files} files, ${itemCount.dirs} directories)`;
if (spinner) {
spinner.succeed(colored('[OK]', 'green') + ` ${msg}`);
} else {
console.log(`[OK] ${msg}`);
}
return true;
} catch (err) {
const msg = `Failed to copy .claude/ directory: ${err.message}`;
if (spinner) {
spinner.fail(colored('[!]', 'yellow') + ` ${msg}`);
console.warn(' CCS items may not be available');
} else {
console.warn(`[!] ${msg}`);
console.warn(' CCS items may not be available');
}
return false;
}
}
/**
* Recursively copy directory (fallback for Node.js < 16.7.0)
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @private
*/
_copyDirRecursive(src, dest) {
// Create destination directory
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
// Read source directory
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
// Recursively copy subdirectory
this._copyDirRecursive(srcPath, destPath);
} else {
// Copy file
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Count files and directories in a path
* @param {string} dirPath - Directory to count
* @returns {Object} { files: number, dirs: number }
* @private
*/
_countItems(dirPath) {
let files = 0;
let dirs = 0;
const countRecursive = (p) => {
const entries = fs.readdirSync(p, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
dirs++;
countRecursive(path.join(p, entry.name));
} else {
files++;
}
}
};
try {
countRecursive(dirPath);
} catch (e) {
// Ignore errors
}
return { files, dirs };
}
/**
* Clean up deprecated files from previous installations
* Removes ccs-delegator.md that was deprecated in v4.3.2
* @param {boolean} silent - Suppress console output
*/
cleanupDeprecated(silent = false) {
const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md');
const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md');
const migrationMarker = path.join(this.homeDir, '.ccs', '.migrations', 'v435-delegator-cleanup');
let cleanedFiles = [];
try {
// Check if cleanup already done
if (fs.existsSync(migrationMarker)) {
return { success: true, cleanedFiles: [] }; // Already cleaned
}
// Clean up user symlink in ~/.claude/agents/ccs-delegator.md FIRST
// This ensures we can detect broken symlinks before deleting the target
try {
const userStats = fs.lstatSync(userSymlinkFile);
if (userStats.isSymbolicLink()) {
fs.unlinkSync(userSymlinkFile);
cleanedFiles.push('user symlink');
} else {
// It's not a symlink (user created their own file), backup it
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const backupPath = `${userSymlinkFile}.backup-${timestamp}`;
fs.renameSync(userSymlinkFile, backupPath);
if (!silent) console.log(`[i] Backed up user file to ${path.basename(backupPath)}`);
cleanedFiles.push('user file (backed up)');
}
} catch (err) {
// File doesn't exist or other error - that's okay
if (err.code !== 'ENOENT' && !silent) {
console.log(`[!] Failed to remove user symlink: ${err.message}`);
}
}
// Clean up package copy in ~/.ccs/.claude/agents/ccs-delegator.md
if (fs.existsSync(deprecatedFile)) {
try {
// Check if file was modified by user (compare with expected content)
const shouldBackup = this._shouldBackupDeprecatedFile(deprecatedFile);
if (shouldBackup) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const backupPath = `${deprecatedFile}.backup-${timestamp}`;
fs.renameSync(deprecatedFile, backupPath);
if (!silent) console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`);
} else {
fs.rmSync(deprecatedFile, { force: true });
}
cleanedFiles.push('package copy');
} catch (err) {
if (!silent) console.log(`[!] Failed to remove package copy: ${err.message}`);
}
}
// Create migration marker
if (cleanedFiles.length > 0) {
const migrationsDir = path.dirname(migrationMarker);
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(migrationMarker, new Date().toISOString());
if (!silent) {
console.log(`[OK] Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`);
}
}
return { success: true, cleanedFiles };
} catch (err) {
if (!silent) console.log(`[!] Cleanup failed: ${err.message}`);
return { success: false, error: err.message, cleanedFiles };
}
}
/**
* Check if deprecated file should be backed up (user modified)
* @param {string} filePath - Path to check
* @returns {boolean} True if file should be backed up
* @private
*/
_shouldBackupDeprecatedFile(filePath) {
try {
// Simple heuristic: if file size differs significantly from expected, assume user modified
// Expected size for ccs-delegator.md was around 2-3KB
const stats = fs.statSync(filePath);
const expectedMinSize = 1000; // 1KB minimum
const expectedMaxSize = 10000; // 10KB maximum
// If size is outside expected range, likely user modified
return stats.size < expectedMinSize || stats.size > expectedMaxSize;
} catch (err) {
// If we can't determine, err on side of caution and backup
return true;
}
}
/**
* Check if ~/.ccs/.claude/ exists and is valid
* @returns {boolean} True if directory exists
*/
isInstalled() {
return fs.existsSync(this.ccsClaudeDir);
}
}
module.exports = ClaudeDirInstaller;
-289
View File
@@ -1,289 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// Make ora optional (might not be available during npm install postinstall)
// ora v9+ is an ES module, need to use .default for CommonJS
let ora = null;
try {
const oraModule = require('ora');
ora = oraModule.default || oraModule;
} catch (e) {
// ora not available, create fallback spinner that uses console.log
ora = function(text) {
return {
start: () => ({
succeed: (msg) => console.log(msg || `[OK] ${text}`),
fail: (msg) => console.log(msg || `[X] ${text}`),
warn: (msg) => console.log(msg || `[!] ${text}`),
info: (msg) => console.log(msg || `[i] ${text}`),
text: ''
})
};
};
}
const { colored } = require('./helpers');
/**
* ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/
* v4.1.0: Selective symlinking for CCS items
*
* Purpose: Ship CCS items (.claude/) with package and symlink them to user's ~/.claude/
* Architecture:
* - ~/.ccs/.claude/* (source, ships with CCS)
* - ~/.claude/* (target, gets selective symlinks)
* - ~/.ccs/shared/ (UNTOUCHED, existing profile mechanism)
*
* Symlink Chain:
* profile -> ~/.ccs/shared/ -> ~/.claude/ (which has symlinks to ~/.ccs/.claude/)
*/
class ClaudeSymlinkManager {
constructor() {
this.homeDir = os.homedir();
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
this.userClaudeDir = path.join(this.homeDir, '.claude');
// CCS items to symlink (selective, item-level)
this.ccsItems = [
{ source: 'commands/ccs.md', target: 'commands/ccs.md', type: 'file' },
{ source: 'commands/ccs', target: 'commands/ccs', type: 'directory' },
{ source: 'skills/ccs-delegation', target: 'skills/ccs-delegation', type: 'directory' }
];
}
/**
* Install CCS items to user's ~/.claude/ via selective symlinks
* Safe: backs up existing files before creating symlinks
*/
install(silent = false) {
const spinner = (silent || !ora) ? null : ora('Installing CCS items to ~/.claude/').start();
// Ensure ~/.ccs/.claude/ exists (should be shipped with package)
if (!fs.existsSync(this.ccsClaudeDir)) {
const msg = 'CCS .claude/ directory not found, skipping symlink installation';
if (spinner) {
spinner.warn(`[!] ${msg}`);
} else {
console.log(`[!] ${msg}`);
}
return;
}
// Create ~/.claude/ if missing
if (!fs.existsSync(this.userClaudeDir)) {
if (!silent) {
if (spinner) spinner.text = 'Creating ~/.claude/ directory';
}
fs.mkdirSync(this.userClaudeDir, { recursive: true, mode: 0o700 });
}
// Install each CCS item
let installed = 0;
for (const item of this.ccsItems) {
if (!silent && spinner) {
spinner.text = `Installing ${item.target}...`;
}
const result = this._installItem(item, silent);
if (result) installed++;
}
const msg = `${installed}/${this.ccsItems.length} items installed to ~/.claude/`;
if (spinner) {
spinner.succeed(colored('[OK]', 'green') + ` ${msg}`);
} else {
console.log(`[OK] ${msg}`);
}
}
/**
* Install a single CCS item with conflict handling
* @param {Object} item - Item descriptor {source, target, type}
* @param {boolean} silent - Suppress individual item messages
* @returns {boolean} True if installed successfully
* @private
*/
_installItem(item, silent = false) {
const sourcePath = path.join(this.ccsClaudeDir, item.source);
const targetPath = path.join(this.userClaudeDir, item.target);
const targetDir = path.dirname(targetPath);
// Ensure source exists
if (!fs.existsSync(sourcePath)) {
if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`);
return false;
}
// Create target parent directory if needed
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
}
// Check if target already exists
if (fs.existsSync(targetPath)) {
// Check if it's already the correct symlink
if (this._isOurSymlink(targetPath, sourcePath)) {
return true; // Already correct, counts as success
}
// Backup existing file/directory
this._backupItem(targetPath, silent);
}
// Create symlink
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(sourcePath, targetPath, symlinkType);
if (!silent) console.log(`[OK] Symlinked ${item.target}`);
return true;
} catch (err) {
// Windows fallback: stub for now, full implementation in v4.2
if (process.platform === 'win32') {
if (!silent) {
console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`);
console.log(`[i] Enable Developer Mode or wait for next update`);
}
} else {
if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${err.message}`);
}
return false;
}
}
/**
* Check if target is already the correct symlink pointing to source
* @param {string} targetPath - Target path to check
* @param {string} expectedSource - Expected source path
* @returns {boolean} True if target is correct symlink
* @private
*/
_isOurSymlink(targetPath, expectedSource) {
try {
const stats = fs.lstatSync(targetPath);
if (!stats.isSymbolicLink()) {
return false;
}
const actualTarget = fs.readlinkSync(targetPath);
const resolvedTarget = path.resolve(path.dirname(targetPath), actualTarget);
return resolvedTarget === expectedSource;
} catch (err) {
return false;
}
}
/**
* Backup existing item before replacing with symlink
* @param {string} itemPath - Path to item to backup
* @param {boolean} silent - Suppress backup messages
* @private
*/
_backupItem(itemPath, silent = false) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const backupPath = `${itemPath}.backup-${timestamp}`;
try {
// If backup already exists, use counter
let finalBackupPath = backupPath;
let counter = 1;
while (fs.existsSync(finalBackupPath)) {
finalBackupPath = `${backupPath}-${counter}`;
counter++;
}
fs.renameSync(itemPath, finalBackupPath);
if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`);
} catch (err) {
if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${err.message}`);
throw err; // Don't proceed if backup fails
}
}
/**
* Uninstall CCS items from ~/.claude/ (remove symlinks only)
* Safe: only removes items that are CCS symlinks
*/
uninstall() {
let removed = 0;
for (const item of this.ccsItems) {
const targetPath = path.join(this.userClaudeDir, item.target);
const sourcePath = path.join(this.ccsClaudeDir, item.source);
// Only remove if it's our symlink
if (fs.existsSync(targetPath) && this._isOurSymlink(targetPath, sourcePath)) {
try {
fs.unlinkSync(targetPath);
console.log(`[OK] Removed ${item.target}`);
removed++;
} catch (err) {
console.log(`[!] Failed to remove ${item.target}: ${err.message}`);
}
}
}
if (removed > 0) {
console.log(`[OK] Removed ${removed} delegation commands and skills from ~/.claude/`);
} else {
console.log('[i] No delegation commands or skills to remove');
}
}
/**
* Check symlink health and report issues
* Used by 'ccs doctor' command
* @returns {Object} Health check results {healthy: boolean, issues: string[]}
*/
checkHealth() {
const issues = [];
let healthy = true;
// Check if ~/.ccs/.claude/ exists
if (!fs.existsSync(this.ccsClaudeDir)) {
issues.push('CCS .claude/ directory missing (reinstall CCS)');
healthy = false;
return { healthy, issues };
}
// Check each item
for (const item of this.ccsItems) {
const sourcePath = path.join(this.ccsClaudeDir, item.source);
const targetPath = path.join(this.userClaudeDir, item.target);
// Check source exists
if (!fs.existsSync(sourcePath)) {
issues.push(`Source missing: ${item.source}`);
healthy = false;
continue;
}
// Check target
if (!fs.existsSync(targetPath)) {
issues.push(`Not installed: ${item.target} (run 'ccs sync' to install)`);
healthy = false;
} else if (!this._isOurSymlink(targetPath, sourcePath)) {
issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`);
healthy = false;
}
}
return { healthy, issues };
}
/**
* Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command)
* Same as install() but with explicit sync message
*/
sync() {
console.log('');
console.log(colored('Syncing CCS Components...', 'cyan'));
console.log('');
this.install(false);
}
}
module.exports = ClaudeSymlinkManager;
-103
View File
@@ -1,103 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { error, expandPath } = require('./helpers');
const { ErrorManager } = require('./error-manager');
// Get config file path
function getConfigPath() {
return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json');
}
// Read and parse config
function readConfig() {
const configPath = getConfigPath();
// Check config exists
if (!fs.existsSync(configPath)) {
// Attempt recovery
const RecoveryManager = require('./recovery-manager');
const recovery = new RecoveryManager();
recovery.ensureConfigJson();
if (!fs.existsSync(configPath)) {
ErrorManager.showInvalidConfig(configPath, 'File not found');
process.exit(1);
}
}
// Read and parse JSON
let config;
try {
const configContent = fs.readFileSync(configPath, 'utf8');
config = JSON.parse(configContent);
} catch (e) {
ErrorManager.showInvalidConfig(configPath, `Invalid JSON: ${e.message}`);
process.exit(1);
}
// Validate config has profiles object
if (!config.profiles || typeof config.profiles !== 'object') {
ErrorManager.showInvalidConfig(configPath, "Missing 'profiles' object");
process.exit(1);
}
return config;
}
// Get settings path for profile
function getSettingsPath(profile) {
const config = readConfig();
// Get settings path
const settingsPath = config.profiles[profile];
if (!settingsPath) {
const availableProfiles = Object.keys(config.profiles);
const profileList = availableProfiles.map(p => ` - ${p}`);
ErrorManager.showProfileNotFound(profile, profileList);
process.exit(1);
}
// Expand path
const expandedPath = expandPath(settingsPath);
// Validate settings file exists
if (!fs.existsSync(expandedPath)) {
// Auto-create if it's ~/.claude/settings.json
if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) {
const RecoveryManager = require('./recovery-manager');
const recovery = new RecoveryManager();
recovery.ensureClaudeSettings();
if (!fs.existsSync(expandedPath)) {
ErrorManager.showSettingsNotFound(expandedPath);
process.exit(1);
}
console.log('[i] Auto-created missing settings file');
} else {
ErrorManager.showSettingsNotFound(expandedPath);
process.exit(1);
}
}
// Validate settings file is valid JSON
try {
const settingsContent = fs.readFileSync(expandedPath, 'utf8');
JSON.parse(settingsContent);
} catch (e) {
ErrorManager.showInvalidConfig(expandedPath, `Invalid JSON: ${e.message}`);
process.exit(1);
}
return expandedPath;
}
module.exports = {
getConfigPath,
readConfig,
getSettingsPath
};
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Validates delegation profiles for CCS delegation system
* Ensures profiles exist and have valid API keys configured
*/
class DelegationValidator {
/**
* Validate a delegation profile
* @param {string} profileName - Name of profile to validate (e.g., 'glm', 'kimi')
* @returns {Object} Validation result { valid: boolean, error?: string, settingsPath?: string }
*/
static validate(profileName) {
const homeDir = os.homedir();
const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`);
// Check if profile directory exists
if (!fs.existsSync(settingsPath)) {
return {
valid: false,
error: `Profile not found: ${profileName}`,
suggestion: `Profile settings missing at: ${settingsPath}\n\n` +
`To set up ${profileName} profile:\n` +
` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` +
` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` +
` 3. Set your API key in ANTHROPIC_AUTH_TOKEN field`
};
}
// Read and parse settings.json
let settings;
try {
const settingsContent = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(settingsContent);
} catch (error) {
return {
valid: false,
error: `Failed to parse settings.json for ${profileName}`,
suggestion: `Settings file is corrupted or invalid JSON.\n\n` +
`Location: ${settingsPath}\n` +
`Parse error: ${error.message}\n\n` +
`Fix: Restore from base config:\n` +
` cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json`
};
}
// Validate API key exists and is not default
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
if (!apiKey) {
return {
valid: false,
error: `API key not configured for ${profileName}`,
suggestion: `Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` +
`Edit: ${settingsPath}\n` +
`Set: env.ANTHROPIC_AUTH_TOKEN to your API key`
};
}
// Check for default placeholder values
const defaultPlaceholders = [
'YOUR_GLM_API_KEY_HERE',
'YOUR_KIMI_API_KEY_HERE',
'YOUR_API_KEY_HERE',
'your-api-key-here',
'PLACEHOLDER'
];
if (defaultPlaceholders.some(placeholder => apiKey.includes(placeholder))) {
return {
valid: false,
error: `Default API key placeholder detected for ${profileName}`,
suggestion: `API key is still set to default placeholder.\n\n` +
`To configure your profile:\n` +
` 1. Edit: ${settingsPath}\n` +
` 2. Replace ANTHROPIC_AUTH_TOKEN with your actual API key\n\n` +
`Get API key:\n` +
` GLM: https://z.ai/manage-apikey/apikey-list\n` +
` Kimi: https://platform.moonshot.cn/console/api-keys`
};
}
// Validation passed
return {
valid: true,
settingsPath,
apiKey: apiKey.substring(0, 8) + '...' // Show first 8 chars for verification
};
}
/**
* Format validation error for display
* @param {Object} result - Validation result from validate()
* @returns {string} Formatted error message
*/
static formatError(result) {
if (result.valid) {
return '';
}
let message = `\n[X] ${result.error}\n\n`;
if (result.suggestion) {
message += `${result.suggestion}\n`;
}
return message;
}
/**
* Check if profile is delegation-ready (shorthand)
* @param {string} profileName - Profile to check
* @returns {boolean} True if ready for delegation
*/
static isReady(profileName) {
const result = this.validate(profileName);
return result.valid;
}
/**
* Get all delegation-ready profiles
* @returns {Array<string>} List of profile names ready for delegation
*/
static getReadyProfiles() {
const homeDir = os.homedir();
const ccsDir = path.join(homeDir, '.ccs');
if (!fs.existsSync(ccsDir)) {
return [];
}
const profiles = [];
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
// Look for *.settings.json files
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.settings.json')) {
const profileName = entry.name.replace('.settings.json', '');
if (this.isReady(profileName)) {
profiles.push(profileName);
}
}
}
return profiles;
}
}
module.exports = { DelegationValidator };
-59
View File
@@ -1,59 +0,0 @@
// CCS Error Codes
// Documentation: ../../docs/errors/README.md
const ERROR_CODES = {
// Configuration Errors (E100-E199)
CONFIG_MISSING: 'E101',
CONFIG_INVALID_JSON: 'E102',
CONFIG_INVALID_PROFILE: 'E103',
// Profile Management Errors (E200-E299)
PROFILE_NOT_FOUND: 'E104',
PROFILE_ALREADY_EXISTS: 'E105',
PROFILE_CANNOT_DELETE_DEFAULT: 'E106',
PROFILE_INVALID_NAME: 'E107',
// Claude CLI Detection Errors (E300-E399)
CLAUDE_NOT_FOUND: 'E301',
CLAUDE_VERSION_INCOMPATIBLE: 'E302',
CLAUDE_EXECUTION_FAILED: 'E303',
// Network/API Errors (E400-E499)
GLMT_PROXY_TIMEOUT: 'E401',
API_KEY_MISSING: 'E402',
API_AUTH_FAILED: 'E403',
API_RATE_LIMIT: 'E404',
// File System Errors (E500-E599)
FS_CANNOT_CREATE_DIR: 'E501',
FS_CANNOT_WRITE_FILE: 'E502',
FS_CANNOT_READ_FILE: 'E503',
FS_INSTANCE_NOT_FOUND: 'E504',
// Internal Errors (E900-E999)
INTERNAL_ERROR: 'E900',
INVALID_STATE: 'E901'
};
// Error code documentation URL generator
function getErrorDocUrl(errorCode) {
return `https://github.com/kaitranntt/ccs/blob/main/docs/errors/README.md#${errorCode.toLowerCase()}`;
}
// Get error category from code
function getErrorCategory(errorCode) {
const code = parseInt(errorCode.substring(1));
if (code >= 100 && code < 200) return 'Configuration';
if (code >= 200 && code < 300) return 'Profile Management';
if (code >= 300 && code < 400) return 'Claude CLI Detection';
if (code >= 400 && code < 500) return 'Network/API';
if (code >= 500 && code < 600) return 'File System';
if (code >= 900 && code < 1000) return 'Internal';
return 'Unknown';
}
module.exports = {
ERROR_CODES,
getErrorDocUrl,
getErrorCategory
};
-165
View File
@@ -1,165 +0,0 @@
'use strict';
const { colored } = require('./helpers');
const { ERROR_CODES, getErrorDocUrl } = require('./error-codes');
/**
* Error types with structured messages (Legacy - kept for compatibility)
*/
const ErrorTypes = {
NO_CLAUDE_CLI: 'NO_CLAUDE_CLI',
MISSING_SETTINGS: 'MISSING_SETTINGS',
INVALID_CONFIG: 'INVALID_CONFIG',
UNKNOWN_PROFILE: 'UNKNOWN_PROFILE',
PERMISSION_DENIED: 'PERMISSION_DENIED',
GENERIC: 'GENERIC'
};
/**
* Enhanced error manager with context-aware messages
*/
class ErrorManager {
/**
* Show error code and documentation URL
* @param {string} errorCode - Error code (e.g., E301)
*/
static showErrorCode(errorCode) {
console.error(colored(`Error: ${errorCode}`, 'yellow'));
console.error(colored(getErrorDocUrl(errorCode), 'yellow'));
console.error('');
}
/**
* Show Claude CLI not found error
*/
static showClaudeNotFound() {
console.error('');
console.error(colored('[X] Claude CLI not found', 'red'));
console.error('');
console.error('CCS requires Claude CLI to be installed and available in PATH.');
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' 1. Install Claude CLI:');
console.error(' https://docs.claude.com/en/docs/claude-code/installation');
console.error('');
console.error(' 2. Verify installation:');
console.error(' command -v claude (Unix)');
console.error(' Get-Command claude (Windows)');
console.error('');
console.error(' 3. Custom path (if installed elsewhere):');
console.error(' export CCS_CLAUDE_PATH="/path/to/claude"');
console.error('');
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
}
/**
* Show settings file not found error
* @param {string} settingsPath - Path to missing settings file
*/
static showSettingsNotFound(settingsPath) {
const isClaudeSettings = settingsPath.includes('.claude') && settingsPath.endsWith('settings.json');
console.error('');
console.error(colored('[X] Settings file not found', 'red'));
console.error('');
console.error(`File: ${settingsPath}`);
console.error('');
if (isClaudeSettings) {
console.error('This file is auto-created when you login to Claude CLI.');
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(` echo '{}' > ${settingsPath}`);
console.error(' claude /login');
console.error('');
console.error('Why: Newer Claude CLI versions require explicit login.');
} else {
console.error(colored('Solutions:', 'yellow'));
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
console.error('This will recreate missing profile settings.');
}
console.error('');
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_PROFILE);
}
/**
* Show invalid configuration error
* @param {string} configPath - Path to invalid config
* @param {string} errorDetail - JSON parse error detail
*/
static showInvalidConfig(configPath, errorDetail) {
console.error('');
console.error(colored('[X] Configuration invalid', 'red'));
console.error('');
console.error(`File: ${configPath}`);
console.error(`Issue: ${errorDetail}`);
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Backup corrupted file');
console.error(` mv ${configPath} ${configPath}.backup`);
console.error('');
console.error(' # Reinstall CCS');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
console.error('Your profile settings will be preserved.');
console.error('');
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON);
}
/**
* Show profile not found error
* @param {string} profileName - Requested profile name
* @param {string[]} availableProfiles - List of available profiles
* @param {string[]} suggestions - Suggested profile names (fuzzy match)
*/
static showProfileNotFound(profileName, availableProfiles, suggestions = []) {
console.error('');
console.error(colored(`[X] Profile '${profileName}' not found`, 'red'));
console.error('');
if (suggestions && suggestions.length > 0) {
console.error(colored('Did you mean:', 'yellow'));
suggestions.forEach(s => console.error(` ${s}`));
console.error('');
}
console.error(colored('Available profiles:', 'cyan'));
availableProfiles.forEach(line => console.error(` ${line}`));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Use existing profile');
console.error(' ccs <profile> "your prompt"');
console.error('');
console.error(' # Create new account profile');
console.error(' ccs auth create <name>');
console.error('');
this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND);
}
/**
* Show permission denied error
* @param {string} path - Path with permission issue
*/
static showPermissionDenied(path) {
console.error('');
console.error(colored('[X] Permission denied', 'red'));
console.error('');
console.error(`Cannot write to: ${path}`);
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Fix ownership');
console.error(' sudo chown -R $USER ~/.ccs ~/.claude');
console.error('');
console.error(' # Fix permissions');
console.error(' chmod 755 ~/.ccs ~/.claude');
console.error('');
console.error(' # Retry installation');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE);
}
}
module.exports = { ErrorManager, ErrorTypes };
-136
View File
@@ -1,136 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// TTY-aware color detection (matches lib/ccs bash logic)
function getColors() {
const forcedColors = process.env.FORCE_COLOR;
const noColor = process.env.NO_COLOR;
const isTTY = process.stdout.isTTY === true; // Must be explicitly true
const useColors = forcedColors || (isTTY && !noColor);
if (useColors) {
return {
red: '\x1b[0;31m',
yellow: '\x1b[1;33m',
cyan: '\x1b[0;36m',
green: '\x1b[0;32m',
bold: '\x1b[1m',
reset: '\x1b[0m'
};
}
return { red: '', yellow: '', cyan: '', green: '', bold: '', reset: '' };
}
// Colors object (dynamic)
const colors = getColors();
// Helper: Apply color to text (returns plain text if colors disabled)
function colored(text, colorName = 'reset') {
const currentColors = getColors();
const color = currentColors[colorName] || '';
return color ? `${color}${text}${currentColors.reset}` : text;
}
// Simple error formatting
function error(message) {
console.error(`ERROR: ${message}`);
console.error('Try: npm install -g @kaitranntt/ccs --force');
process.exit(1);
}
// Path expansion (~ and env vars)
function expandPath(pathStr) {
// Handle tilde expansion
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
pathStr = path.join(os.homedir(), pathStr.slice(2));
}
// Expand environment variables (Windows and Unix)
pathStr = pathStr.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || '');
pathStr = pathStr.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || '');
// Windows %VAR% style
if (process.platform === 'win32') {
pathStr = pathStr.replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
}
return path.normalize(pathStr);
}
/**
* Calculate Levenshtein distance between two strings
* @param {string} a - First string
* @param {string} b - Second string
* @returns {number} Edit distance
*/
function levenshteinDistance(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = [];
// Initialize first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Find similar strings using fuzzy matching
* @param {string} target - Target string
* @param {string[]} candidates - List of candidate strings
* @param {number} maxDistance - Maximum edit distance (default: 2)
* @returns {string[]} Similar strings sorted by distance
*/
function findSimilarStrings(target, candidates, maxDistance = 2) {
const targetLower = target.toLowerCase();
const matches = candidates
.map(candidate => ({
name: candidate,
distance: levenshteinDistance(targetLower, candidate.toLowerCase())
}))
.filter(item => item.distance <= maxDistance && item.distance > 0)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3) // Show at most 3 suggestions
.map(item => item.name);
return matches;
}
module.exports = {
colors,
colored,
error,
expandPath,
levenshteinDistance,
findSimilarStrings
};
-111
View File
@@ -1,111 +0,0 @@
'use strict';
/**
* Simple Progress Indicator (no external dependencies)
*
* Features:
* - ASCII-only spinner frames (cross-platform compatible)
* - TTY detection (no spinners in pipes/logs)
* - Elapsed time display
* - CI environment detection
*/
class ProgressIndicator {
/**
* Create a progress indicator
* @param {string} message - Message to display
* @param {Object} options - Options
* @param {string[]} options.frames - Spinner frames (default: ASCII)
* @param {number} options.interval - Frame interval in ms (default: 80)
*/
constructor(message, options = {}) {
this.message = message;
// ASCII-only frames for cross-platform compatibility
this.frames = options.frames || ['|', '/', '-', '\\'];
this.frameIndex = 0;
this.interval = null;
this.startTime = Date.now();
// TTY detection: only animate if stderr is TTY and not in CI
this.isTTY = process.stderr.isTTY === true && !process.env.CI && !process.env.NO_COLOR;
}
/**
* Start the spinner
*/
start() {
if (!this.isTTY) {
// Non-TTY: just print message once
process.stderr.write(`[i] ${this.message}...\n`);
return;
}
// TTY: animate spinner
this.interval = setInterval(() => {
const frame = this.frames[this.frameIndex];
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
process.stderr.write(`\r[${frame}] ${this.message}... (${elapsed}s)`);
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
}, 80); // 12.5fps for smooth animation
}
/**
* Stop spinner with success message
* @param {string} message - Optional success message (defaults to original message)
*/
succeed(message) {
this.stop();
const finalMessage = message || this.message;
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
if (this.isTTY) {
// Clear spinner line and show success
process.stderr.write(`\r[OK] ${finalMessage} (${elapsed}s)\n`);
} else {
// Non-TTY: just show completion
process.stderr.write(`[OK] ${finalMessage}\n`);
}
}
/**
* Stop spinner with failure message
* @param {string} message - Optional failure message (defaults to original message)
*/
fail(message) {
this.stop();
const finalMessage = message || this.message;
if (this.isTTY) {
// Clear spinner line and show failure
process.stderr.write(`\r[X] ${finalMessage}\n`);
} else {
// Non-TTY: just show failure
process.stderr.write(`[X] ${finalMessage}\n`);
}
}
/**
* Update spinner message (while running)
* @param {string} newMessage - New message to display
*/
update(newMessage) {
this.message = newMessage;
}
/**
* Stop the spinner without showing success/failure
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
if (this.isTTY) {
// Clear the spinner line
process.stderr.write('\r\x1b[K');
}
}
}
}
module.exports = { ProgressIndicator };
-134
View File
@@ -1,134 +0,0 @@
'use strict';
const readline = require('readline');
/**
* Interactive Prompt Utilities (NO external dependencies)
*
* Features:
* - TTY detection (auto-confirm in non-TTY)
* - --yes flag support for automation
* - --no-input flag support for CI
* - Safe defaults (N for destructive actions)
* - Input validation with retry
*/
class InteractivePrompt {
/**
* Ask for confirmation
* @param {string} message - Confirmation message
* @param {Object} options - Options
* @param {boolean} options.default - Default value (true=Yes, false=No)
* @returns {Promise<boolean>} User confirmation
*/
static async confirm(message, options = {}) {
const { default: defaultValue = false } = options;
// Check for --yes flag (automation) - always returns true
if (process.env.CCS_YES === '1' || process.argv.includes('--yes') || process.argv.includes('-y')) {
return true;
}
// Check for --no-input flag (CI)
if (process.env.CCS_NO_INPUT === '1' || process.argv.includes('--no-input')) {
throw new Error('Interactive input required but --no-input specified');
}
// Non-TTY: use default
if (!process.stdin.isTTY) {
return defaultValue;
}
// Interactive prompt
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
terminal: true
});
const promptText = defaultValue
? `${message} [Y/n]: `
: `${message} [y/N]: `;
return new Promise((resolve) => {
rl.question(promptText, (answer) => {
rl.close();
const normalized = answer.trim().toLowerCase();
// Empty answer: use default
if (normalized === '') {
resolve(defaultValue);
return;
}
// Valid answers
if (normalized === 'y' || normalized === 'yes') {
resolve(true);
return;
}
if (normalized === 'n' || normalized === 'no') {
resolve(false);
return;
}
// Invalid input: retry
console.error('[!] Please answer y or n');
resolve(InteractivePrompt.confirm(message, options));
});
});
}
/**
* Get text input from user
* @param {string} message - Prompt message
* @param {Object} options - Options
* @param {string} options.default - Default value
* @param {Function} options.validate - Validation function
* @returns {Promise<string>} User input
*/
static async input(message, options = {}) {
const { default: defaultValue = '', validate = null } = options;
// Non-TTY: use default or error
if (!process.stdin.isTTY) {
if (defaultValue) {
return defaultValue;
}
throw new Error('Interactive input required but stdin is not a TTY');
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
terminal: true
});
const promptText = defaultValue
? `${message} [${defaultValue}]: `
: `${message}: `;
return new Promise((resolve) => {
rl.question(promptText, (answer) => {
rl.close();
const value = answer.trim() || defaultValue;
// Validate input if validator provided
if (validate) {
const error = validate(value);
if (error) {
console.error(`[!] ${error}`);
resolve(InteractivePrompt.input(message, options));
return;
}
}
resolve(value);
});
});
}
}
module.exports = { InteractivePrompt };
-256
View File
@@ -1,256 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
/**
* Shell Completion Installer
* Auto-configures shell completion for bash, zsh, fish, PowerShell
*/
class ShellCompletionInstaller {
constructor() {
this.homeDir = os.homedir();
this.ccsDir = path.join(this.homeDir, '.ccs');
this.completionDir = path.join(this.ccsDir, 'completions');
this.scriptsDir = path.join(__dirname, '../../scripts/completion');
}
/**
* Detect current shell
*/
detectShell() {
const shell = process.env.SHELL || '';
if (shell.includes('bash')) return 'bash';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('fish')) return 'fish';
if (process.platform === 'win32') return 'powershell';
return null;
}
/**
* Ensure completion files are in ~/.ccs/completions/
*/
ensureCompletionFiles() {
if (!fs.existsSync(this.completionDir)) {
fs.mkdirSync(this.completionDir, { recursive: true });
}
// Copy completion scripts
const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1'];
files.forEach(file => {
const src = path.join(this.scriptsDir, file);
const dest = path.join(this.completionDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
});
}
/**
* Safely create directory, checking for file conflicts
* @param {string} dirPath - Path to create
* @throws {Error} If path exists but is a file
*/
ensureDirectory(dirPath) {
if (fs.existsSync(dirPath)) {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
throw new Error(
`Cannot create directory: ${dirPath} exists but is a file.\n` +
`Please remove or rename this file and try again.`
);
}
// Directory exists, nothing to do
return;
}
// Check parent directories recursively
const parentDir = path.dirname(dirPath);
if (parentDir !== dirPath) {
this.ensureDirectory(parentDir);
}
// Create the directory
fs.mkdirSync(dirPath);
}
/**
* Install bash completion
*/
installBash() {
const rcFile = path.join(this.homeDir, '.bashrc');
const completionPath = path.join(this.completionDir, 'ccs.bash');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `source "${completionPath}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to .bashrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.bashrc'
};
}
/**
* Install zsh completion
*/
installZsh() {
const rcFile = path.join(this.homeDir, '.zshrc');
const completionPath = path.join(this.completionDir, 'ccs.zsh');
const zshCompDir = path.join(this.homeDir, '.zsh', 'completion');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create zsh completion directory (with file conflict checking)
this.ensureDirectory(zshCompDir);
// Copy to zsh completion directory
const destFile = path.join(zshCompDir, '_ccs');
fs.copyFileSync(completionPath, destFile);
const marker = '# CCS shell completion';
const setupCmds = [
'fpath=(~/.zsh/completion $fpath)',
'autoload -Uz compinit && compinit'
];
const block = `\n${marker}\n${setupCmds.join('\n')}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to .zshrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.zshrc'
};
}
/**
* Install fish completion
*/
installFish() {
const completionPath = path.join(this.completionDir, 'ccs.fish');
const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create fish completion directory (with file conflict checking)
this.ensureDirectory(fishCompDir);
// Copy to fish completion directory (fish auto-loads from here)
const destFile = path.join(fishCompDir, 'ccs.fish');
fs.copyFileSync(completionPath, destFile);
return {
success: true,
message: `Installed to ${destFile}`,
reload: 'Fish auto-loads completions (no reload needed)'
};
}
/**
* Install PowerShell completion
*/
installPowerShell() {
const profilePath = process.env.PROFILE || path.join(
this.homeDir,
'Documents',
'PowerShell',
'Microsoft.PowerShell_profile.ps1'
);
const completionPath = path.join(this.completionDir, 'ccs.ps1');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `. "${completionPath.replace(/\\/g, '\\\\')}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Create profile directory if needed (with file conflict checking)
const profileDir = path.dirname(profilePath);
this.ensureDirectory(profileDir);
// Check if already installed
if (fs.existsSync(profilePath)) {
const content = fs.readFileSync(profilePath, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to PowerShell profile
fs.appendFileSync(profilePath, block);
return {
success: true,
message: `Added to ${profilePath}`,
reload: '. $PROFILE'
};
}
/**
* Install for detected or specified shell
*/
install(shell = null) {
const targetShell = shell || this.detectShell();
if (!targetShell) {
throw new Error('Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell');
}
// Ensure completion files exist
this.ensureCompletionFiles();
// Install for target shell
switch (targetShell) {
case 'bash':
return this.installBash();
case 'zsh':
return this.installZsh();
case 'fish':
return this.installFish();
case 'powershell':
return this.installPowerShell();
default:
throw new Error(`Unsupported shell: ${targetShell}`);
}
}
}
module.exports = { ShellCompletionInstaller };
-243
View File
@@ -1,243 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const { colored } = require('./helpers');
const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json');
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest';
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@kaitranntt/ccs/latest';
const REQUEST_TIMEOUT = 5000; // 5 seconds
/**
* Compare semantic versions
* @param {string} v1 - First version (e.g., "4.1.6")
* @param {string} v2 - Second version
* @returns {number} - 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1, v2) {
const parts1 = v1.replace(/^v/, '').split('.').map(Number);
const parts2 = v2.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* Fetch latest version from GitHub releases
* @returns {Promise<string|null>} - Latest version or null on error
*/
function fetchLatestVersionFromGitHub() {
return new Promise((resolve) => {
const req = https.get(GITHUB_API_URL, {
headers: { 'User-Agent': 'CCS-Update-Checker' },
timeout: REQUEST_TIMEOUT
}, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const release = JSON.parse(data);
const version = release.tag_name?.replace(/^v/, '') || null;
resolve(version);
} catch (err) {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
});
}
/**
* Fetch latest version from npm registry
* @returns {Promise<string|null>} - Latest version or null on error
*/
function fetchLatestVersionFromNpm() {
return new Promise((resolve) => {
const req = https.get(NPM_REGISTRY_URL, {
headers: { 'User-Agent': 'CCS-Update-Checker' },
timeout: REQUEST_TIMEOUT
}, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const packageData = JSON.parse(data);
const version = packageData.version || null;
resolve(version);
} catch (err) {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
});
}
/**
* Read update check cache
* @returns {Object} - Cache object
*/
function readCache() {
try {
if (!fs.existsSync(UPDATE_CHECK_FILE)) {
return { last_check: 0, latest_version: null, dismissed_version: null };
}
const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8');
return JSON.parse(data);
} catch (err) {
return { last_check: 0, latest_version: null, dismissed_version: null };
}
}
/**
* Write update check cache
* @param {Object} cache - Cache object to write
*/
function writeCache(cache) {
try {
const ccsDir = path.join(os.homedir(), '.ccs');
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8');
} catch (err) {
// Silently fail - not critical
}
}
/**
* Check for updates (async, non-blocking)
* @param {string} currentVersion - Current CCS version
* @param {boolean} force - Force check even if within interval
* @param {string} installMethod - Installation method ('npm' or 'direct')
* @returns {Promise<Object>} - Update result object with status and data
*/
async function checkForUpdates(currentVersion, force = false, installMethod = 'direct') {
const cache = readCache();
const now = Date.now();
// Check if we should check for updates
if (!force && (now - cache.last_check < CHECK_INTERVAL)) {
// Use cached result if available
if (cache.latest_version && compareVersions(cache.latest_version, currentVersion) > 0) {
// Don't show if user dismissed this version
if (cache.dismissed_version === cache.latest_version) {
return { status: 'no_update', reason: 'dismissed' };
}
return { status: 'update_available', latest: cache.latest_version, current: currentVersion };
}
return { status: 'no_update', reason: 'cached' };
}
// Fetch latest version from appropriate source
let latestVersion;
let fetchError = null;
if (installMethod === 'npm') {
latestVersion = await fetchLatestVersionFromNpm();
if (!latestVersion) fetchError = 'npm_registry_error';
} else {
latestVersion = await fetchLatestVersionFromGitHub();
if (!latestVersion) fetchError = 'github_api_error';
}
// Update cache
cache.last_check = now;
if (latestVersion) {
cache.latest_version = latestVersion;
}
writeCache(cache);
// Handle fetch errors
if (fetchError) {
return {
status: 'check_failed',
reason: fetchError,
message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}`
};
}
// Check if update available
if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
// Don't show if user dismissed this version
if (cache.dismissed_version === latestVersion) {
return { status: 'no_update', reason: 'dismissed' };
}
return { status: 'update_available', latest: latestVersion, current: currentVersion };
}
return { status: 'no_update', reason: 'latest' };
}
/**
* Show update notification
* @param {Object} updateInfo - Update information
*/
function showUpdateNotification(updateInfo) {
console.log('');
console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
console.log(colored(` Update available: ${updateInfo.current}${updateInfo.latest}`, 'yellow'));
console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
console.log('');
console.log(` Run ${colored('ccs update', 'yellow')} to update`);
console.log('');
}
/**
* Dismiss update notification for a specific version
* @param {string} version - Version to dismiss
*/
function dismissUpdate(version) {
const cache = readCache();
cache.dismissed_version = version;
writeCache(cache);
}
module.exports = {
compareVersions,
checkForUpdates,
showUpdateNotification,
dismissUpdate,
readCache,
writeCache
};
+1 -1
View File
@@ -19,7 +19,7 @@
"ora": "^9.0.0"
},
"bin": {
"ccs": "bin/ccs.js"
"ccs": "dist/ccs.js"
},
"devDependencies": {
"@types/node": "^20.19.25",
+3 -3
View File
@@ -93,7 +93,7 @@ function createConfigFiles() {
// Migrate from v3.1.1 to v3.2.0 (symlink architecture)
console.log('');
try {
const SharedManager = require('../bin/management/shared-manager');
const SharedManager = require('../dist/management/shared-manager').default;
const sharedManager = new SharedManager();
sharedManager.migrateFromV311();
sharedManager.ensureSharedDirectories();
@@ -108,7 +108,7 @@ function createConfigFiles() {
// Copy .claude/ directory from package to ~/.ccs/.claude/ (v4.1.1)
try {
const ClaudeDirInstaller = require('../bin/utils/claude-dir-installer');
const ClaudeDirInstaller = require('../dist/utils/claude-dir-installer').default;
const installer = new ClaudeDirInstaller();
const packageDir = path.join(__dirname, '..');
installer.install(packageDir);
@@ -122,7 +122,7 @@ function createConfigFiles() {
// Install CCS items to ~/.claude/ (v4.1.0)
try {
const ClaudeSymlinkManager = require('../bin/utils/claude-symlink-manager');
const { ClaudeSymlinkManager } = require('../dist/utils/claude-symlink-manager');
const claudeSymlinkManager = new ClaudeSymlinkManager();
claudeSymlinkManager.install();
} catch (err) {
+1 -1
View File
@@ -512,7 +512,7 @@ class Doctor {
const spinner = ora('Checking CCS symlinks').start();
try {
const ClaudeSymlinkManager = require('../utils/claude-symlink-manager');
const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager');
const manager = new ClaudeSymlinkManager();
const health = manager.checkHealth();
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../bin/glmt-transformer');
const DeltaAccumulator = require('../bin/delta-accumulator');
const GlmtTransformer = require('../dist/glmt/glmt-transformer').default;
const { DeltaAccumulator } = require('../dist/glmt/delta-accumulator');
/**
* Token Counting Validation Tests
+1 -1
View File
@@ -5,7 +5,7 @@ const fs = require('fs');
const os = require('os');
describe('npm CLI', () => {
const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js');
const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js');
const ccsDir = path.join(os.homedir(), '.ccs');
const configPath = path.join(ccsDir, 'config.json');
+4 -4
View File
@@ -2,10 +2,10 @@ const assert = require('assert');
const path = require('path');
const os = require('os');
// Import the expandPath function from bin/utils/helpers.js
// Import the expandPath function from dist/utils/helpers.js
let expandPath;
try {
expandPath = require('../../bin/utils/helpers').expandPath;
expandPath = require('../../dist/utils/helpers').expandPath;
} catch (e) {
// If helpers module doesn't exist or doesn't export expandPath, create a mock
expandPath = function(p) {
@@ -110,9 +110,9 @@ describe('cross-platform', () => {
describe('npm package structure', () => {
it('has required executable files', () => {
const fs = require('fs');
const binDir = path.join(__dirname, '..', '..', 'bin');
const distDir = path.join(__dirname, '..', '..', 'dist');
assert(fs.existsSync(path.join(binDir, 'ccs.js')), 'ccs.js should exist in bin directory');
assert(fs.existsSync(path.join(distDir, 'ccs.js')), 'ccs.js should exist in dist directory');
});
it('has required script files', () => {
+1 -1
View File
@@ -3,7 +3,7 @@ const { execSync } = require('child_process');
const path = require('path');
describe('integration: special commands', () => {
const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js');
const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js');
it('shows version with --version', () => {
const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' });
+1 -1
View File
@@ -1,7 +1,7 @@
const assert = require('assert');
const path = require('path');
const os = require('os');
const { expandPath } = require('../../../bin/utils/helpers');
const { expandPath } = require('../../../dist/utils/helpers');
describe('helpers', () => {
describe('expandPath', () => {
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const { HeadlessExecutor } = require('../../../bin/delegation/headless-executor');
const { HeadlessExecutor } = require('../../../dist/delegation/headless-executor');
/**
* Test runner
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const { ResultFormatter } = require('../../../bin/delegation/result-formatter');
const { ResultFormatter } = require('../../../dist/delegation/result-formatter');
/**
* Simple test runner (no external dependencies)
@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { SessionManager } = require('../../../bin/delegation/session-manager');
const { SessionManager } = require('../../../dist/delegation/session-manager');
/**
* Test runner
@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { SettingsParser } = require('../../../bin/delegation/settings-parser');
const { SettingsParser } = require('../../../dist/delegation/settings-parser');
/**
* Test runner
+1 -1
View File
@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
/**
* Manual test for debug mode file logging
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const DeltaAccumulator = require('../../../bin/glmt/delta-accumulator');
const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator');
console.log('[TEST] DeltaAccumulator unit tests');
console.log('');
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
/**
* Simple test runner (no external dependencies)
+1 -1
View File
@@ -11,7 +11,7 @@
*/
const assert = require('assert');
const LocaleEnforcer = require('../../../bin/glmt/locale-enforcer');
const LocaleEnforcer = require('../../../dist/glmt/locale-enforcer').default;
describe('LocaleEnforcer', () => {
describe('Scenario 1: English prompt → English output', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
console.log('=== Performance Test: Debug Mode Impact ===\n');
+1 -1
View File
@@ -13,7 +13,7 @@
* 6. Edge cases (empty messages, no system/user)
*/
const ReasoningEnforcer = require('../../../bin/glmt/reasoning-enforcer');
const ReasoningEnforcer = require('../../../dist/glmt/reasoning-enforcer').default;
/**
* Simple test runner (no external dependencies)
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const SSEParser = require('../../../bin/glmt/sse-parser');
const SSEParser = require('../../../dist/glmt/sse-parser').default;
console.log('[TEST] SSEParser unit tests');
console.log('');
+1 -1
View File
@@ -6,7 +6,7 @@
* Tests different message formats to understand the bug
*/
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
const transformer = new GlmtTransformer({ verbose: true });
@@ -10,8 +10,8 @@
* Fix: Guard against empty content, return null if block.content is empty
*/
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const DeltaAccumulator = require('../../../bin/glmt/delta-accumulator');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator');
// Test runner
class TestRunner {
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
const GlmtTransformer = require('../../../bin/glmt/glmt-transformer');
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
console.log('=== Demo: Verbose Output with Reasoning Detection ===\n');