mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 12:20:44 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,191 +0,0 @@
|
||||
# CCS Delegation Module
|
||||
|
||||
Enhanced Claude Code delegation system for multi-model task delegation.
|
||||
|
||||
## Files
|
||||
|
||||
### Core Components
|
||||
- **headless-executor.js** (405 lines) - Main executor, spawns `claude -p` with enhanced features
|
||||
- **session-manager.js** (156 lines) - Session persistence and cost tracking
|
||||
- **settings-parser.js** (88 lines) - Parse tool restrictions from settings
|
||||
- **result-formatter.js** (326 lines) - Terminal output formatting
|
||||
|
||||
**Total**: 975 lines (down from 1,755 lines - 44% reduction)
|
||||
|
||||
## Features
|
||||
|
||||
### Enhanced Headless Execution
|
||||
- Stream-JSON output parsing (`--output-format stream-json`)
|
||||
- Real-time tool use visibility in TTY
|
||||
- Permission mode acceptEdits (`--permission-mode acceptEdits`)
|
||||
- Tool restrictions from `.claude/settings.local.json`
|
||||
- Multi-turn session management (`--resume <session-id>`)
|
||||
- Time-based limits (10 min default timeout with graceful termination)
|
||||
- Cost tracking and aggregation
|
||||
|
||||
### Session Management
|
||||
- Persistence: `~/.ccs/delegation-sessions.json`
|
||||
- Resume via `/ccs:continue` (auto-detects profile)
|
||||
- Auto-cleanup expired sessions (>30 days)
|
||||
- Cost aggregation across turns
|
||||
|
||||
### Settings
|
||||
- Profile location: `~/.ccs/{profile}.settings.json`
|
||||
- Examples: `glm.settings.json`, `kimi.settings.json`, `glmt.settings.json`
|
||||
- Tool restrictions from `.claude/settings.local.json`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Delegation
|
||||
```javascript
|
||||
const { HeadlessExecutor } = require('./headless-executor');
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'Refactor auth.js', {
|
||||
cwd: '/path/to/project',
|
||||
outputFormat: 'stream-json',
|
||||
permissionMode: 'acceptEdits',
|
||||
timeout: 600000 // 10 minutes
|
||||
});
|
||||
|
||||
console.log(result.sessionId); // For multi-turn
|
||||
console.log(result.totalCost); // Cost in USD
|
||||
console.log(result.content); // Result text
|
||||
```
|
||||
|
||||
### Multi-Turn Sessions
|
||||
```javascript
|
||||
// Start session
|
||||
const result1 = await HeadlessExecutor.execute('glm', 'Implement feature');
|
||||
const sessionId = result1.sessionId;
|
||||
|
||||
// Continue session
|
||||
const result2 = await HeadlessExecutor.execute('glm', 'Add tests', {
|
||||
resumeSession: true
|
||||
});
|
||||
|
||||
// Or with specific session ID
|
||||
const result3 = await HeadlessExecutor.execute('glm', 'Run tests', {
|
||||
sessionId: sessionId
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Restrictions
|
||||
Create `.claude/settings.local.json`:
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(git:*)", "Read", "Edit"],
|
||||
"deny": ["Bash(rm:*)", "Bash(sudo:*)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Automatically applied as CLI flags:
|
||||
```bash
|
||||
--allowedTools "Bash(git:*)" "Read" "Edit" \
|
||||
--disallowedTools "Bash(rm:*)" "Bash(sudo:*)"
|
||||
```
|
||||
|
||||
## Slash Commands
|
||||
|
||||
The delegation system is invoked via simple slash commands in `.claude/commands/ccs/`:
|
||||
|
||||
### Basic Commands
|
||||
- `/ccs "task"` - Delegate task (auto-selects best profile)
|
||||
- `/ccs --glm "task"` - Force GLM-4.6 delegation
|
||||
- `/ccs --kimi "task"` - Force Kimi delegation (long-context)
|
||||
|
||||
### Session Continuation
|
||||
- `/ccs:continue "follow-up"` - Resume last delegation session (auto-detect profile)
|
||||
- `/ccs:continue --glm "follow-up"` - Resume with specific profile switch
|
||||
|
||||
Each command directly invokes:
|
||||
```bash
|
||||
claude -p "$ARGUMENTS" \
|
||||
--settings ~/.ccs/{profile}.settings.json \
|
||||
--output-format stream-json \
|
||||
--permission-mode acceptEdits
|
||||
```
|
||||
|
||||
## Debug Mode
|
||||
|
||||
```bash
|
||||
export CCS_DEBUG=1
|
||||
```
|
||||
|
||||
Enables verbose logging:
|
||||
- Permission mode selection
|
||||
- Session resumption details
|
||||
- Tool restrictions parsing
|
||||
- CLI args construction
|
||||
- Session persistence events
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all delegation tests
|
||||
node tests/unit/delegation/json-output.test.js
|
||||
node tests/unit/delegation/permission-mode.test.js
|
||||
node tests/unit/delegation/session-manager.test.js
|
||||
node tests/unit/delegation/settings-parser.test.js
|
||||
node tests/unit/delegation/max-turns.test.js
|
||||
node tests/unit/delegation/result-formatter.test.js
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
- JSON output parsing (6 tests)
|
||||
- Permission modes (11 tests)
|
||||
- Session management (7 tests)
|
||||
- Settings parser (6 tests)
|
||||
- Auto max-turns (14 tests)
|
||||
- Result formatting (14 tests)
|
||||
- **Total: 58 tests**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User → SlashCommand (/ccs)
|
||||
→ ccs-delegation skill (auto-selects profile)
|
||||
→ Directly invokes: claude -p
|
||||
→ HeadlessExecutor (monitors execution)
|
||||
→ SessionManager (load last session)
|
||||
→ SettingsParser (tool restrictions)
|
||||
→ Parse JSON response
|
||||
→ SessionManager (store/update)
|
||||
→ ResultFormatter.format()
|
||||
→ Display to user
|
||||
```
|
||||
|
||||
**Key Simplification**: Slash commands invoke `claude -p` directly. No intermediate delegation engine or rule system - just direct headless execution with enhanced features.
|
||||
|
||||
## File Permissions
|
||||
|
||||
All files should be `644` (rw-r--r--):
|
||||
```bash
|
||||
chmod 644 bin/delegation/*.js
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Node.js 14+
|
||||
- Claude CLI installed and in PATH
|
||||
- Profile settings configured in `~/.ccs/{profile}.settings.json`
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
**Removed components** (as of 2025-11-15):
|
||||
- ~~delegation-engine.js~~ - Rule-based decision engine (unused)
|
||||
- ~~cwd-resolver.js~~ - Working directory resolution (unused)
|
||||
- ~~rules-schema.js~~ - Schema validation (unused)
|
||||
- ~~delegation-rules.json~~ - Configuration file (not created)
|
||||
|
||||
**Why removed**: Current slash commands directly invoke `claude -p` without intermediate orchestration. The delegation engine, CWD resolver, and rules schema were designed for a more complex system that was never fully integrated.
|
||||
|
||||
**Result**: 44% code reduction (1,755 → 975 lines) with same functionality.
|
||||
|
||||
## References
|
||||
|
||||
- Official docs: https://code.claude.com/docs/en/headless.md
|
||||
- Skill: `.claude/skills/ccs-delegation/`
|
||||
- Commands: `.claude/commands/ccs/`
|
||||
- Tests: `tests/unit/delegation/`
|
||||
@@ -1,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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,103 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { error, expandPath } = require('./helpers');
|
||||
const { ErrorManager } = require('./error-manager');
|
||||
|
||||
// Get config file path
|
||||
function getConfigPath() {
|
||||
return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json');
|
||||
}
|
||||
|
||||
// Read and parse config
|
||||
function readConfig() {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Check config exists
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// Attempt recovery
|
||||
const RecoveryManager = require('./recovery-manager');
|
||||
const recovery = new RecoveryManager();
|
||||
recovery.ensureConfigJson();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
ErrorManager.showInvalidConfig(configPath, 'File not found');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse JSON
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (e) {
|
||||
ErrorManager.showInvalidConfig(configPath, `Invalid JSON: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate config has profiles object
|
||||
if (!config.profiles || typeof config.profiles !== 'object') {
|
||||
ErrorManager.showInvalidConfig(configPath, "Missing 'profiles' object");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Get settings path for profile
|
||||
function getSettingsPath(profile) {
|
||||
const config = readConfig();
|
||||
|
||||
// Get settings path
|
||||
const settingsPath = config.profiles[profile];
|
||||
|
||||
if (!settingsPath) {
|
||||
const availableProfiles = Object.keys(config.profiles);
|
||||
const profileList = availableProfiles.map(p => ` - ${p}`);
|
||||
ErrorManager.showProfileNotFound(profile, profileList);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Expand path
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
|
||||
// Validate settings file exists
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
// Auto-create if it's ~/.claude/settings.json
|
||||
if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) {
|
||||
const RecoveryManager = require('./recovery-manager');
|
||||
const recovery = new RecoveryManager();
|
||||
recovery.ensureClaudeSettings();
|
||||
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
ErrorManager.showSettingsNotFound(expandedPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[i] Auto-created missing settings file');
|
||||
} else {
|
||||
ErrorManager.showSettingsNotFound(expandedPath);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings file is valid JSON
|
||||
try {
|
||||
const settingsContent = fs.readFileSync(expandedPath, 'utf8');
|
||||
JSON.parse(settingsContent);
|
||||
} catch (e) {
|
||||
ErrorManager.showInvalidConfig(expandedPath, `Invalid JSON: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return expandedPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigPath,
|
||||
readConfig,
|
||||
getSettingsPath
|
||||
};
|
||||
@@ -1,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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
Generated
+1
-1
@@ -19,7 +19,7 @@
|
||||
"ora": "^9.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"ccs": "bin/ccs.js"
|
||||
"ccs": "dist/ccs.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.25",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
describe('integration: special commands', () => {
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js');
|
||||
const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js');
|
||||
|
||||
it('shows version with --version', () => {
|
||||
const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { expandPath } = require('../../../bin/utils/helpers');
|
||||
const { expandPath } = require('../../../dist/utils/helpers');
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('expandPath', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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)
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
|
||||
@@ -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,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('');
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user